mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
* docs(plugins): add microsoft 365 calendar provider plan * docs(plugins): add microsoft 365 calendar provider plan * docs(ios): plan internal testflight builds * fix(sync): recreate cascaded tasks when a deleteProject loses LWW (#8997) When a remote deleteProject loses an LWW conflict, its reducer cascade (tasks removed via removeMany(allTaskIds)) is not compensated, so the project resurfaces empty and its tasks are lost on every client that applied the delete and on this client's own hydration replay. Mirror the TASK-parent recovery from #8990 for PROJECT cascades: _createTaskRecreationOpsForWinningProject emits recreate-after-delete TASK snapshots for every still-present task in the delete payload's allTaskIds, plus relationship/membership patch ops so the exact regular/backlog lists and subtask links are restored after the entities exist. enrichDeleteProjectAction expands a stale delete through current project relationships on replay; the lww meta-reducer validates recreate rows against present parents/projects. Covers part (a) of #8997. Parts (b) local-delete-loses and (c) notes/archived-tasks remain open. * fix(sync): make deleteProject-loser recovery interruption-safe (#8997) Hardens the base #8997 recovery for cases where a recreate-after-delete row is itself caught in a later conflict or delivered out of order. - createTaskRecreationFollowUpOps re-emits parent/subtask relationships and PROJECT membership when a recovery row is rejected and replaced, so independent server acceptance cannot drop parent/child links or append a backlog task to the regular list. - _createRemoteWinCompensationForRejectedTaskRecreation reconstructs a local snapshot when a remote TASK winner (move-to-project or a field-safe update) beats a local recovery row, then restores its dependents; opaque/relationship-changing remote winners bail out. - The superseded-op resolver re-emits the same follow-ups and persists each replacement group atomically before retiring the stale rows. - Preserve the recreate guard when a recovery row wins a later conflict. Load-bearing, not gold-plating: with this layer stubbed, 8 unit tests plus the move-winner half of the persistence convergence test fail. * fix(sync): don't resurrect concurrently-deleted tasks in project recovery (#8997) Adversarial review of the #8997 recovery found two cross-device residuals in _createTaskRecreationOpsForWinningProject: 1. (HIGH) The recovery reads task presence from the pre-batch store, so it was blind to a delete piggybacked as a non-conflicting op in the same sync batch. Device C deletes task t2 while device A wins a project rename vs B's deleteProject; A recreated t2, resurrecting it (via a borrowed newer timestamp) on every client that applied C's delete while A's own delete won locally — a silent split-brain. _collectDeletedTaskIds now gathers the batch's deleted TASK ids (single + multi-entity) and recovery skips them. 2. (MEDIUM) Recreations borrowed the project's timestamp as their LWW proxy, which is unrelated to task content and could clobber a CONCURRENT edit on another device. They now use each task's own `modified` (fallback: the project timestamp). Clock domination over the delete is unaffected. Both are proven by new failing-first specs. Note: the sibling subtask path (_createSubtaskRecreationOpsForWinningParent, #8956) shares the same same-batch-delete blindness and is a candidate follow-up. * fix(sync): keep bulk-deleted tasks deleted in project recovery (#8997) The deleteProject-loser recovery skips tasks a concurrent non-conflicting op is deleting in the same batch, so it doesn't resurrect them on clients that applied the delete. Its `_collectDeletedTaskIds` guard only read `op.entityId`, but a bulk `deleteTasks` (TASK_SHARED_DELETE_MULTIPLE) op carries every id in `entityIds` and mirrors only the first to `entityId`, with an empty `entityChanges`. So every id after the first was missed and recreated with a borrowed newer timestamp — the same split-brain the single-delete guard closes, via the bulk path. Union both id sources via `getOpEntityIds` (already used throughout this file for the identical reason). Adds a failing-first regression test mirroring the single-delete case with an entityIds-carrying bulk delete. * fix(sync): exclude conflict-won deletes from project recovery (#8997) The deleteProject-loser recovery excludes tasks a non-conflicting op is deleting in the same batch, but a task can also be deleted by its OWN LWW conflict — this client held a competing edit that lost, and the remote delete won. Such a delete is applied this batch yet never reaches `nonConflictingOps`, so recovery read the task from the pre-batch store and re-emitted a recreation for a deletion that had just won. Fold the remote-delete winners of resolved conflicts into the same `_collectDeletedTaskIds` guard so recovery skips them too. Reusing that helper means bulk deleteTasks winners are covered as well. Failing-first regression test added. * test(sync): add replay-convergence tests for project-recovery delete guards (#8997) The concurrent-delete guards were covered only by emission assertions (was the recovery row emitted/skipped) — which cannot catch a resurrection that only manifests after replay. These two real-store integration tests apply the recovery ops on a fresh client that also applied the concurrent delete and assert the deleted task stays deleted: - bulk deleteTasks: every trailing entityIds entry stays deleted - a task whose own LWW conflict the remote delete won stays deleted Both fail (task resurrected) when the respective guard is reverted, confirming they exercise the divergence, not just op emission. The second also disproves the "borrowed modified timestamp self-corrects" theory: a recreation dominates the delete by clock/seq, so the task is resurrected without the guard.
This commit is contained in:
parent
29df7e7719
commit
7e273a0e5c
15 changed files with 2789 additions and 43 deletions
|
|
@ -754,6 +754,22 @@ describe('operation-converter utility', () => {
|
|||
).toBe('replace');
|
||||
});
|
||||
|
||||
it('should expose recreate-after-delete metadata (#8997)', () => {
|
||||
const op = createMockOperation({
|
||||
actionType: '[TASK] LWW Update' as ActionType,
|
||||
payload: {
|
||||
actionPayload: { id: 'task-1', projectId: 'project-1' },
|
||||
entityChanges: [],
|
||||
lwwUpdateMode: 'replace',
|
||||
recreatesEntityAfterDelete: true,
|
||||
},
|
||||
});
|
||||
|
||||
const action = convertOpToAction(op);
|
||||
|
||||
expect(action.meta.recreatesEntityAfterDelete).toBeTrue();
|
||||
});
|
||||
|
||||
it('should fall back to legacy payload format when not MultiEntityPayload', () => {
|
||||
const op = createMockOperation({
|
||||
payload: { directProperty: 'value', nested: { prop: 123 } },
|
||||
|
|
|
|||
|
|
@ -322,6 +322,9 @@ export const convertOpToAction = (op: Operation): PersistentAction => {
|
|||
...(isLwwUpdatePayload(op.payload)
|
||||
? { lwwUpdateMode: op.payload.lwwUpdateMode }
|
||||
: {}),
|
||||
...(isLwwUpdatePayload(op.payload) && op.payload.recreatesEntityAfterDelete === true
|
||||
? { recreatesEntityAfterDelete: true }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export interface PersistentActionMeta {
|
|||
isApplyingFromOtherClient?: boolean;
|
||||
isBulk?: boolean; // TRUE for batch operations
|
||||
lwwUpdateMode?: LwwUpdateMode;
|
||||
recreatesEntityAfterDelete?: boolean;
|
||||
}
|
||||
|
||||
export interface PersistentAction extends Action {
|
||||
|
|
|
|||
|
|
@ -144,7 +144,11 @@ describe('ConflictResolutionService persistence (integration, real store)', () =
|
|||
const taskReplayReducer = bulkOperationsMetaReducer(
|
||||
createCombinedTaskSharedMetaReducer(lwwUpdateMetaReducer(taskRootReducer)),
|
||||
) as ActionReducer<RootState, Action>;
|
||||
const createTaskReplayState = (tasks: Task[], projectTaskIds: string[]): RootState => {
|
||||
const createTaskReplayState = (
|
||||
tasks: Task[],
|
||||
projectTaskIds: string[],
|
||||
projectBacklogTaskIds: string[] = [],
|
||||
): RootState => {
|
||||
const state = createBaseState();
|
||||
const project = state[PROJECT_FEATURE_NAME].entities.project1;
|
||||
if (!project) {
|
||||
|
|
@ -164,7 +168,7 @@ describe('ConflictResolutionService persistence (integration, real store)', () =
|
|||
project1: {
|
||||
...project,
|
||||
taskIds: projectTaskIds,
|
||||
backlogTaskIds: [],
|
||||
backlogTaskIds: projectBacklogTaskIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -678,6 +682,631 @@ describe('ConflictResolutionService persistence (integration, real store)', () =
|
|||
.toEqual(expectedShape);
|
||||
});
|
||||
|
||||
it('converges an outright-losing remote deleteProject with exact task membership (#8997)', async () => {
|
||||
const regularTaskId = 'regular-task';
|
||||
const backlogTaskId = 'backlog-task';
|
||||
const subtaskId = 'subtask';
|
||||
const regularTask: Task = {
|
||||
...DEFAULT_TASK,
|
||||
id: regularTaskId,
|
||||
title: 'Regular task',
|
||||
projectId: 'project1',
|
||||
subTaskIds: [subtaskId],
|
||||
} as Task;
|
||||
const backlogTask: Task = {
|
||||
...DEFAULT_TASK,
|
||||
id: backlogTaskId,
|
||||
title: 'Backlog task',
|
||||
projectId: 'project1',
|
||||
subTaskIds: [],
|
||||
} as Task;
|
||||
const subtask: Task = {
|
||||
...DEFAULT_TASK,
|
||||
id: subtaskId,
|
||||
title: 'Project subtask',
|
||||
projectId: 'project1',
|
||||
parentId: regularTaskId,
|
||||
subTaskIds: [],
|
||||
} as Task;
|
||||
const initialTaskState = createTaskReplayState(
|
||||
[regularTask, backlogTask, subtask],
|
||||
[regularTaskId],
|
||||
[backlogTaskId],
|
||||
);
|
||||
const project = initialTaskState[PROJECT_FEATURE_NAME].entities.project1;
|
||||
if (!project) {
|
||||
throw new Error('Test fixture project1 is missing.');
|
||||
}
|
||||
|
||||
const localProjectEdit: Operation = {
|
||||
id: 'local-project-edit',
|
||||
actionType: toLwwUpdateActionType('PROJECT'),
|
||||
opType: OpType.Update,
|
||||
entityType: 'PROJECT',
|
||||
entityId: 'project1',
|
||||
payload: {
|
||||
actionPayload: project as unknown as Record<string, unknown>,
|
||||
entityChanges: [],
|
||||
lwwUpdateMode: 'replace',
|
||||
},
|
||||
clientId: LOCAL_CLIENT_ID,
|
||||
vectorClock: { [LOCAL_CLIENT_ID]: 1 },
|
||||
timestamp: 2_000,
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
};
|
||||
const remoteProjectDelete: Operation = {
|
||||
id: 'remote-project-delete',
|
||||
actionType: ActionType.TASK_SHARED_DELETE_PROJECT,
|
||||
opType: OpType.Delete,
|
||||
entityType: 'PROJECT',
|
||||
entityId: 'project1',
|
||||
payload: {
|
||||
actionPayload: {
|
||||
projectId: 'project1',
|
||||
// Stale deleting client knew only this root. Replay expands through
|
||||
// current project relationships; recovery must recreate the same set.
|
||||
allTaskIds: [regularTaskId],
|
||||
noteIds: [],
|
||||
},
|
||||
entityChanges: [],
|
||||
},
|
||||
clientId: REMOTE_CLIENT_ID,
|
||||
vectorClock: { [REMOTE_CLIENT_ID]: 1 },
|
||||
timestamp: 1_000,
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
};
|
||||
store.select.and.callFake((_selector: unknown, props?: { id?: string }) =>
|
||||
of(
|
||||
props?.id === 'project1'
|
||||
? project
|
||||
: props?.id === regularTaskId
|
||||
? regularTask
|
||||
: props?.id === backlogTaskId
|
||||
? backlogTask
|
||||
: props?.id === subtaskId
|
||||
? subtask
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
await opLogStore.append(localProjectEdit, 'local');
|
||||
|
||||
await service.autoResolveConflictsLWW([
|
||||
{
|
||||
entityType: 'PROJECT',
|
||||
entityId: 'project1',
|
||||
localOps: [localProjectEdit],
|
||||
remoteOps: [remoteProjectDelete],
|
||||
suggestedResolution: 'manual',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(operationApplier.applyOperations).not.toHaveBeenCalled();
|
||||
|
||||
const storedEntries = await opLogStore.getOpsAfterSeq(0);
|
||||
const compensationOps = storedEntries
|
||||
.filter(({ op, source }) => source === 'local' && op.id !== localProjectEdit.id)
|
||||
.map(({ op }) => op);
|
||||
expect(compensationOps.filter(({ entityType }) => entityType === 'TASK').length).toBe(
|
||||
4,
|
||||
);
|
||||
expect(storedEntries.map(({ op }) => op.id)).toEqual([
|
||||
localProjectEdit.id,
|
||||
remoteProjectDelete.id,
|
||||
...compensationOps.map(({ id }) => id),
|
||||
]);
|
||||
|
||||
const restartedClientState = applyTaskOperations(
|
||||
initialTaskState,
|
||||
storedEntries.map(({ op }) => op),
|
||||
LOCAL_CLIENT_ID,
|
||||
);
|
||||
let remoteClientState = applyTaskOperations(
|
||||
initialTaskState,
|
||||
[remoteProjectDelete],
|
||||
REMOTE_CLIENT_ID,
|
||||
);
|
||||
for (const compensationOp of compensationOps) {
|
||||
remoteClientState = applyTaskOperations(
|
||||
remoteClientState,
|
||||
[compensationOp],
|
||||
REMOTE_CLIENT_ID,
|
||||
);
|
||||
}
|
||||
|
||||
const assertExactWinningState = (state: RootState, context: string): void => {
|
||||
expect(taskShape(state)).withContext(context).toEqual(taskShape(initialTaskState));
|
||||
expect(state[PROJECT_FEATURE_NAME].entities.project1?.taskIds)
|
||||
.withContext(`${context}: regular task order`)
|
||||
.toEqual([regularTaskId]);
|
||||
expect(state[PROJECT_FEATURE_NAME].entities.project1?.backlogTaskIds)
|
||||
.withContext(`${context}: backlog task order`)
|
||||
.toEqual([backlogTaskId]);
|
||||
};
|
||||
assertExactWinningState(initialTaskState, 'live state (nothing applied)');
|
||||
assertExactWinningState(restartedClientState, 'restart');
|
||||
assertExactWinningState(remoteClientState, 'originating client');
|
||||
|
||||
const projectCompensations = compensationOps.filter(
|
||||
({ entityType }) => entityType === 'PROJECT',
|
||||
);
|
||||
const taskRecreations = compensationOps.filter(
|
||||
({ entityType }) => entityType === 'TASK',
|
||||
);
|
||||
const laterProjectDelete: Operation = {
|
||||
...remoteProjectDelete,
|
||||
id: 'later-project-delete',
|
||||
clientId: 'third-client',
|
||||
// This client saw only the first uploaded recreation before deleting the
|
||||
// partially restored project. Later task compensations must not survive.
|
||||
payload: {
|
||||
actionPayload: {
|
||||
projectId: 'project1',
|
||||
allTaskIds: [regularTaskId],
|
||||
noteIds: [],
|
||||
},
|
||||
entityChanges: [],
|
||||
},
|
||||
vectorClock: {
|
||||
[LOCAL_CLIENT_ID]: 2,
|
||||
[REMOTE_CLIENT_ID]: 1,
|
||||
['third-client']: 1,
|
||||
},
|
||||
timestamp: 3_000,
|
||||
};
|
||||
const partiallyAcceptedServerState = applyTaskOperations(
|
||||
initialTaskState,
|
||||
[
|
||||
remoteProjectDelete,
|
||||
projectCompensations[0],
|
||||
taskRecreations[0],
|
||||
laterProjectDelete,
|
||||
...taskRecreations.slice(1),
|
||||
// The final PROJECT compensation conflicts with laterProjectDelete and
|
||||
// is rejected, while later TASK rows are independently accepted.
|
||||
],
|
||||
'fresh-client',
|
||||
);
|
||||
|
||||
expect(partiallyAcceptedServerState[PROJECT_FEATURE_NAME].entities.project1)
|
||||
.withContext('later delete keeps the partially restored project deleted')
|
||||
.toBeUndefined();
|
||||
expect(partiallyAcceptedServerState[TASK_FEATURE_NAME].ids)
|
||||
.withContext('later task compensations cannot outlive their deleted project')
|
||||
.toEqual([]);
|
||||
|
||||
const taskReplacementOps = taskRecreations.filter(
|
||||
({ payload }) =>
|
||||
(payload as { lwwUpdateMode?: string }).lwwUpdateMode === 'replace',
|
||||
);
|
||||
const taskRelationshipOps = taskRecreations.filter(
|
||||
({ payload }) => (payload as { lwwUpdateMode?: string }).lwwUpdateMode === 'patch',
|
||||
);
|
||||
const alreadyAcceptedTaskServerState = applyTaskOperations(
|
||||
initialTaskState,
|
||||
[
|
||||
remoteProjectDelete,
|
||||
projectCompensations[0],
|
||||
...taskReplacementOps,
|
||||
// This delete was authored from a stale prefix that knew only about
|
||||
// regularTaskId. Its reducer must also follow the project's currently
|
||||
// established root/child relationships, without scanning all tasks.
|
||||
laterProjectDelete,
|
||||
...taskRelationshipOps,
|
||||
],
|
||||
'fresh-client',
|
||||
);
|
||||
|
||||
expect(alreadyAcceptedTaskServerState[PROJECT_FEATURE_NAME].entities.project1)
|
||||
.withContext('stale later delete still removes the restored project')
|
||||
.toBeUndefined();
|
||||
expect(alreadyAcceptedTaskServerState[TASK_FEATURE_NAME].ids)
|
||||
.withContext('stale later delete removes already accepted roots and children')
|
||||
.toEqual([]);
|
||||
|
||||
const project2 = { ...project, id: 'project2', taskIds: [], backlogTaskIds: [] };
|
||||
const initialStateWithMoveTarget: RootState = {
|
||||
...initialTaskState,
|
||||
[PROJECT_FEATURE_NAME]: {
|
||||
...initialTaskState[PROJECT_FEATURE_NAME],
|
||||
ids: [...(initialTaskState[PROJECT_FEATURE_NAME].ids as string[]), 'project2'],
|
||||
entities: {
|
||||
...initialTaskState[PROJECT_FEATURE_NAME].entities,
|
||||
project2,
|
||||
},
|
||||
},
|
||||
};
|
||||
const remoteMove: Operation = {
|
||||
id: 'remote-parent-move',
|
||||
actionType: ActionType.TASK_SHARED_MOVE_TO_PROJECT,
|
||||
opType: OpType.Update,
|
||||
entityType: 'TASK',
|
||||
entityId: regularTaskId,
|
||||
payload: {
|
||||
actionPayload: {
|
||||
task: { ...regularTask, subTasks: [subtask] },
|
||||
targetProjectId: 'project2',
|
||||
},
|
||||
entityChanges: [],
|
||||
},
|
||||
clientId: 'third-client',
|
||||
vectorClock: { ['third-client']: 2 },
|
||||
timestamp: 4_000,
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
};
|
||||
const parentRecreation = taskRecreations.find(
|
||||
({ entityId, payload }) =>
|
||||
entityId === regularTaskId &&
|
||||
(payload as { lwwUpdateMode?: string }).lwwUpdateMode === 'replace',
|
||||
);
|
||||
if (!parentRecreation) {
|
||||
throw new Error('Expected the parent TASK recreation.');
|
||||
}
|
||||
const originalLocalOpIds = new Set(
|
||||
storedEntries.filter(({ source }) => source === 'local').map(({ op }) => op.id),
|
||||
);
|
||||
const movedRegularTask = { ...regularTask, projectId: 'project2' };
|
||||
const movedSubtask = { ...subtask, projectId: 'project2' };
|
||||
store.select.and.callFake((_selector: unknown, props?: { id?: string }) =>
|
||||
of(
|
||||
props?.id === regularTaskId
|
||||
? movedRegularTask
|
||||
: props?.id === subtaskId
|
||||
? movedSubtask
|
||||
: props?.id === 'project2'
|
||||
? { ...project2, taskIds: [regularTaskId] }
|
||||
: props?.id === 'project1'
|
||||
? { ...project, taskIds: [], backlogTaskIds: [backlogTaskId] }
|
||||
: props?.id === backlogTaskId
|
||||
? backlogTask
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
|
||||
await service.autoResolveConflictsLWW([
|
||||
{
|
||||
entityType: 'TASK',
|
||||
entityId: regularTaskId,
|
||||
localOps: [parentRecreation],
|
||||
remoteOps: [remoteMove],
|
||||
suggestedResolution: 'manual',
|
||||
},
|
||||
]);
|
||||
|
||||
const remoteWinnerCompensations = (await opLogStore.getOpsAfterSeq(0))
|
||||
.filter(({ op, source }) => source === 'local' && !originalLocalOpIds.has(op.id))
|
||||
.map(({ op }) => op);
|
||||
expect(remoteWinnerCompensations.length).toBeGreaterThan(0);
|
||||
const moveWinnerState = applyTaskOperations(
|
||||
initialStateWithMoveTarget,
|
||||
[
|
||||
remoteProjectDelete,
|
||||
// The move is accepted first but cannot recreate the task removed by
|
||||
// deleteProject on a fresh client.
|
||||
remoteMove,
|
||||
projectCompensations[0],
|
||||
// The parent recovery is rejected. Independently accepted sibling rows
|
||||
// must stay harmless until the reconstructive remote-winner group lands.
|
||||
...taskRecreations.filter(({ entityId }) => entityId !== regularTaskId),
|
||||
projectCompensations[projectCompensations.length - 1],
|
||||
...remoteWinnerCompensations,
|
||||
],
|
||||
'fresh-client',
|
||||
);
|
||||
|
||||
expect(moveWinnerState[TASK_FEATURE_NAME].entities[regularTaskId]?.projectId).toBe(
|
||||
'project2',
|
||||
);
|
||||
expect(moveWinnerState[TASK_FEATURE_NAME].entities[subtaskId]?.projectId).toBe(
|
||||
'project2',
|
||||
);
|
||||
expect(
|
||||
moveWinnerState[TASK_FEATURE_NAME].entities[regularTaskId]?.subTaskIds,
|
||||
).toEqual([subtaskId]);
|
||||
expect(moveWinnerState[TASK_FEATURE_NAME].entities[subtaskId]?.parentId).toBe(
|
||||
regularTaskId,
|
||||
);
|
||||
expect(moveWinnerState[PROJECT_FEATURE_NAME].entities.project1?.taskIds).toEqual([]);
|
||||
expect(
|
||||
moveWinnerState[PROJECT_FEATURE_NAME].entities.project1?.backlogTaskIds,
|
||||
).toEqual([backlogTaskId]);
|
||||
expect(moveWinnerState[PROJECT_FEATURE_NAME].entities.project2?.taskIds).toEqual([
|
||||
regularTaskId,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps concurrently bulk-deleted tasks deleted on every client during project recovery (#8997)', async () => {
|
||||
// A wins a project rename vs B's deleteProject (B loses LWW). In the same
|
||||
// batch, C's bulk deleteTasks([bulk-1, bulk-2]) lands as a non-conflicting
|
||||
// op. Verified at REPLAY level (not just emission): a client that applied
|
||||
// C's delete must NOT have bulk-1/bulk-2 resurrected by the recovery rows.
|
||||
const survivorId = 'survivor-task';
|
||||
const bulkId1 = 'bulk-1';
|
||||
const bulkId2 = 'bulk-2';
|
||||
const survivorTask: Task = {
|
||||
...DEFAULT_TASK,
|
||||
id: survivorId,
|
||||
title: 'Survivor',
|
||||
projectId: 'project1',
|
||||
subTaskIds: [],
|
||||
} as Task;
|
||||
const bulkTask1: Task = {
|
||||
...DEFAULT_TASK,
|
||||
id: bulkId1,
|
||||
title: 'Bulk one',
|
||||
projectId: 'project1',
|
||||
subTaskIds: [],
|
||||
} as Task;
|
||||
const bulkTask2: Task = {
|
||||
...DEFAULT_TASK,
|
||||
id: bulkId2,
|
||||
title: 'Bulk two',
|
||||
projectId: 'project1',
|
||||
subTaskIds: [],
|
||||
} as Task;
|
||||
const initialTaskState = createTaskReplayState(
|
||||
[survivorTask, bulkTask1, bulkTask2],
|
||||
[survivorId, bulkId1, bulkId2],
|
||||
);
|
||||
const project = initialTaskState[PROJECT_FEATURE_NAME].entities.project1;
|
||||
if (!project) {
|
||||
throw new Error('Test fixture project1 is missing.');
|
||||
}
|
||||
|
||||
const localProjectEdit: Operation = {
|
||||
id: 'local-project-edit',
|
||||
actionType: toLwwUpdateActionType('PROJECT'),
|
||||
opType: OpType.Update,
|
||||
entityType: 'PROJECT',
|
||||
entityId: 'project1',
|
||||
payload: {
|
||||
actionPayload: project as unknown as Record<string, unknown>,
|
||||
entityChanges: [],
|
||||
lwwUpdateMode: 'replace',
|
||||
},
|
||||
clientId: LOCAL_CLIENT_ID,
|
||||
vectorClock: { [LOCAL_CLIENT_ID]: 1 },
|
||||
timestamp: 2_000,
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
};
|
||||
const remoteProjectDelete: Operation = {
|
||||
id: 'remote-project-delete',
|
||||
actionType: ActionType.TASK_SHARED_DELETE_PROJECT,
|
||||
opType: OpType.Delete,
|
||||
entityType: 'PROJECT',
|
||||
entityId: 'project1',
|
||||
payload: {
|
||||
actionPayload: {
|
||||
projectId: 'project1',
|
||||
allTaskIds: [survivorId, bulkId1, bulkId2],
|
||||
noteIds: [],
|
||||
},
|
||||
entityChanges: [],
|
||||
},
|
||||
clientId: REMOTE_CLIENT_ID,
|
||||
vectorClock: { [REMOTE_CLIENT_ID]: 1 },
|
||||
timestamp: 1_000,
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
};
|
||||
// A bulk deleteTasks op carries every id in entityIds and only the first in
|
||||
// entityId, with an empty entityChanges — the shape the guard must union.
|
||||
const remoteBulkDelete: Operation = {
|
||||
id: 'remote-bulk-delete',
|
||||
actionType: ActionType.TASK_SHARED_DELETE_MULTIPLE,
|
||||
opType: OpType.Delete,
|
||||
entityType: 'TASK',
|
||||
entityId: bulkId1,
|
||||
entityIds: [bulkId1, bulkId2],
|
||||
payload: {
|
||||
actionPayload: { taskIds: [bulkId1, bulkId2] },
|
||||
entityChanges: [],
|
||||
},
|
||||
clientId: 'third-client',
|
||||
vectorClock: { ['third-client']: 1 },
|
||||
timestamp: 1_500,
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
};
|
||||
store.select.and.callFake((_selector: unknown, props?: { id?: string }) =>
|
||||
of(
|
||||
props?.id === 'project1'
|
||||
? project
|
||||
: props?.id === survivorId
|
||||
? survivorTask
|
||||
: props?.id === bulkId1
|
||||
? bulkTask1
|
||||
: props?.id === bulkId2
|
||||
? bulkTask2
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
await opLogStore.append(localProjectEdit, 'local');
|
||||
|
||||
await service.autoResolveConflictsLWW(
|
||||
[
|
||||
{
|
||||
entityType: 'PROJECT',
|
||||
entityId: 'project1',
|
||||
localOps: [localProjectEdit],
|
||||
remoteOps: [remoteProjectDelete],
|
||||
suggestedResolution: 'manual',
|
||||
},
|
||||
],
|
||||
[remoteBulkDelete],
|
||||
);
|
||||
|
||||
const storedEntries = await opLogStore.getOpsAfterSeq(0);
|
||||
const compensationOps = storedEntries
|
||||
.filter(({ op, source }) => source === 'local' && op.id !== localProjectEdit.id)
|
||||
.map(({ op }) => op);
|
||||
|
||||
// A fresh client that applied B's project delete AND C's bulk delete, then
|
||||
// replays the recovery rows.
|
||||
const remoteClientState = applyTaskOperations(
|
||||
initialTaskState,
|
||||
[remoteProjectDelete, remoteBulkDelete, ...compensationOps],
|
||||
REMOTE_CLIENT_ID,
|
||||
);
|
||||
const ids = remoteClientState[TASK_FEATURE_NAME].ids as string[];
|
||||
expect(ids).withContext('the uncontested task is recovered').toContain(survivorId);
|
||||
expect(ids)
|
||||
.withContext('bulk-deleted tasks must stay deleted, not resurrected')
|
||||
.not.toContain(bulkId1);
|
||||
expect(ids)
|
||||
.withContext('every trailing bulk-deleted id must stay deleted')
|
||||
.not.toContain(bulkId2);
|
||||
});
|
||||
|
||||
it('keeps a task whose delete won its own conflict deleted on every client during project recovery (#8997)', async () => {
|
||||
// A edits project P (wins vs B's deleteProject) and also edits task T; C
|
||||
// deletes T. T's own LWW conflict resolves to C's delete (remote wins).
|
||||
// Verified at REPLAY level: recovery must not resurrect T on a client that
|
||||
// applied C's delete — the borrowed `modified` timestamp does NOT make the
|
||||
// recreation lose, because it dominates the delete by clock/seq.
|
||||
const survivorId = 'survivor-task';
|
||||
const contestedId = 'contested-task';
|
||||
const survivorTask: Task = {
|
||||
...DEFAULT_TASK,
|
||||
id: survivorId,
|
||||
title: 'Survivor',
|
||||
projectId: 'project1',
|
||||
subTaskIds: [],
|
||||
} as Task;
|
||||
const contestedTask: Task = {
|
||||
...DEFAULT_TASK,
|
||||
id: contestedId,
|
||||
title: 'Contested',
|
||||
projectId: 'project1',
|
||||
subTaskIds: [],
|
||||
} as Task;
|
||||
const initialTaskState = createTaskReplayState(
|
||||
[survivorTask, contestedTask],
|
||||
[survivorId, contestedId],
|
||||
);
|
||||
const project = initialTaskState[PROJECT_FEATURE_NAME].entities.project1;
|
||||
if (!project) {
|
||||
throw new Error('Test fixture project1 is missing.');
|
||||
}
|
||||
|
||||
const localProjectEdit: Operation = {
|
||||
id: 'local-project-edit',
|
||||
actionType: toLwwUpdateActionType('PROJECT'),
|
||||
opType: OpType.Update,
|
||||
entityType: 'PROJECT',
|
||||
entityId: 'project1',
|
||||
payload: {
|
||||
actionPayload: project as unknown as Record<string, unknown>,
|
||||
entityChanges: [],
|
||||
lwwUpdateMode: 'replace',
|
||||
},
|
||||
clientId: LOCAL_CLIENT_ID,
|
||||
vectorClock: { [LOCAL_CLIENT_ID]: 1 },
|
||||
timestamp: 2_000,
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
};
|
||||
const remoteProjectDelete: Operation = {
|
||||
id: 'remote-project-delete',
|
||||
actionType: ActionType.TASK_SHARED_DELETE_PROJECT,
|
||||
opType: OpType.Delete,
|
||||
entityType: 'PROJECT',
|
||||
entityId: 'project1',
|
||||
payload: {
|
||||
actionPayload: {
|
||||
projectId: 'project1',
|
||||
allTaskIds: [survivorId, contestedId],
|
||||
noteIds: [],
|
||||
},
|
||||
entityChanges: [],
|
||||
},
|
||||
clientId: REMOTE_CLIENT_ID,
|
||||
vectorClock: { [REMOTE_CLIENT_ID]: 1 },
|
||||
timestamp: 1_000,
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
};
|
||||
// This client's own edit to T loses to C's delete.
|
||||
const localTaskEdit: Operation = {
|
||||
id: 'local-task-edit',
|
||||
actionType: ActionType.TASK_SHARED_UPDATE,
|
||||
opType: OpType.Update,
|
||||
entityType: 'TASK',
|
||||
entityId: contestedId,
|
||||
payload: {
|
||||
actionPayload: { task: { id: contestedId, changes: { title: 'Mine' } } },
|
||||
entityChanges: [],
|
||||
},
|
||||
clientId: LOCAL_CLIENT_ID,
|
||||
vectorClock: { [LOCAL_CLIENT_ID]: 1 },
|
||||
timestamp: 500,
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
};
|
||||
const remoteTaskDelete: Operation = {
|
||||
id: 'remote-task-delete',
|
||||
actionType: ActionType.TASK_SHARED_DELETE,
|
||||
opType: OpType.Delete,
|
||||
entityType: 'TASK',
|
||||
entityId: contestedId,
|
||||
payload: {
|
||||
actionPayload: { task: contestedTask as unknown as Record<string, unknown> },
|
||||
entityChanges: [],
|
||||
},
|
||||
clientId: 'third-client',
|
||||
vectorClock: { ['third-client']: 1 },
|
||||
timestamp: 3_000,
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
};
|
||||
store.select.and.callFake((_selector: unknown, props?: { id?: string }) =>
|
||||
of(
|
||||
props?.id === 'project1'
|
||||
? project
|
||||
: props?.id === survivorId
|
||||
? survivorTask
|
||||
: props?.id === contestedId
|
||||
? contestedTask
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
await opLogStore.append(localProjectEdit, 'local');
|
||||
await opLogStore.append(localTaskEdit, 'local');
|
||||
|
||||
await service.autoResolveConflictsLWW([
|
||||
{
|
||||
entityType: 'PROJECT',
|
||||
entityId: 'project1',
|
||||
localOps: [localProjectEdit],
|
||||
remoteOps: [remoteProjectDelete],
|
||||
suggestedResolution: 'manual',
|
||||
},
|
||||
{
|
||||
entityType: 'TASK',
|
||||
entityId: contestedId,
|
||||
localOps: [localTaskEdit],
|
||||
remoteOps: [remoteTaskDelete],
|
||||
suggestedResolution: 'manual',
|
||||
},
|
||||
]);
|
||||
|
||||
const storedEntries = await opLogStore.getOpsAfterSeq(0);
|
||||
const compensationOps = storedEntries
|
||||
.filter(
|
||||
({ op, source }) =>
|
||||
source === 'local' &&
|
||||
op.id !== localProjectEdit.id &&
|
||||
op.id !== localTaskEdit.id,
|
||||
)
|
||||
.map(({ op }) => op);
|
||||
|
||||
// A fresh client that applied B's project delete AND C's winning task
|
||||
// delete, then replays the recovery rows.
|
||||
const remoteClientState = applyTaskOperations(
|
||||
initialTaskState,
|
||||
[remoteProjectDelete, remoteTaskDelete, ...compensationOps],
|
||||
REMOTE_CLIENT_ID,
|
||||
);
|
||||
const ids = remoteClientState[TASK_FEATURE_NAME].ids as string[];
|
||||
expect(ids).withContext('the uncontested task is recovered').toContain(survivorId);
|
||||
expect(ids)
|
||||
.withContext('a task whose delete won its conflict must stay deleted')
|
||||
.not.toContain(contestedId);
|
||||
});
|
||||
|
||||
it('keeps a winning remote archive as an archive on every client', async () => {
|
||||
const taskId = 'task-to-archive';
|
||||
const task = {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
ActionType,
|
||||
EntityConflict,
|
||||
extractActionPayload,
|
||||
isLwwUpdatePayload,
|
||||
OpType,
|
||||
Operation,
|
||||
} from '../core/operation.types';
|
||||
|
|
@ -103,6 +104,7 @@ describe('ConflictResolutionService', () => {
|
|||
'markFailed',
|
||||
'getUnsyncedByEntity',
|
||||
'getOpById',
|
||||
'getVectorClock',
|
||||
]);
|
||||
mockOpLogStore.mergeRemoteOpClocks.and.resolveTo(undefined);
|
||||
mockOpLogStore.markReducersCommittedAndMergeClocks.and.resolveTo(undefined);
|
||||
|
|
@ -151,6 +153,7 @@ describe('ConflictResolutionService', () => {
|
|||
mockValidateStateService.validateAndRepairCurrentState.and.resolveTo(true);
|
||||
mockOpLogStore.getUnsyncedByEntity.and.resolveTo(new Map());
|
||||
mockOpLogStore.getOpById.and.resolveTo(undefined);
|
||||
mockOpLogStore.getVectorClock.and.resolveTo({});
|
||||
// By default, appendBatchSkipDuplicates writes all ops (no duplicates)
|
||||
mockOpLogStore.appendBatchSkipDuplicates.and.callFake((ops: Operation[]) =>
|
||||
Promise.resolve({
|
||||
|
|
@ -1704,6 +1707,972 @@ describe('ConflictResolutionService', () => {
|
|||
expect(mockOperationApplier.applyOperations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('recreates project tasks when a remote deleteProject loses outright (#8997)', async () => {
|
||||
const remoteProjectDelete: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'remote-project-delete',
|
||||
'client-b',
|
||||
1_000,
|
||||
OpType.Delete,
|
||||
'project-1',
|
||||
),
|
||||
actionType: ActionType.TASK_SHARED_DELETE_PROJECT,
|
||||
entityType: 'PROJECT',
|
||||
payload: {
|
||||
actionPayload: {
|
||||
projectId: 'project-1',
|
||||
allTaskIds: ['regular-task', 'locally-gone', 'regular-task', 42],
|
||||
noteIds: [],
|
||||
},
|
||||
entityChanges: [],
|
||||
},
|
||||
};
|
||||
const localProjectEdit: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'local-project-edit',
|
||||
'client-a',
|
||||
2_000,
|
||||
OpType.Update,
|
||||
'project-1',
|
||||
),
|
||||
entityType: 'PROJECT',
|
||||
};
|
||||
mockStore.select.and.callFake((_selector: unknown, props?: { id: string }) => {
|
||||
if (props?.id === 'project-1') {
|
||||
return of({
|
||||
id: 'project-1',
|
||||
title: 'Winning project',
|
||||
taskIds: ['regular-task'],
|
||||
backlogTaskIds: ['backlog-task'],
|
||||
});
|
||||
}
|
||||
if (props?.id === 'regular-task') {
|
||||
return of({
|
||||
id: 'regular-task',
|
||||
title: 'Regular task',
|
||||
projectId: 'project-1',
|
||||
subTaskIds: ['subtask'],
|
||||
});
|
||||
}
|
||||
if (props?.id === 'backlog-task') {
|
||||
return of({
|
||||
id: 'backlog-task',
|
||||
title: 'Backlog task',
|
||||
projectId: 'project-1',
|
||||
subTaskIds: [],
|
||||
});
|
||||
}
|
||||
if (props?.id === 'subtask') {
|
||||
return of({
|
||||
id: 'subtask',
|
||||
title: 'Subtask',
|
||||
projectId: 'project-1',
|
||||
parentId: 'regular-task',
|
||||
subTaskIds: [],
|
||||
});
|
||||
}
|
||||
return of(undefined);
|
||||
});
|
||||
|
||||
await service.autoResolveConflictsLWW([
|
||||
{
|
||||
entityType: 'PROJECT',
|
||||
entityId: 'project-1',
|
||||
localOps: [localProjectEdit],
|
||||
remoteOps: [remoteProjectDelete],
|
||||
suggestedResolution: 'manual',
|
||||
},
|
||||
]);
|
||||
|
||||
const localOps = getMixedLocalOps();
|
||||
expect(localOps[0].entityType).toBe('PROJECT');
|
||||
expect(localOps[0].entityId).toBe('project-1');
|
||||
const projectCompensations = localOps.filter((op) => op.entityType === 'PROJECT');
|
||||
expect(projectCompensations.length).toBe(2);
|
||||
expect(
|
||||
compareVectorClocks(
|
||||
projectCompensations[1].vectorClock,
|
||||
projectCompensations[0].vectorClock,
|
||||
),
|
||||
).toBe(VectorClockComparison.GREATER_THAN);
|
||||
expect(
|
||||
extractActionPayload(projectCompensations[1].payload)['backlogTaskIds'],
|
||||
).toEqual(['backlog-task']);
|
||||
const taskRecreations = localOps.filter((op) => op.entityType === 'TASK');
|
||||
// Backlog and subtask are absent from the stale remote payload, but the
|
||||
// winning project snapshot and its current root relationships include
|
||||
// them. Recovery must mirror the replay-time delete cascade.
|
||||
expect(taskRecreations.map(({ entityId }) => entityId)).toEqual([
|
||||
'regular-task',
|
||||
'backlog-task',
|
||||
'subtask',
|
||||
'regular-task',
|
||||
]);
|
||||
expect(
|
||||
(taskRecreations[3].payload as { lwwUpdateMode?: string }).lwwUpdateMode,
|
||||
).toBe('patch');
|
||||
expect(extractActionPayload(taskRecreations[3].payload)).toEqual({
|
||||
id: 'regular-task',
|
||||
projectId: 'project-1',
|
||||
parentId: undefined,
|
||||
subTaskIds: ['subtask'],
|
||||
});
|
||||
for (const recreationOp of taskRecreations) {
|
||||
expect(
|
||||
(recreationOp.payload as { recreatesEntityAfterDelete?: boolean })
|
||||
.recreatesEntityAfterDelete,
|
||||
)
|
||||
.withContext(`recreate flag on ${recreationOp.entityId}`)
|
||||
.toBeTrue();
|
||||
expect(
|
||||
compareVectorClocks(
|
||||
recreationOp.vectorClock,
|
||||
remoteProjectDelete.vectorClock,
|
||||
),
|
||||
)
|
||||
.withContext(`clock domination for ${recreationOp.entityId}`)
|
||||
.toBe(VectorClockComparison.GREATER_THAN);
|
||||
}
|
||||
expect(extractActionPayload(taskRecreations[0].payload)['title']).toBe(
|
||||
'Regular task',
|
||||
);
|
||||
expect(getMixedRemoteOps().map(({ id }) => id)).toEqual([remoteProjectDelete.id]);
|
||||
expect(mockOperationApplier.applyOperations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not resurrect a task deleted by a concurrent non-conflicting op (#8997 review)', async () => {
|
||||
// Device A wins a project rename vs Device B's deleteProject(P) (loses
|
||||
// LWW). In the SAME sync batch, Device C's independent deleteTask lands
|
||||
// as a non-conflicting op. The recovery reads task presence from the
|
||||
// pre-batch store, so it must skip a task another device is concurrently
|
||||
// deleting — otherwise its recreation (carrying a borrowed newer
|
||||
// timestamp) resurrects the task on every client that applied C's
|
||||
// delete, and diverges from this client, whose own delete wins locally.
|
||||
const remoteProjectDelete: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'remote-project-delete',
|
||||
'client-b',
|
||||
1_000,
|
||||
OpType.Delete,
|
||||
'project-1',
|
||||
),
|
||||
actionType: ActionType.TASK_SHARED_DELETE_PROJECT,
|
||||
entityType: 'PROJECT',
|
||||
payload: {
|
||||
actionPayload: {
|
||||
projectId: 'project-1',
|
||||
allTaskIds: ['regular-task'],
|
||||
noteIds: [],
|
||||
},
|
||||
entityChanges: [],
|
||||
},
|
||||
};
|
||||
const localProjectEdit: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'local-project-edit',
|
||||
'client-a',
|
||||
2_000,
|
||||
OpType.Update,
|
||||
'project-1',
|
||||
),
|
||||
entityType: 'PROJECT',
|
||||
};
|
||||
const concurrentBacklogDelete: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'remote-delete-backlog',
|
||||
'client-c',
|
||||
1_500,
|
||||
OpType.Delete,
|
||||
'backlog-task',
|
||||
),
|
||||
actionType: ActionType.TASK_SHARED_DELETE,
|
||||
};
|
||||
mockStore.select.and.callFake((_selector: unknown, props?: { id: string }) => {
|
||||
if (props?.id === 'project-1') {
|
||||
return of({
|
||||
id: 'project-1',
|
||||
title: 'Winning project',
|
||||
taskIds: ['regular-task'],
|
||||
backlogTaskIds: ['backlog-task'],
|
||||
});
|
||||
}
|
||||
if (props?.id === 'regular-task') {
|
||||
return of({
|
||||
id: 'regular-task',
|
||||
title: 'Regular task',
|
||||
projectId: 'project-1',
|
||||
subTaskIds: [],
|
||||
});
|
||||
}
|
||||
// Still present in the pre-batch store: C's delete has not applied yet.
|
||||
if (props?.id === 'backlog-task') {
|
||||
return of({
|
||||
id: 'backlog-task',
|
||||
title: 'Backlog task',
|
||||
projectId: 'project-1',
|
||||
subTaskIds: [],
|
||||
});
|
||||
}
|
||||
return of(undefined);
|
||||
});
|
||||
|
||||
await service.autoResolveConflictsLWW(
|
||||
[
|
||||
{
|
||||
entityType: 'PROJECT',
|
||||
entityId: 'project-1',
|
||||
localOps: [localProjectEdit],
|
||||
remoteOps: [remoteProjectDelete],
|
||||
suggestedResolution: 'manual',
|
||||
},
|
||||
],
|
||||
[concurrentBacklogDelete],
|
||||
);
|
||||
|
||||
const taskRecreations = getMixedLocalOps().filter(
|
||||
(op) => op.entityType === 'TASK',
|
||||
);
|
||||
expect(taskRecreations.map(({ entityId }) => entityId))
|
||||
.withContext('recovery must not recreate a concurrently-deleted task')
|
||||
.not.toContain('backlog-task');
|
||||
// The genuinely-present task is still recovered.
|
||||
expect(taskRecreations.map(({ entityId }) => entityId)).toContain('regular-task');
|
||||
});
|
||||
|
||||
it('does not resurrect tasks removed by a concurrent bulk deleteTasks (#8997 review)', async () => {
|
||||
// Same split-brain as the single-delete case above, but Device C removes
|
||||
// the tasks with a bulk deleteTasks (TASK_SHARED_DELETE_MULTIPLE). Such
|
||||
// an op carries every id in `entityIds` and only the FIRST in
|
||||
// `entityId`, with an empty `entityChanges`. Recovery must skip ALL of
|
||||
// them; otherwise every id after the first is recreated (with a borrowed
|
||||
// newer timestamp) and resurrected on every client that applied C's
|
||||
// delete, while this client's own bulk delete wins locally.
|
||||
const remoteProjectDelete: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'remote-project-delete',
|
||||
'client-b',
|
||||
1_000,
|
||||
OpType.Delete,
|
||||
'project-1',
|
||||
),
|
||||
actionType: ActionType.TASK_SHARED_DELETE_PROJECT,
|
||||
entityType: 'PROJECT',
|
||||
payload: {
|
||||
actionPayload: {
|
||||
projectId: 'project-1',
|
||||
allTaskIds: ['regular-task', 'backlog-task-1', 'backlog-task-2'],
|
||||
noteIds: [],
|
||||
},
|
||||
entityChanges: [],
|
||||
},
|
||||
};
|
||||
const localProjectEdit: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'local-project-edit',
|
||||
'client-a',
|
||||
2_000,
|
||||
OpType.Update,
|
||||
'project-1',
|
||||
),
|
||||
entityType: 'PROJECT',
|
||||
};
|
||||
const concurrentBulkDelete: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'remote-bulk-delete',
|
||||
'client-c',
|
||||
1_500,
|
||||
OpType.Delete,
|
||||
// A bulk delete op's primary entityId is the first of entityIds.
|
||||
'backlog-task-1',
|
||||
),
|
||||
actionType: ActionType.TASK_SHARED_DELETE_MULTIPLE,
|
||||
entityIds: ['backlog-task-1', 'backlog-task-2'],
|
||||
payload: {
|
||||
actionPayload: { taskIds: ['backlog-task-1', 'backlog-task-2'] },
|
||||
entityChanges: [],
|
||||
},
|
||||
};
|
||||
mockStore.select.and.callFake((_selector: unknown, props?: { id: string }) => {
|
||||
if (props?.id === 'project-1') {
|
||||
return of({
|
||||
id: 'project-1',
|
||||
title: 'Winning project',
|
||||
taskIds: ['regular-task'],
|
||||
backlogTaskIds: ['backlog-task-1', 'backlog-task-2'],
|
||||
});
|
||||
}
|
||||
if (props?.id === 'regular-task') {
|
||||
return of({
|
||||
id: 'regular-task',
|
||||
title: 'Regular task',
|
||||
projectId: 'project-1',
|
||||
subTaskIds: [],
|
||||
});
|
||||
}
|
||||
// Both bulk-deleted tasks are still present in the pre-batch store:
|
||||
// C's delete has not applied yet.
|
||||
if (props?.id === 'backlog-task-1' || props?.id === 'backlog-task-2') {
|
||||
return of({
|
||||
id: props.id,
|
||||
title: props.id,
|
||||
projectId: 'project-1',
|
||||
subTaskIds: [],
|
||||
});
|
||||
}
|
||||
return of(undefined);
|
||||
});
|
||||
|
||||
await service.autoResolveConflictsLWW(
|
||||
[
|
||||
{
|
||||
entityType: 'PROJECT',
|
||||
entityId: 'project-1',
|
||||
localOps: [localProjectEdit],
|
||||
remoteOps: [remoteProjectDelete],
|
||||
suggestedResolution: 'manual',
|
||||
},
|
||||
],
|
||||
[concurrentBulkDelete],
|
||||
);
|
||||
|
||||
const recreatedIds = getMixedLocalOps()
|
||||
.filter((op) => op.entityType === 'TASK')
|
||||
.map(({ entityId }) => entityId);
|
||||
expect(recreatedIds)
|
||||
.withContext('recovery must skip the first id of a concurrent bulk delete')
|
||||
.not.toContain('backlog-task-1');
|
||||
expect(recreatedIds)
|
||||
.withContext('recovery must skip every trailing id of a concurrent bulk delete')
|
||||
.not.toContain('backlog-task-2');
|
||||
// The genuinely-present task is still recovered.
|
||||
expect(recreatedIds).toContain('regular-task');
|
||||
});
|
||||
|
||||
it('does not recreate a task whose own conflict a remote delete won (#8997 review)', async () => {
|
||||
// The concurrent task delete arrives as its OWN conflict (this client
|
||||
// had a competing edit that lost LWW), not as a non-conflicting op, so
|
||||
// it is invisible to the nonConflictingOps scan. Project recovery must
|
||||
// still skip it: the delete just won and is applied this batch, so a
|
||||
// recreation would fight a resolution that already stood.
|
||||
const remoteProjectDelete: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'remote-project-delete',
|
||||
'client-b',
|
||||
1_000,
|
||||
OpType.Delete,
|
||||
'project-1',
|
||||
),
|
||||
actionType: ActionType.TASK_SHARED_DELETE_PROJECT,
|
||||
entityType: 'PROJECT',
|
||||
payload: {
|
||||
actionPayload: {
|
||||
projectId: 'project-1',
|
||||
allTaskIds: ['regular-task', 'contested-task'],
|
||||
noteIds: [],
|
||||
},
|
||||
entityChanges: [],
|
||||
},
|
||||
};
|
||||
const localProjectEdit: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'local-project-edit',
|
||||
'client-a',
|
||||
2_000,
|
||||
OpType.Update,
|
||||
'project-1',
|
||||
),
|
||||
entityType: 'PROJECT',
|
||||
};
|
||||
// Task conflict: this client's edit (older) loses to client-c's delete.
|
||||
const localTaskEdit: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'local-task-edit',
|
||||
'client-a',
|
||||
500,
|
||||
OpType.Update,
|
||||
'contested-task',
|
||||
),
|
||||
actionType: ActionType.TASK_SHARED_UPDATE,
|
||||
payload: {
|
||||
actionPayload: { task: { id: 'contested-task', changes: { title: 'Mine' } } },
|
||||
entityChanges: [],
|
||||
},
|
||||
};
|
||||
const remoteTaskDelete: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'remote-task-delete',
|
||||
'client-c',
|
||||
3_000,
|
||||
OpType.Delete,
|
||||
'contested-task',
|
||||
),
|
||||
actionType: ActionType.TASK_SHARED_DELETE,
|
||||
};
|
||||
mockStore.select.and.callFake((_selector: unknown, props?: { id: string }) => {
|
||||
if (props?.id === 'project-1') {
|
||||
return of({
|
||||
id: 'project-1',
|
||||
title: 'Winning project',
|
||||
taskIds: ['regular-task', 'contested-task'],
|
||||
backlogTaskIds: [],
|
||||
});
|
||||
}
|
||||
if (props?.id === 'regular-task') {
|
||||
return of({
|
||||
id: 'regular-task',
|
||||
title: 'Regular task',
|
||||
projectId: 'project-1',
|
||||
subTaskIds: [],
|
||||
});
|
||||
}
|
||||
// Still present in the pre-batch store: client-c's delete is only
|
||||
// applied as this batch resolves.
|
||||
if (props?.id === 'contested-task') {
|
||||
return of({
|
||||
id: 'contested-task',
|
||||
title: 'Contested task',
|
||||
projectId: 'project-1',
|
||||
subTaskIds: [],
|
||||
});
|
||||
}
|
||||
return of(undefined);
|
||||
});
|
||||
|
||||
await service.autoResolveConflictsLWW([
|
||||
{
|
||||
entityType: 'PROJECT',
|
||||
entityId: 'project-1',
|
||||
localOps: [localProjectEdit],
|
||||
remoteOps: [remoteProjectDelete],
|
||||
suggestedResolution: 'manual',
|
||||
},
|
||||
{
|
||||
entityType: 'TASK',
|
||||
entityId: 'contested-task',
|
||||
localOps: [localTaskEdit],
|
||||
remoteOps: [remoteTaskDelete],
|
||||
suggestedResolution: 'manual',
|
||||
},
|
||||
]);
|
||||
|
||||
const recreatedIds = getMixedLocalOps()
|
||||
.filter(
|
||||
(op) =>
|
||||
op.entityType === 'TASK' &&
|
||||
isLwwUpdatePayload(op.payload) &&
|
||||
op.payload.recreatesEntityAfterDelete === true,
|
||||
)
|
||||
.map(({ entityId }) => entityId);
|
||||
expect(recreatedIds)
|
||||
.withContext('recovery must not recreate a task whose delete just won LWW')
|
||||
.not.toContain('contested-task');
|
||||
// The uncontested task is still recovered.
|
||||
expect(recreatedIds).toContain('regular-task');
|
||||
});
|
||||
|
||||
it('recreates project tasks with each task’s own modified timestamp, not the project’s (#8997 review)', async () => {
|
||||
// The project edit is much newer (9000) than the task's last change
|
||||
// (4321). Borrowing the project timestamp would let the recreation win
|
||||
// a CONCURRENT content edit on another device and clobber it; the task's
|
||||
// own `modified` keeps that edit winning by LWW.
|
||||
const remoteProjectDelete: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'remote-project-delete',
|
||||
'client-b',
|
||||
1_000,
|
||||
OpType.Delete,
|
||||
'project-1',
|
||||
),
|
||||
actionType: ActionType.TASK_SHARED_DELETE_PROJECT,
|
||||
entityType: 'PROJECT',
|
||||
payload: {
|
||||
actionPayload: {
|
||||
projectId: 'project-1',
|
||||
allTaskIds: ['regular-task'],
|
||||
noteIds: [],
|
||||
},
|
||||
entityChanges: [],
|
||||
},
|
||||
};
|
||||
const localProjectEdit: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'local-project-edit',
|
||||
'client-a',
|
||||
9_000,
|
||||
OpType.Update,
|
||||
'project-1',
|
||||
),
|
||||
entityType: 'PROJECT',
|
||||
};
|
||||
mockStore.select.and.callFake((_selector: unknown, props?: { id: string }) => {
|
||||
if (props?.id === 'project-1') {
|
||||
return of({
|
||||
id: 'project-1',
|
||||
title: 'Winning project',
|
||||
taskIds: ['regular-task'],
|
||||
backlogTaskIds: [],
|
||||
});
|
||||
}
|
||||
if (props?.id === 'regular-task') {
|
||||
return of({
|
||||
id: 'regular-task',
|
||||
title: 'Regular task',
|
||||
projectId: 'project-1',
|
||||
subTaskIds: [],
|
||||
modified: 4_321,
|
||||
});
|
||||
}
|
||||
return of(undefined);
|
||||
});
|
||||
|
||||
await service.autoResolveConflictsLWW([
|
||||
{
|
||||
entityType: 'PROJECT',
|
||||
entityId: 'project-1',
|
||||
localOps: [localProjectEdit],
|
||||
remoteOps: [remoteProjectDelete],
|
||||
suggestedResolution: 'manual',
|
||||
},
|
||||
]);
|
||||
|
||||
const taskRecreation = getMixedLocalOps().find(
|
||||
(op) =>
|
||||
op.entityType === 'TASK' &&
|
||||
op.entityId === 'regular-task' &&
|
||||
(op.payload as { lwwUpdateMode?: string }).lwwUpdateMode !== 'patch',
|
||||
);
|
||||
expect(taskRecreation?.timestamp)
|
||||
.withContext('recreation uses the task’s own modified, not the project edit')
|
||||
.toBe(4_321);
|
||||
});
|
||||
|
||||
it('preserves the recreation guard when a task recreation wins again (#8997)', async () => {
|
||||
const localTaskRecreation: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'local-project-task-recreation',
|
||||
'client-a',
|
||||
2_000,
|
||||
OpType.Update,
|
||||
'task-1',
|
||||
),
|
||||
actionType: '[TASK] LWW Update' as ActionType,
|
||||
payload: {
|
||||
actionPayload: {
|
||||
id: 'task-1',
|
||||
title: 'Winning task',
|
||||
projectId: 'project-1',
|
||||
subTaskIds: ['sub-1'],
|
||||
},
|
||||
entityChanges: [],
|
||||
lwwUpdateMode: 'replace',
|
||||
recreatesEntityAfterDelete: true,
|
||||
},
|
||||
};
|
||||
const remoteTaskEdit: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'remote-task-edit',
|
||||
'client-b',
|
||||
1_000,
|
||||
OpType.Update,
|
||||
'task-1',
|
||||
),
|
||||
};
|
||||
mockStore.select.and.callFake((_selector: unknown, props?: { id: string }) => {
|
||||
if (props?.id === 'task-1') {
|
||||
return of({
|
||||
id: 'task-1',
|
||||
title: 'Winning task',
|
||||
projectId: 'project-1',
|
||||
subTaskIds: ['sub-1'],
|
||||
});
|
||||
}
|
||||
if (props?.id === 'sub-1') {
|
||||
return of({
|
||||
id: 'sub-1',
|
||||
title: 'Surviving subtask',
|
||||
projectId: 'project-1',
|
||||
parentId: 'task-1',
|
||||
subTaskIds: [],
|
||||
});
|
||||
}
|
||||
if (props?.id === 'project-1') {
|
||||
return of({
|
||||
id: 'project-1',
|
||||
taskIds: ['regular-task'],
|
||||
backlogTaskIds: ['older-backlog-task', 'task-1'],
|
||||
});
|
||||
}
|
||||
return of(undefined);
|
||||
});
|
||||
|
||||
await service.autoResolveConflictsLWW([
|
||||
createConflict('task-1', [localTaskRecreation], [remoteTaskEdit]),
|
||||
]);
|
||||
|
||||
const replacementTaskOp = getMixedLocalOps().find(
|
||||
(op) => op.entityType === 'TASK' && op.entityId === 'task-1',
|
||||
);
|
||||
expect(replacementTaskOp).toBeDefined();
|
||||
expect(
|
||||
(replacementTaskOp!.payload as { recreatesEntityAfterDelete?: boolean })
|
||||
.recreatesEntityAfterDelete,
|
||||
).toBeTrue();
|
||||
const subtaskFollowUp = getMixedLocalOps().find(
|
||||
(op) => op.entityType === 'TASK' && op.entityId === 'sub-1',
|
||||
);
|
||||
expect(subtaskFollowUp).toBeDefined();
|
||||
expect(
|
||||
(subtaskFollowUp!.payload as { recreatesEntityAfterDelete?: boolean })
|
||||
.recreatesEntityAfterDelete,
|
||||
).toBeTrue();
|
||||
const projectFollowUp = getMixedLocalOps().find(
|
||||
(op) => op.entityType === 'PROJECT' && op.entityId === 'project-1',
|
||||
);
|
||||
expect(projectFollowUp).toBeDefined();
|
||||
expect(extractActionPayload(projectFollowUp!.payload)['backlogTaskIds']).toEqual([
|
||||
'older-backlog-task',
|
||||
'task-1',
|
||||
]);
|
||||
expect(
|
||||
compareVectorClocks(
|
||||
projectFollowUp!.vectorClock,
|
||||
replacementTaskOp!.vectorClock,
|
||||
),
|
||||
).toBe(VectorClockComparison.GREATER_THAN);
|
||||
});
|
||||
|
||||
it('recreates a remote move winner after a project recovery row is rejected (#8997)', async () => {
|
||||
const localTaskRecreation: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'local-project-task-recreation',
|
||||
'client-a',
|
||||
1_000,
|
||||
OpType.Update,
|
||||
'task-1',
|
||||
),
|
||||
actionType: '[TASK] LWW Update' as ActionType,
|
||||
payload: {
|
||||
actionPayload: {
|
||||
id: 'task-1',
|
||||
title: 'Local winning content',
|
||||
projectId: 'project-1',
|
||||
parentId: null,
|
||||
subTaskIds: ['sub-1'],
|
||||
},
|
||||
entityChanges: [],
|
||||
lwwUpdateMode: 'replace',
|
||||
recreatesEntityAfterDelete: true,
|
||||
},
|
||||
};
|
||||
const remoteMove: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'remote-parent-move',
|
||||
'client-b',
|
||||
2_000,
|
||||
OpType.Update,
|
||||
'task-1',
|
||||
),
|
||||
actionType: ActionType.TASK_SHARED_MOVE_TO_PROJECT,
|
||||
payload: {
|
||||
actionPayload: {
|
||||
task: {
|
||||
id: 'task-1',
|
||||
title: 'Remote stale preimage',
|
||||
projectId: 'project-1',
|
||||
parentId: null,
|
||||
subTaskIds: ['sub-1'],
|
||||
subTasks: [
|
||||
{
|
||||
id: 'sub-1',
|
||||
title: 'Child task',
|
||||
projectId: 'project-1',
|
||||
parentId: 'task-1',
|
||||
subTaskIds: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
targetProjectId: 'project-2',
|
||||
},
|
||||
entityChanges: [],
|
||||
},
|
||||
};
|
||||
mockStore.select.and.callFake((_selector: unknown, props?: { id: string }) => {
|
||||
if (props?.id === 'sub-1') {
|
||||
return of({
|
||||
id: 'sub-1',
|
||||
title: 'Child task',
|
||||
projectId: 'project-1',
|
||||
parentId: 'task-1',
|
||||
subTaskIds: [],
|
||||
});
|
||||
}
|
||||
if (props?.id === 'project-2') {
|
||||
return of({
|
||||
id: 'project-2',
|
||||
taskIds: ['older-task'],
|
||||
backlogTaskIds: [],
|
||||
});
|
||||
}
|
||||
return of(undefined);
|
||||
});
|
||||
|
||||
await service.autoResolveConflictsLWW([
|
||||
createConflict('task-1', [localTaskRecreation], [remoteMove]),
|
||||
]);
|
||||
|
||||
expect(getMixedRemoteOps().map(({ id }) => id)).toContain(remoteMove.id);
|
||||
const localOps = getMixedLocalOps();
|
||||
const parentOps = localOps.filter(({ entityId }) => entityId === 'task-1');
|
||||
expect(parentOps.length).toBe(2);
|
||||
expect(extractActionPayload(parentOps[0].payload)['projectId']).toBe('project-2');
|
||||
expect(extractActionPayload(parentOps[0].payload)['title']).toBe(
|
||||
'Local winning content',
|
||||
);
|
||||
expect(extractActionPayload(parentOps[1].payload)).toEqual({
|
||||
id: 'task-1',
|
||||
projectId: 'project-2',
|
||||
parentId: null,
|
||||
subTaskIds: ['sub-1'],
|
||||
});
|
||||
expect(
|
||||
(parentOps[0].payload as { recreatesEntityAfterDelete?: boolean })
|
||||
.recreatesEntityAfterDelete,
|
||||
).toBeTrue();
|
||||
const childOp = localOps.find(({ entityId }) => entityId === 'sub-1');
|
||||
expect(extractActionPayload(childOp!.payload)['projectId']).toBe('project-2');
|
||||
const projectOp = localOps.find(
|
||||
({ entityType, entityId }) =>
|
||||
entityType === 'PROJECT' && entityId === 'project-2',
|
||||
);
|
||||
expect(extractActionPayload(projectOp!.payload)['taskIds']).toEqual([
|
||||
'older-task',
|
||||
'task-1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not recreate a project recovery task when remote archive wins (#8997)', async () => {
|
||||
const localTaskRecreation: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'local-project-task-recreation',
|
||||
'client-a',
|
||||
1_000,
|
||||
OpType.Update,
|
||||
'task-1',
|
||||
),
|
||||
actionType: '[TASK] LWW Update' as ActionType,
|
||||
payload: {
|
||||
actionPayload: {
|
||||
id: 'task-1',
|
||||
title: 'Task',
|
||||
projectId: 'project-1',
|
||||
subTaskIds: [],
|
||||
},
|
||||
entityChanges: [],
|
||||
lwwUpdateMode: 'replace',
|
||||
recreatesEntityAfterDelete: true,
|
||||
},
|
||||
};
|
||||
const remoteArchive: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'remote-archive',
|
||||
'client-b',
|
||||
2_000,
|
||||
OpType.Update,
|
||||
'task-1',
|
||||
),
|
||||
actionType: ActionType.TASK_SHARED_MOVE_TO_ARCHIVE,
|
||||
payload: {
|
||||
actionPayload: { tasks: [{ id: 'task-1', title: 'Task' }] },
|
||||
entityChanges: [],
|
||||
},
|
||||
};
|
||||
|
||||
await service.autoResolveConflictsLWW([
|
||||
createConflict('task-1', [localTaskRecreation], [remoteArchive]),
|
||||
]);
|
||||
|
||||
expect(getMixedLocalOps()).toEqual([]);
|
||||
});
|
||||
|
||||
it('reconstructs a supported remote task field update', async () => {
|
||||
const localTaskRecreation: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'local-project-task-recreation',
|
||||
'client-a',
|
||||
1_000,
|
||||
OpType.Update,
|
||||
'task-1',
|
||||
),
|
||||
actionType: toLwwUpdateActionType('TASK'),
|
||||
payload: {
|
||||
actionPayload: {
|
||||
id: 'task-1',
|
||||
title: 'Local title',
|
||||
projectId: 'project-1',
|
||||
subTaskIds: [],
|
||||
},
|
||||
entityChanges: [],
|
||||
lwwUpdateMode: 'replace',
|
||||
recreatesEntityAfterDelete: true,
|
||||
},
|
||||
};
|
||||
const remoteUpdate: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'remote-task-update',
|
||||
'client-b',
|
||||
2_000,
|
||||
OpType.Update,
|
||||
'task-1',
|
||||
),
|
||||
actionType: ActionType.TASK_SHARED_UPDATE,
|
||||
payload: {
|
||||
actionPayload: {
|
||||
task: { id: 'task-1', changes: { title: 'Remote title' } },
|
||||
},
|
||||
entityChanges: [],
|
||||
},
|
||||
};
|
||||
|
||||
await service.autoResolveConflictsLWW([
|
||||
createConflict('task-1', [localTaskRecreation], [remoteUpdate]),
|
||||
]);
|
||||
|
||||
const compensation = getMixedLocalOps().find(
|
||||
({ entityType, entityId }) => entityType === 'TASK' && entityId === 'task-1',
|
||||
);
|
||||
expect(compensation).toBeDefined();
|
||||
expect(extractActionPayload(compensation!.payload)['title']).toBe('Remote title');
|
||||
});
|
||||
|
||||
[
|
||||
{
|
||||
name: 'convertToSubTask',
|
||||
actionType: ActionType.TASK_SHARED_CONVERT_TO_SUB,
|
||||
actionPayload: {
|
||||
taskId: 'task-1',
|
||||
targetParentId: 'parent-1',
|
||||
afterTaskId: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'scheduleTaskWithTime',
|
||||
actionType: ActionType.TASK_SHARED_SCHEDULE_WITH_TIME,
|
||||
actionPayload: {
|
||||
task: { id: 'task-1', title: 'Remote stale preimage' },
|
||||
dueWithTime: 1_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'applyShortSyntax',
|
||||
actionType: ActionType.TASK_SHARED_APPLY_SHORT_SYNTAX,
|
||||
actionPayload: {
|
||||
task: { id: 'task-1', title: 'Remote stale preimage' },
|
||||
taskChanges: { title: 'Remote' },
|
||||
},
|
||||
},
|
||||
].forEach(({ name, actionType, actionPayload }) => {
|
||||
it(`does not incorrectly reconstruct opaque ${name} winners`, async () => {
|
||||
const localTaskRecreation: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'local-project-task-recreation',
|
||||
'client-a',
|
||||
1_000,
|
||||
OpType.Update,
|
||||
'task-1',
|
||||
),
|
||||
actionType: toLwwUpdateActionType('TASK'),
|
||||
payload: {
|
||||
actionPayload: {
|
||||
id: 'task-1',
|
||||
title: 'Local title',
|
||||
projectId: 'project-1',
|
||||
subTaskIds: [],
|
||||
},
|
||||
entityChanges: [],
|
||||
lwwUpdateMode: 'replace',
|
||||
recreatesEntityAfterDelete: true,
|
||||
},
|
||||
};
|
||||
const remoteUpdate: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
`remote-${name}`,
|
||||
'client-b',
|
||||
2_000,
|
||||
OpType.Update,
|
||||
'task-1',
|
||||
),
|
||||
actionType,
|
||||
payload: { actionPayload, entityChanges: [] },
|
||||
};
|
||||
|
||||
await service.autoResolveConflictsLWW([
|
||||
createConflict('task-1', [localTaskRecreation], [remoteUpdate]),
|
||||
]);
|
||||
|
||||
expect(getMixedLocalOps()).toEqual([]);
|
||||
const appliedOps = mockOperationApplier.applyOperations.calls.mostRecent()
|
||||
.args[0] as Operation[];
|
||||
expect(appliedOps.map(({ id }) => id)).toContain(remoteUpdate.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('restores exact sibling order when a subtask recreation is rewritten (#8997)', async () => {
|
||||
const rewrittenSubtask: Operation = {
|
||||
...createOpWithTimestamp(
|
||||
'rewritten-subtask-recreation',
|
||||
'client-a',
|
||||
2_000,
|
||||
OpType.Update,
|
||||
'sub-1',
|
||||
),
|
||||
actionType: '[TASK] LWW Update' as ActionType,
|
||||
payload: {
|
||||
actionPayload: {
|
||||
id: 'sub-1',
|
||||
title: 'First subtask',
|
||||
projectId: 'project-1',
|
||||
parentId: 'parent-1',
|
||||
subTaskIds: [],
|
||||
},
|
||||
entityChanges: [],
|
||||
lwwUpdateMode: 'replace',
|
||||
recreatesEntityAfterDelete: true,
|
||||
},
|
||||
};
|
||||
mockStore.select.and.callFake((_selector: unknown, props?: { id: string }) =>
|
||||
of(
|
||||
props?.id === 'parent-1'
|
||||
? {
|
||||
id: 'parent-1',
|
||||
title: 'Parent',
|
||||
projectId: 'project-1',
|
||||
parentId: null,
|
||||
subTaskIds: ['sub-1', 'sub-2'],
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
|
||||
const followUpOps =
|
||||
await service.createTaskRecreationFollowUpOps(rewrittenSubtask);
|
||||
|
||||
expect(followUpOps.length).toBe(1);
|
||||
expect(followUpOps[0].entityType).toBe('TASK');
|
||||
expect(followUpOps[0].entityId).toBe('parent-1');
|
||||
expect(extractActionPayload(followUpOps[0].payload)['subTaskIds']).toEqual([
|
||||
'sub-1',
|
||||
'sub-2',
|
||||
]);
|
||||
expect(extractActionPayload(followUpOps[0].payload)['title']).toBeUndefined();
|
||||
expect(
|
||||
(followUpOps[0].payload as { recreatesEntityAfterDelete?: boolean })
|
||||
.recreatesEntityAfterDelete,
|
||||
).toBeTrue();
|
||||
expect(
|
||||
compareVectorClocks(followUpOps[0].vectorClock, rewrittenSubtask.vectorClock),
|
||||
).toBe(VectorClockComparison.GREATER_THAN);
|
||||
});
|
||||
|
||||
it('recreates subtasks when every entity of a remote bulk delete loses (#8956)', async () => {
|
||||
const remoteMultiOp: Operation = {
|
||||
...createOpWithTimestamp('remote-multi', 'client-b', 1_000),
|
||||
|
|
|
|||
|
|
@ -102,6 +102,16 @@ interface MergedResolution {
|
|||
plan: LwwConflictResolutionPlan<EntityConflict>;
|
||||
}
|
||||
|
||||
const taskRelationshipPatch = (
|
||||
taskId: string,
|
||||
taskState: Record<string, unknown>,
|
||||
): Record<string, unknown> => ({
|
||||
id: taskId,
|
||||
projectId: taskState['projectId'],
|
||||
parentId: taskState['parentId'],
|
||||
subTaskIds: taskState['subTaskIds'],
|
||||
});
|
||||
|
||||
/** Result of `_resolveConflictsWithLWW`: LWW winners plus disjoint merges. */
|
||||
interface ResolvedConflicts {
|
||||
lwwResolutions: LWWResolution[];
|
||||
|
|
@ -461,6 +471,253 @@ export class ConflictResolutionService {
|
|||
return incrementVectorClock(mergedClock, clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-emits the relationships that a rewritten recreate-after-delete TASK op
|
||||
* cannot carry by itself. This is used only when an earlier recovery row was
|
||||
* rejected and replaced: the parent TASK goes first, any still-present
|
||||
* subtasks follow, and a parent TASK snapshot or PROJECT membership patch
|
||||
* restores exact relationship ordering last.
|
||||
*/
|
||||
async createTaskRecreationFollowUpOps(
|
||||
taskOp: Operation,
|
||||
options: { ensureRegularProjectMembership?: boolean } = {},
|
||||
): Promise<Operation[]> {
|
||||
if (
|
||||
taskOp.entityType !== 'TASK' ||
|
||||
!taskOp.entityId ||
|
||||
!isLwwUpdatePayload(taskOp.payload) ||
|
||||
taskOp.payload.recreatesEntityAfterDelete !== true
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const taskState = extractActionPayload(taskOp.payload);
|
||||
const projectId = taskState['projectId'];
|
||||
const parentId = taskState['parentId'];
|
||||
if (typeof projectId !== 'string') return [];
|
||||
|
||||
const clientId = await this.clientIdProvider.loadClientId();
|
||||
if (!clientId) {
|
||||
OpLog.err(
|
||||
'ConflictResolutionService: Cannot create TASK recovery follow-ups - no client ID',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
let nextClock = this.mergeAndIncrementClocks(
|
||||
[(await this.opLogStore.getVectorClock()) ?? {}, taskOp.vectorClock],
|
||||
clientId,
|
||||
);
|
||||
const followUpOps: Operation[] = [];
|
||||
const subTaskIds = taskState['subTaskIds'];
|
||||
if (Array.isArray(subTaskIds)) {
|
||||
for (const subTaskId of new Set(
|
||||
subTaskIds.filter((id): id is string => typeof id === 'string'),
|
||||
)) {
|
||||
const subTaskState = await this.getCurrentEntityState(
|
||||
'TASK' as EntityType,
|
||||
subTaskId,
|
||||
);
|
||||
if (subTaskState === undefined) continue;
|
||||
const subTaskOp = markLwwDeleteRecreation(
|
||||
this.createLWWUpdateOp(
|
||||
'TASK' as EntityType,
|
||||
subTaskId,
|
||||
typeof subTaskState === 'object' && subTaskState !== null
|
||||
? { ...subTaskState, projectId }
|
||||
: subTaskState,
|
||||
clientId,
|
||||
nextClock,
|
||||
taskOp.timestamp,
|
||||
),
|
||||
);
|
||||
followUpOps.push(subTaskOp);
|
||||
nextClock = this.mergeAndIncrementClocks(
|
||||
[nextClock, subTaskOp.vectorClock],
|
||||
clientId,
|
||||
);
|
||||
}
|
||||
if (subTaskIds.length > 0) {
|
||||
const taskRelationshipOp = markLwwDeleteRecreation(
|
||||
this.createLWWUpdateOp(
|
||||
'TASK' as EntityType,
|
||||
taskOp.entityId,
|
||||
taskRelationshipPatch(taskOp.entityId, taskState),
|
||||
clientId,
|
||||
nextClock,
|
||||
taskOp.timestamp,
|
||||
'patch',
|
||||
),
|
||||
);
|
||||
followUpOps.push(taskRelationshipOp);
|
||||
nextClock = this.mergeAndIncrementClocks(
|
||||
[nextClock, taskRelationshipOp.vectorClock],
|
||||
clientId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof parentId === 'string') {
|
||||
const parentTaskState = await this.getCurrentEntityState(
|
||||
'TASK' as EntityType,
|
||||
parentId,
|
||||
);
|
||||
if (parentTaskState === undefined) {
|
||||
return followUpOps;
|
||||
}
|
||||
followUpOps.push(
|
||||
markLwwDeleteRecreation(
|
||||
this.createLWWUpdateOp(
|
||||
'TASK' as EntityType,
|
||||
parentId,
|
||||
taskRelationshipPatch(parentId, parentTaskState as Record<string, unknown>),
|
||||
clientId,
|
||||
this.mergeAndIncrementClocks([nextClock], clientId),
|
||||
taskOp.timestamp,
|
||||
'patch',
|
||||
),
|
||||
),
|
||||
);
|
||||
return followUpOps;
|
||||
}
|
||||
|
||||
const projectState = await this.getCurrentEntityState(
|
||||
'PROJECT' as EntityType,
|
||||
projectId,
|
||||
);
|
||||
if (typeof projectState !== 'object' || projectState === null) {
|
||||
return followUpOps;
|
||||
}
|
||||
const project = projectState as Record<string, unknown>;
|
||||
if (!Array.isArray(project['taskIds']) || !Array.isArray(project['backlogTaskIds'])) {
|
||||
return followUpOps;
|
||||
}
|
||||
const taskIds = [...project['taskIds']];
|
||||
const backlogTaskIds = [...project['backlogTaskIds']];
|
||||
if (
|
||||
options.ensureRegularProjectMembership === true &&
|
||||
!taskIds.includes(taskOp.entityId) &&
|
||||
!backlogTaskIds.includes(taskOp.entityId)
|
||||
) {
|
||||
taskIds.push(taskOp.entityId);
|
||||
}
|
||||
followUpOps.push(
|
||||
markLwwDeleteRecreation(
|
||||
this.createLWWUpdateOp(
|
||||
'PROJECT' as EntityType,
|
||||
projectId,
|
||||
{
|
||||
id: projectId,
|
||||
taskIds,
|
||||
backlogTaskIds,
|
||||
},
|
||||
clientId,
|
||||
this.mergeAndIncrementClocks([nextClock], clientId),
|
||||
taskOp.timestamp,
|
||||
'patch',
|
||||
),
|
||||
),
|
||||
);
|
||||
return followUpOps;
|
||||
}
|
||||
|
||||
private async _createRemoteWinCompensationForRejectedTaskRecreation(
|
||||
conflict: EntityConflict,
|
||||
remoteOp: Operation,
|
||||
): Promise<Operation | undefined> {
|
||||
if (conflict.entityType !== 'TASK' || remoteOp.opType !== OpType.Update) {
|
||||
return undefined;
|
||||
}
|
||||
const localRecreation = conflict.localOps.find(
|
||||
(op) =>
|
||||
isLwwUpdatePayload(op.payload) && op.payload.recreatesEntityAfterDelete === true,
|
||||
);
|
||||
if (!localRecreation) return undefined;
|
||||
|
||||
const isMoveToProject =
|
||||
remoteOp.actionType === ActionType.TASK_SHARED_MOVE_TO_PROJECT;
|
||||
const isTaskLwwUpdate =
|
||||
remoteOp.actionType === toLwwUpdateActionType('TASK') &&
|
||||
isLwwUpdatePayload(remoteOp.payload);
|
||||
const isAdapterTaskUpdate = [
|
||||
ActionType.TASK_SHARED_UPDATE,
|
||||
ActionType.TASK_UPDATE_UI,
|
||||
ActionType.TASK_SHARED_UPDATE_MULTIPLE,
|
||||
ActionType.TASK_UPDATE_MULTIPLE_SIMPLE,
|
||||
].includes(remoteOp.actionType);
|
||||
if (!isMoveToProject && !isTaskLwwUpdate && !isAdapterTaskUpdate) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const localTaskState = { ...extractActionPayload(localRecreation.payload) };
|
||||
delete localTaskState['subTasks'];
|
||||
const remoteActionPayload = extractActionPayload(remoteOp.payload);
|
||||
const targetProjectId = remoteActionPayload['targetProjectId'];
|
||||
let taskState: Record<string, unknown>;
|
||||
if (isMoveToProject) {
|
||||
if (typeof targetProjectId !== 'string') return undefined;
|
||||
// moveToOtherProject carries a full pre-move task snapshot, but only its
|
||||
// target project is an intended task-field change.
|
||||
taskState = { ...localTaskState, projectId: targetProjectId };
|
||||
} else {
|
||||
const payloadKey = this._resolvePayloadKey('TASK' as EntityType);
|
||||
const syntheticDelete: Operation = {
|
||||
...localRecreation,
|
||||
opType: OpType.Delete,
|
||||
payload: {
|
||||
actionPayload: {
|
||||
[payloadKey]: extractActionPayload(localRecreation.payload),
|
||||
},
|
||||
entityChanges: [],
|
||||
},
|
||||
};
|
||||
const [convertedRemoteOp] = convertLocalDeleteRemoteUpdatesToLww<Operation>(
|
||||
{ ...conflict, localOps: [syntheticDelete], remoteOps: [remoteOp] },
|
||||
{
|
||||
payloadKey,
|
||||
toLwwUpdateActionType: (entityType) =>
|
||||
toLwwUpdateActionType(entityType as EntityType),
|
||||
isSingletonEntityId,
|
||||
},
|
||||
);
|
||||
if (!isLwwUpdatePayload(convertedRemoteOp.payload)) return undefined;
|
||||
taskState = { ...extractActionPayload(convertedRemoteOp.payload) };
|
||||
delete taskState['subTasks'];
|
||||
|
||||
// Generic adapter/LWW reconstruction is field-safe only. Relationship
|
||||
// changes require action-specific parent/project ordering support.
|
||||
if (
|
||||
!deepEqual(taskState['projectId'], localTaskState['projectId']) ||
|
||||
!deepEqual(taskState['parentId'], localTaskState['parentId']) ||
|
||||
!deepEqual(taskState['subTaskIds'], localTaskState['subTaskIds'])
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const clientId = await this.clientIdProvider.loadClientId();
|
||||
if (!clientId) {
|
||||
OpLog.err(
|
||||
'ConflictResolutionService: Cannot compensate remote TASK winner - no client ID',
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return markLwwDeleteRecreation(
|
||||
this.createLWWUpdateOp(
|
||||
'TASK' as EntityType,
|
||||
conflict.entityId,
|
||||
taskState,
|
||||
clientId,
|
||||
this.mergeAndIncrementClocks(
|
||||
[
|
||||
...conflict.localOps.map((op) => op.vectorClock),
|
||||
...conflict.remoteOps.map((op) => op.vectorClock),
|
||||
],
|
||||
clientId,
|
||||
),
|
||||
remoteOp.timestamp,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the current state after conflict resolution and repairs if necessary.
|
||||
*
|
||||
|
|
@ -761,6 +1018,44 @@ export class ConflictResolutionService {
|
|||
}
|
||||
}
|
||||
|
||||
// A semantic remote TASK winner may not recreate an entity that the
|
||||
// earlier project-delete loser removes on a fresh replay. Re-emit the
|
||||
// remote result as a full local snapshot, then restore its dependents and
|
||||
// relationships. Persist/apply the original remote row first so live and
|
||||
// restart order match.
|
||||
for (const resolution of resolutions) {
|
||||
if (
|
||||
resolution.winner !== 'remote' ||
|
||||
!resolution.conflict.localOps.some(
|
||||
(op) =>
|
||||
isLwwUpdatePayload(op.payload) &&
|
||||
op.payload.recreatesEntityAfterDelete === true,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (const remoteOp of resolution.conflict.remoteOps) {
|
||||
const compensationOp =
|
||||
await this._createRemoteWinCompensationForRejectedTaskRecreation(
|
||||
resolution.conflict,
|
||||
remoteOp,
|
||||
);
|
||||
if (!compensationOp) continue;
|
||||
newLocalWinOps.push(compensationOp);
|
||||
compensationOpIdsToApply.add(compensationOp.id);
|
||||
const followUpOps = await this.createTaskRecreationFollowUpOps(compensationOp, {
|
||||
ensureRegularProjectMembership:
|
||||
remoteOp.actionType === ActionType.TASK_SHARED_MOVE_TO_PROJECT,
|
||||
});
|
||||
for (const followUpOp of followUpOps) {
|
||||
newLocalWinOps.push(followUpOp);
|
||||
compensationOpIdsToApply.add(followUpOp.id);
|
||||
}
|
||||
compensatedRemoteOps.set(remoteOp.id, remoteOp);
|
||||
remoteWinsOps = remoteWinsOps.filter((op) => op.id !== remoteOp.id);
|
||||
}
|
||||
}
|
||||
|
||||
const newLocalWinOpsById = new Map(newLocalWinOps.map((op) => [op.id, op]));
|
||||
|
||||
for (const winners of multiEntityRemoteOpWinners.values()) {
|
||||
|
|
@ -848,12 +1143,30 @@ export class ConflictResolutionService {
|
|||
// A remote DELETE that loses outright — single-entity, or a bulk delete
|
||||
// whose conflicting entities all win locally with no uncontested sibling —
|
||||
// never enters the mixed-winner block above, yet its reducer cascade still
|
||||
// removes the winning parent's subtasks wherever the delete IS applied:
|
||||
// removes the winning entity's dependents wherever the delete IS applied:
|
||||
// on every client that already synced it, and on this client's own
|
||||
// status-blind hydration replay of the durable loser row. Only the parent
|
||||
// status-blind hydration replay of the durable loser row. Only the winner
|
||||
// carries a compensation op, so emit recreate-after-delete snapshots for
|
||||
// its still-present subtasks too (#8956). Archive ops are OpType.Update,
|
||||
// so archive precedence is untouched.
|
||||
// its still-present cascade victims too: a TASK parent's subtasks (#8956)
|
||||
// and a PROJECT's active tasks (#8997). Archive ops are OpType.Update, so
|
||||
// archive precedence is untouched.
|
||||
//
|
||||
// Recovery reads task presence from the pre-batch store, so it is blind to
|
||||
// deletes applied elsewhere in this same batch. Exclude those task ids so
|
||||
// recovery does not resurrect a task another device is concurrently
|
||||
// deleting (#8997 review). Two sources apply here in the same batch:
|
||||
// 1. deletes piggybacked as non-conflicting ops, and
|
||||
// 2. deletes that won their own LWW conflict (a competing local edit
|
||||
// lost) — invisible to the nonConflictingOps scan, but just as
|
||||
// applied, so recovery must not fight a deletion that already won.
|
||||
const remoteDeleteWinnerOps = resolutions
|
||||
.filter((resolution) => resolution.winner === 'remote')
|
||||
.flatMap((resolution) => resolution.conflict.remoteOps)
|
||||
.filter((op) => op.opType === OpType.Delete);
|
||||
const concurrentlyDeletedTaskIds = this._collectDeletedTaskIds([
|
||||
...nonConflictingOps,
|
||||
...remoteDeleteWinnerOps,
|
||||
]);
|
||||
for (const resolution of resolutions) {
|
||||
if (resolution.winner !== 'local' || !resolution.localWinOp) {
|
||||
continue;
|
||||
|
|
@ -870,21 +1183,54 @@ export class ConflictResolutionService {
|
|||
if (remoteOp.opType !== OpType.Delete || compensatedRemoteOps.has(remoteOp.id)) {
|
||||
continue;
|
||||
}
|
||||
const subtaskRecreationOps =
|
||||
await this._createSubtaskRecreationOpsForWinningParent(
|
||||
const cascadeRecreationOps = [
|
||||
...(await this._createSubtaskRecreationOpsForWinningParent(
|
||||
parentCompensationOp,
|
||||
remoteOp,
|
||||
);
|
||||
)),
|
||||
...(await this._createTaskRecreationOpsForWinningProject(
|
||||
parentCompensationOp,
|
||||
remoteOp,
|
||||
concurrentlyDeletedTaskIds,
|
||||
)),
|
||||
];
|
||||
// Not queued for live apply: the pure loser is never applied live, so
|
||||
// this client's state already holds the subtasks. The rows exist for
|
||||
// upload and for seq-ordered replay after the durable loser.
|
||||
for (const subtaskOp of subtaskRecreationOps) {
|
||||
newLocalWinOps.push(subtaskOp);
|
||||
newLocalWinOpsById.set(subtaskOp.id, subtaskOp);
|
||||
// this client's state already holds the cascade victims. The rows
|
||||
// exist for upload and for seq-ordered replay after the durable loser.
|
||||
for (const recreationOp of cascadeRecreationOps) {
|
||||
newLocalWinOps.push(recreationOp);
|
||||
newLocalWinOpsById.set(recreationOp.id, recreationOp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A recovery TASK row can itself be rejected by a later per-task conflict.
|
||||
// Its replacement must re-emit any skipped subtasks and finish with the
|
||||
// current PROJECT membership, otherwise independent server acceptance can
|
||||
// lose parent/child links or append a backlog task to the regular list.
|
||||
for (const resolution of resolutions) {
|
||||
if (
|
||||
resolution.winner !== 'local' ||
|
||||
!resolution.localWinOp ||
|
||||
!resolution.conflict.localOps.some(
|
||||
(op) =>
|
||||
isLwwUpdatePayload(op.payload) &&
|
||||
op.payload.recreatesEntityAfterDelete === true,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const replacementOp = newLocalWinOpsById.get(resolution.localWinOp.id);
|
||||
if (!replacementOp) continue;
|
||||
const followUpOps = await this.createTaskRecreationFollowUpOps(replacementOp);
|
||||
const shouldApply = compensationOpIdsToApply.has(replacementOp.id);
|
||||
for (const followUpOp of followUpOps) {
|
||||
newLocalWinOps.push(followUpOp);
|
||||
newLocalWinOpsById.set(followUpOp.id, followUpOp);
|
||||
if (shouldApply) compensationOpIdsToApply.add(followUpOp.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const resolution of resolutions) {
|
||||
// Note: localWinOp is undefined for archive-wins sibling conflicts
|
||||
// (non-archive conflicts for an entity being archived). These resolve
|
||||
|
|
@ -2180,7 +2526,7 @@ export class ConflictResolutionService {
|
|||
// it to win. Using Date.now() would give it an unfair advantage in future conflicts.
|
||||
const preservedTimestamp = Math.max(...conflict.localOps.map((op) => op.timestamp));
|
||||
|
||||
const localWinOp = this.createLWWUpdateOp(
|
||||
let localWinOp = this.createLWWUpdateOp(
|
||||
conflict.entityType,
|
||||
conflict.entityId,
|
||||
entityState,
|
||||
|
|
@ -2190,9 +2536,17 @@ export class ConflictResolutionService {
|
|||
'replace',
|
||||
latestProjectMoveEntityIds(conflict.entityId, conflict.localOps),
|
||||
);
|
||||
return conflict.remoteOps.some((op) => op.opType === OpType.Delete)
|
||||
? markLwwDeleteRecreation(localWinOp)
|
||||
: localWinOp;
|
||||
if (
|
||||
conflict.remoteOps.some((op) => op.opType === OpType.Delete) ||
|
||||
conflict.localOps.some(
|
||||
(op) =>
|
||||
isLwwUpdatePayload(op.payload) &&
|
||||
op.payload.recreatesEntityAfterDelete === true,
|
||||
)
|
||||
) {
|
||||
localWinOp = markLwwDeleteRecreation(localWinOp);
|
||||
}
|
||||
return localWinOp;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -2653,6 +3007,210 @@ export class ConflictResolutionService {
|
|||
return recreationOps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the TASK ids removed by DELETE ops in the same resolution batch.
|
||||
* A bulk `deleteTasks` op carries every id in `entityIds` and mirrors only
|
||||
* the first to `entityId`, with an empty `entityChanges`, so union both via
|
||||
* `getOpEntityIds` — reading `entityId` alone would miss every trailing id
|
||||
* and let recovery resurrect it. A mixed-entity payload can additionally
|
||||
* carry task deletes in `entityChanges`. Used to keep project/parent recovery
|
||||
* from recreating a task another device is concurrently deleting. Archive ops
|
||||
* are `OpType.Update` and are intentionally excluded.
|
||||
*/
|
||||
private _collectDeletedTaskIds(ops: readonly Operation[]): Set<string> {
|
||||
const deletedTaskIds = new Set<string>();
|
||||
for (const op of ops) {
|
||||
if (op.entityType === 'TASK' && op.opType === OpType.Delete) {
|
||||
for (const id of getOpEntityIds(op)) deletedTaskIds.add(id);
|
||||
}
|
||||
if (isMultiEntityPayload(op.payload)) {
|
||||
for (const change of op.payload.entityChanges) {
|
||||
if (
|
||||
change.entityType === 'TASK' &&
|
||||
change.opType === OpType.Delete &&
|
||||
change.entityId
|
||||
) {
|
||||
deletedTaskIds.add(change.entityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return deletedTaskIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreates the active tasks removed by a losing remote `deleteProject`.
|
||||
*
|
||||
* The first PROJECT compensation makes the parent available before any TASK
|
||||
* recreation is delivered. TASK snapshots then restore every cascade target
|
||||
* that still exists locally; a task deleted on this device stays deleted.
|
||||
* Finally, a second PROJECT snapshot restores the exact regular/backlog lists
|
||||
* after the task entities exist. That last row is required because the LWW
|
||||
* reducer filters missing task IDs from a project snapshot, and TASK entities
|
||||
* do not encode whether they belong to the regular list or the backlog.
|
||||
* Keeping this durable order also works when upload/download pagination puts
|
||||
* every compensation in a separate batch.
|
||||
*
|
||||
* Notes, archived tasks, and other deleteProject cascades are intentionally
|
||||
* outside this task-recovery path; they need their own snapshot design.
|
||||
*/
|
||||
private async _createTaskRecreationOpsForWinningProject(
|
||||
projectCompensationOp: Operation,
|
||||
remoteDeleteOp: Operation,
|
||||
concurrentlyDeletedTaskIds: ReadonlySet<string> = new Set(),
|
||||
): Promise<Operation[]> {
|
||||
if (
|
||||
projectCompensationOp.entityType !== 'PROJECT' ||
|
||||
remoteDeleteOp.entityType !== 'PROJECT' ||
|
||||
remoteDeleteOp.actionType !== ActionType.TASK_SHARED_DELETE_PROJECT ||
|
||||
!projectCompensationOp.entityId
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const allTaskIds = extractActionPayload(remoteDeleteOp.payload)['allTaskIds'];
|
||||
const winningProjectState = extractActionPayload(projectCompensationOp.payload);
|
||||
const regularTaskIds = winningProjectState['taskIds'];
|
||||
const backlogTaskIds = winningProjectState['backlogTaskIds'];
|
||||
const projectRootTaskIds = [
|
||||
...(Array.isArray(regularTaskIds) ? regularTaskIds : []),
|
||||
...(Array.isArray(backlogTaskIds) ? backlogTaskIds : []),
|
||||
].filter((taskId): taskId is string => typeof taskId === 'string');
|
||||
const uniqueTaskIds = new Set(
|
||||
(Array.isArray(allTaskIds) ? allTaskIds : []).filter(
|
||||
(taskId): taskId is string => typeof taskId === 'string',
|
||||
),
|
||||
);
|
||||
for (const taskId of projectRootTaskIds) uniqueTaskIds.add(taskId);
|
||||
|
||||
const taskStateCache = new Map<string, unknown>();
|
||||
const childTaskIds: string[] = [];
|
||||
for (const rootTaskId of new Set(projectRootTaskIds)) {
|
||||
const rootTaskState = await this.getCurrentEntityState(
|
||||
'TASK' as EntityType,
|
||||
rootTaskId,
|
||||
);
|
||||
taskStateCache.set(rootTaskId, rootTaskState);
|
||||
// A root deleted concurrently in this batch takes its subtree with it;
|
||||
// don't gather its children only to recreate them as orphans.
|
||||
if (concurrentlyDeletedTaskIds.has(rootTaskId)) continue;
|
||||
const subTaskIds =
|
||||
typeof rootTaskState === 'object' && rootTaskState !== null
|
||||
? (rootTaskState as Record<string, unknown>)['subTaskIds']
|
||||
: undefined;
|
||||
if (!Array.isArray(subTaskIds)) continue;
|
||||
childTaskIds.push(
|
||||
...subTaskIds.filter(
|
||||
(subTaskId): subTaskId is string => typeof subTaskId === 'string',
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const taskId of childTaskIds) uniqueTaskIds.add(taskId);
|
||||
// Recovery decides "still present" from the pre-batch store, so it cannot
|
||||
// see a delete piggybacked as a non-conflicting op in the same batch.
|
||||
// Recreating such a task would resurrect it (with a borrowed newer
|
||||
// timestamp) on every client that applied the delete, while this client's
|
||||
// own delete wins locally — a silent divergence (#8997 review).
|
||||
for (const deletedTaskId of concurrentlyDeletedTaskIds) {
|
||||
uniqueTaskIds.delete(deletedTaskId);
|
||||
}
|
||||
if (uniqueTaskIds.size === 0) return [];
|
||||
|
||||
const clientId = await this.clientIdProvider.loadClientId();
|
||||
if (!clientId) {
|
||||
OpLog.err(
|
||||
'ConflictResolutionService: Cannot recreate winning project tasks - no client ID',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const recreationClock = this.mergeAndIncrementClocks(
|
||||
[remoteDeleteOp.vectorClock, projectCompensationOp.vectorClock],
|
||||
clientId,
|
||||
);
|
||||
const recreationOps: Operation[] = [];
|
||||
const recreationTaskStates = new Map<string, unknown>();
|
||||
for (const taskId of uniqueTaskIds) {
|
||||
const taskState = taskStateCache.has(taskId)
|
||||
? taskStateCache.get(taskId)
|
||||
: await this.getCurrentEntityState('TASK' as EntityType, taskId);
|
||||
if (taskState === undefined) {
|
||||
continue;
|
||||
}
|
||||
recreationTaskStates.set(taskId, taskState);
|
||||
// Prefer the task's own last-modified time as the LWW timestamp. The
|
||||
// project timestamp is unrelated to task content, so borrowing it lets
|
||||
// the snapshot clobber a CONCURRENT content edit made on another device;
|
||||
// the task's `modified` keeps that edit winning. Clock domination over
|
||||
// the delete is independent of this (it comes from recreationClock).
|
||||
const taskModified =
|
||||
typeof taskState === 'object' && taskState !== null
|
||||
? (taskState as Record<string, unknown>)['modified']
|
||||
: undefined;
|
||||
recreationOps.push(
|
||||
markLwwDeleteRecreation(
|
||||
this.createLWWUpdateOp(
|
||||
'TASK' as EntityType,
|
||||
taskId,
|
||||
taskState,
|
||||
clientId,
|
||||
recreationClock,
|
||||
typeof taskModified === 'number'
|
||||
? taskModified
|
||||
: projectCompensationOp.timestamp,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (recreationOps.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let relationshipClock = this.mergeAndIncrementClocks(
|
||||
[projectCompensationOp.vectorClock, ...recreationOps.map((op) => op.vectorClock)],
|
||||
clientId,
|
||||
);
|
||||
const relationshipOps: Operation[] = [];
|
||||
for (const [taskId, taskState] of recreationTaskStates) {
|
||||
const subTaskIds =
|
||||
typeof taskState === 'object' && taskState !== null
|
||||
? (taskState as Record<string, unknown>)['subTaskIds']
|
||||
: undefined;
|
||||
if (!Array.isArray(subTaskIds) || subTaskIds.length === 0) continue;
|
||||
const relationshipOp = markLwwDeleteRecreation(
|
||||
this.createLWWUpdateOp(
|
||||
'TASK' as EntityType,
|
||||
taskId,
|
||||
taskRelationshipPatch(taskId, taskState as Record<string, unknown>),
|
||||
clientId,
|
||||
relationshipClock,
|
||||
projectCompensationOp.timestamp,
|
||||
'patch',
|
||||
),
|
||||
);
|
||||
relationshipOps.push(relationshipOp);
|
||||
relationshipClock = this.mergeAndIncrementClocks(
|
||||
[relationshipClock, relationshipOp.vectorClock],
|
||||
clientId,
|
||||
);
|
||||
}
|
||||
const projectMembershipOp = markLwwDeleteRecreation(
|
||||
this.createLWWUpdateOp(
|
||||
'PROJECT' as EntityType,
|
||||
projectCompensationOp.entityId,
|
||||
{
|
||||
id: projectCompensationOp.entityId,
|
||||
taskIds: winningProjectState['taskIds'],
|
||||
backlogTaskIds: winningProjectState['backlogTaskIds'],
|
||||
},
|
||||
clientId,
|
||||
relationshipClock,
|
||||
projectCompensationOp.timestamp,
|
||||
'patch',
|
||||
),
|
||||
);
|
||||
return [...recreationOps, ...relationshipOps, projectMembershipOp];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts remote UPDATE operations to LWW Update format when entity was deleted locally.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { SupersededOperationResolverService } from './superseded-operation-resolver.service';
|
||||
import { OperationLogStoreService } from '../persistence/operation-log-store.service';
|
||||
import {
|
||||
MixedSourceWrittenOperation,
|
||||
OperationLogStoreService,
|
||||
} from '../persistence/operation-log-store.service';
|
||||
import { VectorClockService } from './vector-clock.service';
|
||||
import { ConflictResolutionService } from './conflict-resolution.service';
|
||||
import { LockService } from './lock.service';
|
||||
|
|
@ -44,6 +47,7 @@ describe('SupersededOperationResolverService', () => {
|
|||
beforeEach(() => {
|
||||
mockOpLogStore = jasmine.createSpyObj('OperationLogStoreService', [
|
||||
'markRejected',
|
||||
'appendMixedSourceBatchSkipDuplicates',
|
||||
'appendWithVectorClockUpdate',
|
||||
]);
|
||||
mockVectorClockService = jasmine.createSpyObj('VectorClockService', [
|
||||
|
|
@ -53,6 +57,7 @@ describe('SupersededOperationResolverService', () => {
|
|||
'getCurrentEntityState',
|
||||
'mergeAndIncrementClocks',
|
||||
'createLWWUpdateOp',
|
||||
'createTaskRecreationFollowUpOps',
|
||||
]);
|
||||
mockLockService = jasmine.createSpyObj('LockService', ['request']);
|
||||
mockSnackService = jasmine.createSpyObj('SnackService', ['open']);
|
||||
|
|
@ -66,6 +71,17 @@ describe('SupersededOperationResolverService', () => {
|
|||
mockVectorClockService.getCurrentVectorClock.and.returnValue(Promise.resolve({}));
|
||||
mockOpLogStore.markRejected.and.returnValue(Promise.resolve());
|
||||
mockOpLogStore.appendWithVectorClockUpdate.and.returnValue(Promise.resolve(1));
|
||||
mockOpLogStore.appendMixedSourceBatchSkipDuplicates.and.callFake(async (batches) => {
|
||||
const written: MixedSourceWrittenOperation[] = [];
|
||||
let seq = 1;
|
||||
for (const batch of batches) {
|
||||
for (const op of batch.ops) {
|
||||
await mockOpLogStore.appendWithVectorClockUpdate(op, batch.source);
|
||||
written.push({ seq: seq++, op, source: batch.source });
|
||||
}
|
||||
}
|
||||
return { written, skippedCount: 0 };
|
||||
});
|
||||
// Mock lock service to execute the callback immediately
|
||||
mockLockService.request.and.callFake(
|
||||
(_lockName: string, callback: () => Promise<any>) => callback(),
|
||||
|
|
@ -107,6 +123,7 @@ describe('SupersededOperationResolverService', () => {
|
|||
schemaVersion: 1,
|
||||
}),
|
||||
);
|
||||
mockConflictResolutionService.createTaskRecreationFollowUpOps.and.resolveTo([]);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
|
|
@ -174,12 +191,31 @@ describe('SupersededOperationResolverService', () => {
|
|||
// Verify write operations happen inside the lock
|
||||
expect(callOrder).toEqual([
|
||||
'lock-start',
|
||||
'markRejected',
|
||||
'appendWithVectorClockUpdate',
|
||||
'markRejected',
|
||||
'lock-end',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps superseded rows retryable when the replacement batch fails', async () => {
|
||||
const supersededOp = createMockOperation('op-1', 'TASK', 'task-1', {
|
||||
clientA: 1,
|
||||
});
|
||||
mockConflictResolutionService.getCurrentEntityState.and.resolveTo({
|
||||
id: 'task-1',
|
||||
title: 'Current task',
|
||||
});
|
||||
mockOpLogStore.appendMixedSourceBatchSkipDuplicates.and.rejectWith(
|
||||
new Error('batch failed'),
|
||||
);
|
||||
|
||||
await expectAsync(
|
||||
service.resolveSupersededLocalOps([{ opId: 'op-1', op: supersededOp }]),
|
||||
).toBeRejectedWithError('batch failed');
|
||||
|
||||
expect(mockOpLogStore.markRejected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 0 when supersededOps array is empty', async () => {
|
||||
const result = await service.resolveSupersededLocalOps([]);
|
||||
|
||||
|
|
@ -261,6 +297,91 @@ describe('SupersededOperationResolverService', () => {
|
|||
expect(appendedOp.timestamp).toBe(1000); // Preserved from original
|
||||
});
|
||||
|
||||
it('preserves recreate guards and appends their relationship follow-ups (#8997)', async () => {
|
||||
const supersededOp: Operation = {
|
||||
...createMockOperation('op-1', 'TASK', 'task-1', { clientA: 5 }, 1_000),
|
||||
actionType: '[TASK] LWW Update' as ActionType,
|
||||
payload: {
|
||||
actionPayload: {
|
||||
id: 'task-1',
|
||||
projectId: 'project-1',
|
||||
subTaskIds: [],
|
||||
},
|
||||
entityChanges: [],
|
||||
lwwUpdateMode: 'replace',
|
||||
recreatesEntityAfterDelete: true,
|
||||
},
|
||||
};
|
||||
const replacementOp: Operation = {
|
||||
...supersededOp,
|
||||
id: 'replacement-task',
|
||||
payload: {
|
||||
actionPayload: {
|
||||
id: 'task-1',
|
||||
projectId: 'project-1',
|
||||
subTaskIds: [],
|
||||
},
|
||||
entityChanges: [],
|
||||
lwwUpdateMode: 'replace',
|
||||
},
|
||||
};
|
||||
const projectFollowUp: Operation = {
|
||||
...replacementOp,
|
||||
id: 'project-follow-up',
|
||||
entityType: 'PROJECT',
|
||||
entityId: 'project-1',
|
||||
actionType: '[PROJECT] LWW Update' as ActionType,
|
||||
};
|
||||
const ordinarySupersededOp = createMockOperation(
|
||||
'op-2',
|
||||
'TAG',
|
||||
'tag-1',
|
||||
{ clientA: 6 },
|
||||
2_000,
|
||||
);
|
||||
const ordinaryReplacementOp: Operation = {
|
||||
...ordinarySupersededOp,
|
||||
id: 'replacement-tag',
|
||||
clientId: TEST_CLIENT_ID,
|
||||
};
|
||||
mockConflictResolutionService.getCurrentEntityState.and.callFake(
|
||||
(entityType: EntityType) =>
|
||||
Promise.resolve(
|
||||
entityType === 'TASK'
|
||||
? (replacementOp.payload as { actionPayload: unknown }).actionPayload
|
||||
: { id: 'tag-1', title: 'Tag' },
|
||||
),
|
||||
);
|
||||
mockConflictResolutionService.createLWWUpdateOp.and.callFake((entityType) =>
|
||||
entityType === 'TASK' ? replacementOp : ordinaryReplacementOp,
|
||||
);
|
||||
mockConflictResolutionService.createTaskRecreationFollowUpOps.and.callFake((op) =>
|
||||
Promise.resolve(op.entityType === 'TASK' ? [projectFollowUp] : []),
|
||||
);
|
||||
|
||||
const result = await service.resolveSupersededLocalOps([
|
||||
{ opId: supersededOp.id, op: supersededOp },
|
||||
{ opId: ordinarySupersededOp.id, op: ordinarySupersededOp },
|
||||
]);
|
||||
|
||||
expect(result).toBe(2);
|
||||
expect(mockOpLogStore.appendMixedSourceBatchSkipDuplicates).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
const appendedOps = mockOpLogStore.appendWithVectorClockUpdate.calls
|
||||
.allArgs()
|
||||
.map(([op]) => op);
|
||||
expect(
|
||||
(appendedOps[0].payload as { recreatesEntityAfterDelete?: boolean })
|
||||
.recreatesEntityAfterDelete,
|
||||
).toBeTrue();
|
||||
expect(appendedOps[1]).toBe(projectFollowUp);
|
||||
expect(appendedOps[2]).toBe(ordinaryReplacementOp);
|
||||
expect(
|
||||
mockConflictResolutionService.createTaskRecreationFollowUpOps,
|
||||
).toHaveBeenCalledWith(appendedOps[0]);
|
||||
});
|
||||
|
||||
it('should not reuse a generic multi-task footprint for an LWW update', async () => {
|
||||
const supersededOp = createMockOperation(
|
||||
'op-1',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { OperationLogStoreService } from '../persistence/operation-log-store.service';
|
||||
import { ActionType, Operation, OpType, VectorClock } from '../core/operation.types';
|
||||
import {
|
||||
ActionType,
|
||||
isLwwUpdatePayload,
|
||||
Operation,
|
||||
OpType,
|
||||
VectorClock,
|
||||
} from '../core/operation.types';
|
||||
import { mergeVectorClocks } from '../../core/util/vector-clock';
|
||||
import { OpLog } from '../../core/log';
|
||||
import {
|
||||
|
|
@ -124,6 +130,7 @@ export class SupersededOperationResolverService {
|
|||
|
||||
const opsToReject: string[] = [];
|
||||
const newOpsCreated: Operation[] = [];
|
||||
const auxiliaryOpIds = new Set<string>();
|
||||
|
||||
// Handle bulk semantic operations BEFORE entity-by-entity grouping.
|
||||
// moveToArchive uses OpType.Update but its reducer removes entities from the NgRx store
|
||||
|
|
@ -256,7 +263,7 @@ export class SupersededOperationResolverService {
|
|||
: undefined;
|
||||
|
||||
// Create new UPDATE op with current state and merged clock
|
||||
const newOp = this.conflictResolutionService.createLWWUpdateOp(
|
||||
let newOp = this.conflictResolutionService.createLWWUpdateOp(
|
||||
entityType,
|
||||
entityId,
|
||||
entityState,
|
||||
|
|
@ -267,7 +274,30 @@ export class SupersededOperationResolverService {
|
|||
declaredEntityIds,
|
||||
);
|
||||
|
||||
if (
|
||||
entityOps.some(
|
||||
({ op }) =>
|
||||
isLwwUpdatePayload(op.payload) &&
|
||||
op.payload.recreatesEntityAfterDelete === true,
|
||||
) &&
|
||||
isLwwUpdatePayload(newOp.payload)
|
||||
) {
|
||||
newOp = {
|
||||
...newOp,
|
||||
payload: {
|
||||
...newOp.payload,
|
||||
recreatesEntityAfterDelete: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
newOpsCreated.push(newOp);
|
||||
const followUpOps =
|
||||
await this.conflictResolutionService.createTaskRecreationFollowUpOps(newOp);
|
||||
for (const followUpOp of followUpOps) {
|
||||
newOpsCreated.push(followUpOp);
|
||||
auxiliaryOpIds.add(followUpOp.id);
|
||||
}
|
||||
opsToReject.push(...entityOps.map((e) => e.opId));
|
||||
|
||||
OpLog.normal(
|
||||
|
|
@ -276,7 +306,21 @@ export class SupersededOperationResolverService {
|
|||
);
|
||||
}
|
||||
|
||||
// Mark old ops as rejected
|
||||
// Persist every replacement group atomically and rebase its clocks in
|
||||
// durable sequence order. Retire the stale rows only afterwards: if the
|
||||
// batch fails, the originals remain retryable; if rejection fails, the
|
||||
// complete replacement group is already durable.
|
||||
if (newOpsCreated.length > 0) {
|
||||
const { written } = await this.opLogStore.appendMixedSourceBatchSkipDuplicates([
|
||||
{ ops: newOpsCreated, source: 'local' },
|
||||
]);
|
||||
for (const { op } of written) {
|
||||
OpLog.normal(
|
||||
`SupersededOperationResolverService: Appended LWW update op ${op.id} for ${op.entityType}:${op.entityId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (opsToReject.length > 0) {
|
||||
await this.opLogStore.markRejected(opsToReject);
|
||||
OpLog.normal(
|
||||
|
|
@ -284,15 +328,6 @@ export class SupersededOperationResolverService {
|
|||
);
|
||||
}
|
||||
|
||||
// Append new ops to the log (will be uploaded on next sync)
|
||||
// Uses appendWithVectorClockUpdate to ensure vector clock store stays in sync
|
||||
for (const op of newOpsCreated) {
|
||||
await this.opLogStore.appendWithVectorClockUpdate(op, 'local');
|
||||
OpLog.normal(
|
||||
`SupersededOperationResolverService: Appended LWW update op ${op.id} for ${op.entityType}:${op.entityId}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (newOpsCreated.length > 0) {
|
||||
// SPAP-15: surface via the journal-driven summary banner (with REVIEW)
|
||||
// instead of a bare snack.
|
||||
|
|
@ -309,7 +344,7 @@ export class SupersededOperationResolverService {
|
|||
});
|
||||
}
|
||||
|
||||
result = newOpsCreated.length;
|
||||
result = newOpsCreated.length - auxiliaryOpIds.size;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -348,6 +348,200 @@ describe('lwwUpdateMetaReducer', () => {
|
|||
expect(updatedState[TASK_FEATURE_NAME]?.ids).toContain('new-task-from-lww');
|
||||
});
|
||||
|
||||
it('should ignore a project recovery after its parent project was deleted (#8997)', () => {
|
||||
const state = createMockState();
|
||||
delete state[PROJECT_FEATURE_NAME]?.entities[PROJECT_ID];
|
||||
state[PROJECT_FEATURE_NAME]!.ids = [INBOX_PROJECT.id];
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: 'delayed-project-task',
|
||||
title: 'Delayed recovery',
|
||||
projectId: PROJECT_ID,
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'TASK',
|
||||
entityId: 'delayed-project-task',
|
||||
recreatesEntityAfterDelete: true,
|
||||
},
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
expect(
|
||||
updatedState[TASK_FEATURE_NAME]?.entities['delayed-project-task'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not create a task from a delayed relationship patch (#8997)', () => {
|
||||
const state = createMockState();
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: 'missing-task',
|
||||
projectId: PROJECT_ID,
|
||||
parentId: null,
|
||||
subTaskIds: [],
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'TASK',
|
||||
entityId: 'missing-task',
|
||||
lwwUpdateMode: 'patch',
|
||||
recreatesEntityAfterDelete: true,
|
||||
},
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
expect(updatedState[TASK_FEATURE_NAME]?.entities['missing-task']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not create a project from a delayed membership patch (#8997)', () => {
|
||||
const state = createMockState();
|
||||
delete state[PROJECT_FEATURE_NAME]?.entities[PROJECT_ID];
|
||||
state[PROJECT_FEATURE_NAME]!.ids = [INBOX_PROJECT.id];
|
||||
const action = {
|
||||
type: '[PROJECT] LWW Update',
|
||||
id: PROJECT_ID,
|
||||
taskIds: [],
|
||||
backlogTaskIds: [],
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'PROJECT',
|
||||
entityId: PROJECT_ID,
|
||||
lwwUpdateMode: 'patch',
|
||||
recreatesEntityAfterDelete: true,
|
||||
},
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_ID]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not move an existing task back into a deleted recovery project (#8997)', () => {
|
||||
const state = createMockState([{ projectId: INBOX_PROJECT.id }]);
|
||||
delete state[PROJECT_FEATURE_NAME]?.entities[PROJECT_ID];
|
||||
state[PROJECT_FEATURE_NAME]!.ids = [INBOX_PROJECT.id];
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
title: 'Stale delayed recovery',
|
||||
projectId: PROJECT_ID,
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'TASK',
|
||||
entityId: TASK_ID,
|
||||
recreatesEntityAfterDelete: true,
|
||||
},
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const task = updatedState[TASK_FEATURE_NAME]?.entities[TASK_ID] as Task;
|
||||
expect(task.projectId).toBe(INBOX_PROJECT.id);
|
||||
expect(task.title).toBe('Original Title');
|
||||
});
|
||||
|
||||
it('should ignore a subtask recreation while its parent task is absent (#8997)', () => {
|
||||
const state = createMockState();
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: 'delayed-subtask',
|
||||
title: 'Delayed subtask',
|
||||
projectId: PROJECT_ID,
|
||||
parentId: 'missing-parent',
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'TASK',
|
||||
entityId: 'delayed-subtask',
|
||||
recreatesEntityAfterDelete: true,
|
||||
},
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
expect(
|
||||
updatedState[TASK_FEATURE_NAME]?.entities['delayed-subtask'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should ignore a subtask recreation after its parent moved projects (#8997)', () => {
|
||||
const state = createMockState([{ projectId: INBOX_PROJECT.id }]);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: 'delayed-subtask',
|
||||
title: 'Delayed subtask',
|
||||
projectId: PROJECT_ID,
|
||||
parentId: TASK_ID,
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'TASK',
|
||||
entityId: 'delayed-subtask',
|
||||
recreatesEntityAfterDelete: true,
|
||||
},
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
expect(
|
||||
updatedState[TASK_FEATURE_NAME]?.entities['delayed-subtask'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should exclude moved tasks from a project recovery snapshot (#8997)', () => {
|
||||
const state = createMockState([{ projectId: INBOX_PROJECT.id }]);
|
||||
const action = {
|
||||
type: '[PROJECT] LWW Update',
|
||||
id: PROJECT_ID,
|
||||
taskIds: [TASK_ID],
|
||||
backlogTaskIds: [],
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'PROJECT',
|
||||
entityId: PROJECT_ID,
|
||||
lwwUpdateMode: 'patch',
|
||||
recreatesEntityAfterDelete: true,
|
||||
},
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_ID]?.taskIds).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('should exclude rejected children from a final task relationship snapshot (#8997)', () => {
|
||||
const state = createMockState([
|
||||
{ projectId: PROJECT_ID, subTaskIds: ['missing-child'] },
|
||||
]);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
projectId: PROJECT_ID,
|
||||
parentId: null,
|
||||
subTaskIds: ['missing-child'],
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'TASK',
|
||||
entityId: TASK_ID,
|
||||
lwwUpdateMode: 'patch',
|
||||
recreatesEntityAfterDelete: true,
|
||||
},
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
expect(updatedState[TASK_FEATURE_NAME]?.entities[TASK_ID]?.subTaskIds).toEqual([]);
|
||||
});
|
||||
|
||||
// Regression for issue #7330: a partial LWW Update payload (e.g. only the
|
||||
// changed fields from _convertToLWWUpdatesIfNeeded fallback) used to create
|
||||
// a task entity with `title`, `timeSpentOnDay`, `tagIds`, `subTaskIds`
|
||||
|
|
|
|||
|
|
@ -398,14 +398,23 @@ const filterOrphanedTaskIdsFromEntityData = (
|
|||
entityData: Record<string, unknown>,
|
||||
entityType: string,
|
||||
rootState: RootState,
|
||||
requiresMatchingProjectMembership: boolean,
|
||||
): Record<string, unknown> => {
|
||||
const taskState = rootState[TASK_FEATURE_NAME];
|
||||
if (!taskState) return entityData;
|
||||
const existingTaskIds = new Set(taskState.ids as string[]);
|
||||
const projectId = entityData['id'];
|
||||
const cleaned = filterTaskIdArraysFromTagOrProjectPayload(
|
||||
entityData,
|
||||
entityType,
|
||||
(id) => !existingTaskIds.has(id),
|
||||
(id) => {
|
||||
const task = taskState.entities[id] as Task | undefined;
|
||||
if (!task) return true;
|
||||
return (
|
||||
entityType === 'PROJECT' &&
|
||||
requiresMatchingProjectMembership &&
|
||||
(task.projectId !== projectId || !!task.parentId)
|
||||
);
|
||||
},
|
||||
{
|
||||
warnMessage: `lwwUpdateMetaReducer: Filtered orphaned taskIds from ${entityType} LWW Update`,
|
||||
entityId:
|
||||
|
|
@ -479,6 +488,7 @@ export const lwwUpdateMetaReducer: MetaReducer = (
|
|||
| {
|
||||
lwwUpdateMode?: LwwUpdateMode;
|
||||
isApplyingFromOtherClient?: boolean;
|
||||
recreatesEntityAfterDelete?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
let entityData: Record<string, unknown> = {};
|
||||
|
|
@ -489,7 +499,12 @@ export const lwwUpdateMetaReducer: MetaReducer = (
|
|||
}
|
||||
|
||||
// Filter orphaned taskIds/backlogTaskIds for TAG and PROJECT entities
|
||||
entityData = filterOrphanedTaskIdsFromEntityData(entityData, entityType, rootState);
|
||||
entityData = filterOrphanedTaskIdsFromEntityData(
|
||||
entityData,
|
||||
entityType,
|
||||
rootState,
|
||||
actionMeta?.recreatesEntityAfterDelete === true,
|
||||
);
|
||||
|
||||
// Singleton entities: replace entire feature state with the winning data
|
||||
if (isSingletonEntity(config)) {
|
||||
|
|
@ -591,6 +606,19 @@ export const lwwUpdateMetaReducer: MetaReducer = (
|
|||
`lwwUpdateMetaReducer: Stripped virtual TODAY tag from task ${entityId}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
actionMeta?.recreatesEntityAfterDelete === true &&
|
||||
actionMeta.lwwUpdateMode === 'patch' &&
|
||||
Array.isArray(entityData['subTaskIds'])
|
||||
) {
|
||||
const parentId = entityData['id'];
|
||||
const parentProjectId = entityData['projectId'];
|
||||
entityData['subTaskIds'] = entityData['subTaskIds'].filter((id) => {
|
||||
if (typeof id !== 'string') return false;
|
||||
const child = rootState[TASK_FEATURE_NAME].entities[id] as Task | undefined;
|
||||
return child?.parentId === parentId && child.projectId === parentProjectId;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const existingEntityCandidate = (
|
||||
|
|
@ -618,6 +646,7 @@ export const lwwUpdateMetaReducer: MetaReducer = (
|
|||
if (
|
||||
entityType === 'TASK' &&
|
||||
existingEntity &&
|
||||
actionMeta?.recreatesEntityAfterDelete !== true &&
|
||||
Object.prototype.hasOwnProperty.call(entityData, 'projectId')
|
||||
) {
|
||||
const requestedProjectId = entityData['projectId'];
|
||||
|
|
@ -639,6 +668,44 @@ export const lwwUpdateMetaReducer: MetaReducer = (
|
|||
}
|
||||
}
|
||||
|
||||
// Marked patch rows reconcile relationships after a full replacement. If
|
||||
// pagination/conflict resolution delivers one without that replacement,
|
||||
// it must not synthesize a partial TASK/PROJECT from relationship fields.
|
||||
if (
|
||||
!existingEntity &&
|
||||
actionMeta?.recreatesEntityAfterDelete === true &&
|
||||
actionMeta.lwwUpdateMode === 'patch'
|
||||
) {
|
||||
OpLog.log(
|
||||
`lwwUpdateMetaReducer: Ignoring delayed ${entityType} relationship patch ${entityId} because the entity is absent`,
|
||||
);
|
||||
return reducer(state, action);
|
||||
}
|
||||
|
||||
// Recreate-after-delete rows can be uploaded independently from the parent
|
||||
// recovery that made them valid. If that project (or subtask parent) is no
|
||||
// longer present, a delayed row must not create an orphan or move an
|
||||
// existing task back underneath the deleted parent.
|
||||
const recreationProjectId = entityData['projectId'];
|
||||
const recreationParentId = entityData['parentId'];
|
||||
const recreationParent =
|
||||
typeof recreationParentId === 'string'
|
||||
? (rootState[TASK_FEATURE_NAME].entities[recreationParentId] as Task | undefined)
|
||||
: undefined;
|
||||
const hasInvalidRecreationParent =
|
||||
entityType === 'TASK' &&
|
||||
actionMeta?.recreatesEntityAfterDelete === true &&
|
||||
((typeof recreationProjectId === 'string' &&
|
||||
!rootState[PROJECT_FEATURE_NAME].entities[recreationProjectId]) ||
|
||||
(typeof recreationParentId === 'string' &&
|
||||
(!recreationParent || recreationParent.projectId !== recreationProjectId)));
|
||||
if (hasInvalidRecreationParent) {
|
||||
OpLog.log(
|
||||
`lwwUpdateMetaReducer: Ignoring delayed TASK recreation ${entityId} because its parent relationship is no longer valid`,
|
||||
);
|
||||
return reducer(state, action);
|
||||
}
|
||||
|
||||
let updatedFeatureState: unknown;
|
||||
|
||||
if (!existingEntity) {
|
||||
|
|
|
|||
|
|
@ -416,6 +416,59 @@ describe('projectSharedMetaReducer', () => {
|
|||
});
|
||||
|
||||
describe('deleteProject action', () => {
|
||||
it('should enrich stale task ids from current project relationships only', () => {
|
||||
const testState = createStateWithExistingTasks(
|
||||
['listed-root'],
|
||||
['backlog-root'],
|
||||
[],
|
||||
);
|
||||
const listedRoot = testState[TASK_FEATURE_NAME].entities['listed-root'];
|
||||
if (!listedRoot) {
|
||||
throw new Error('Expected listed root test fixture.');
|
||||
}
|
||||
testState[TASK_FEATURE_NAME] = {
|
||||
...testState[TASK_FEATURE_NAME],
|
||||
ids: [
|
||||
...(testState[TASK_FEATURE_NAME].ids as string[]),
|
||||
'listed-child',
|
||||
'project-id-only',
|
||||
'unrelated-task',
|
||||
],
|
||||
entities: {
|
||||
...testState[TASK_FEATURE_NAME].entities,
|
||||
'listed-root': { ...listedRoot, subTaskIds: ['listed-child'] },
|
||||
'listed-child': createMockTask({
|
||||
id: 'listed-child',
|
||||
parentId: 'listed-root',
|
||||
}),
|
||||
'project-id-only': createMockTask({ id: 'project-id-only' }),
|
||||
'unrelated-task': createMockTask({
|
||||
id: 'unrelated-task',
|
||||
projectId: 'another-project',
|
||||
}),
|
||||
},
|
||||
};
|
||||
const action = TaskSharedActions.deleteProject({
|
||||
projectId: 'project1',
|
||||
noteIds: [],
|
||||
allTaskIds: ['payload-task'],
|
||||
});
|
||||
|
||||
metaReducer(testState, action);
|
||||
|
||||
const forwardedAction = mockReducer.calls.mostRecent().args[1] as ReturnType<
|
||||
typeof TaskSharedActions.deleteProject
|
||||
>;
|
||||
expect(forwardedAction.allTaskIds).toEqual([
|
||||
'payload-task',
|
||||
'listed-root',
|
||||
'backlog-root',
|
||||
'listed-child',
|
||||
]);
|
||||
expect(forwardedAction.allTaskIds).not.toContain('project-id-only');
|
||||
expect(forwardedAction.allTaskIds).not.toContain('unrelated-task');
|
||||
});
|
||||
|
||||
it('should remove all project tasks from all tags', () => {
|
||||
const testState = createStateWithExistingTasks(
|
||||
[],
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { Task, TaskWithSubTasks } from '../../../features/tasks/task.model';
|
|||
import { unique } from '../../../util/unique';
|
||||
import {
|
||||
ActionHandlerMap,
|
||||
enrichDeleteProjectAction,
|
||||
getProject,
|
||||
removeTasksFromAllTags,
|
||||
removeTasksFromList,
|
||||
|
|
@ -206,10 +207,11 @@ export const projectSharedMetaReducer: MetaReducer = (
|
|||
if (!state) return reducer(state, action);
|
||||
|
||||
const extendedState = state as ExtendedState;
|
||||
const actionHandlers = createActionHandlers(extendedState, action);
|
||||
const handler = actionHandlers[action.type];
|
||||
const effectiveAction = enrichDeleteProjectAction(extendedState, action);
|
||||
const actionHandlers = createActionHandlers(extendedState, effectiveAction);
|
||||
const handler = actionHandlers[effectiveAction.type];
|
||||
const updatedState = handler ? handler(extendedState) : extendedState;
|
||||
|
||||
return reducer(updatedState, action);
|
||||
return reducer(updatedState, effectiveAction);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -506,6 +506,63 @@ describe('sectionSharedMetaReducer', () => {
|
|||
expect(updated.entities['sA']?.taskIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses current project relationships when deleteProject task ids are stale', () => {
|
||||
const state = stateWith(
|
||||
{
|
||||
root: { projectId: 'project1', subTaskIds: ['child'] },
|
||||
child: { projectId: 'project1', parentId: 'root' },
|
||||
backlog: { projectId: 'project1' },
|
||||
projectIdOnly: { projectId: 'project1' },
|
||||
unrelated: { projectId: 'another-project' },
|
||||
},
|
||||
[
|
||||
{
|
||||
id: 'sA',
|
||||
contextId: 'tA',
|
||||
contextType: WorkContextType.TAG,
|
||||
title: 'Shared tag section',
|
||||
taskIds: ['root', 'child', 'backlog', 'projectIdOnly', 'unrelated'],
|
||||
},
|
||||
],
|
||||
);
|
||||
const project = state[PROJECT_FEATURE_NAME].entities.project1;
|
||||
if (!project) {
|
||||
throw new Error('Expected project test fixture.');
|
||||
}
|
||||
state[PROJECT_FEATURE_NAME] = {
|
||||
...state[PROJECT_FEATURE_NAME],
|
||||
entities: {
|
||||
...state[PROJECT_FEATURE_NAME].entities,
|
||||
project1: {
|
||||
...project,
|
||||
taskIds: ['root'],
|
||||
backlogTaskIds: ['backlog'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
metaReducer(
|
||||
state,
|
||||
TaskSharedActions.deleteProject({
|
||||
projectId: 'project1',
|
||||
allTaskIds: ['payload-task'],
|
||||
noteIds: [],
|
||||
}),
|
||||
);
|
||||
|
||||
const [updatedState, forwardedAction] = mockReducer.calls.mostRecent().args;
|
||||
expect(updatedState[SECTION_FEATURE_NAME].entities['sA']?.taskIds).toEqual([
|
||||
'projectIdOnly',
|
||||
'unrelated',
|
||||
]);
|
||||
expect(forwardedAction.allTaskIds).toEqual([
|
||||
'payload-task',
|
||||
'root',
|
||||
'backlog',
|
||||
'child',
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips a moved task (and its subtasks) from sections in the old project only', () => {
|
||||
const state = stateWith(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { moveItemAfterAnchor } from '../../../features/work-context/store/work-c
|
|||
import { canApplyConvertToSubTask } from '../../../features/tasks/util/can-convert-task-to-sub-task';
|
||||
import {
|
||||
collectTaskAndSubTaskIds,
|
||||
enrichDeleteProjectAction,
|
||||
getProjectOrUndefined,
|
||||
isValidTaskProjectIdUpdate,
|
||||
} from './task-shared-helpers';
|
||||
|
|
@ -541,17 +542,21 @@ export const sectionSharedMetaReducer: MetaReducer<RootState> = (
|
|||
// Boot/hydration guard: skip section-side cleanup until every slice
|
||||
// it touches is hydrated.
|
||||
const ext = state as ExtendedState;
|
||||
const effectiveAction =
|
||||
ext[TASK_FEATURE_NAME] && ext[PROJECT_FEATURE_NAME]
|
||||
? enrichDeleteProjectAction(ext, action)
|
||||
: action;
|
||||
if (
|
||||
!ext[TASK_FEATURE_NAME] ||
|
||||
!ext[TAG_FEATURE_NAME] ||
|
||||
!ext[PROJECT_FEATURE_NAME] ||
|
||||
!ext[SECTION_FEATURE_NAME]
|
||||
) {
|
||||
return reducer(state, action);
|
||||
return reducer(state, effectiveAction);
|
||||
}
|
||||
const handler = ACTION_HANDLERS[action.type];
|
||||
const preState = handler ? handler(ext, action) : state;
|
||||
const next = reducer(preState, action);
|
||||
const handler = ACTION_HANDLERS[effectiveAction.type];
|
||||
const preState = handler ? handler(ext, effectiveAction) : state;
|
||||
const next = reducer(preState, effectiveAction);
|
||||
// Post-reducer TODAY_TAG.taskIds diff catches every flow that
|
||||
// removes ids from TODAY without going through a known action.
|
||||
const removedFromToday = diffRemovedTodayTaskIds(state, next);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Update } from '@ngrx/entity';
|
||||
import { Action } from '@ngrx/store';
|
||||
import { RootState } from '../../root-state';
|
||||
import { Tag } from '../../../features/tag/tag.model';
|
||||
import { Project } from '../../../features/project/project.model';
|
||||
|
|
@ -18,6 +19,7 @@ import {
|
|||
} from '../../../features/planner/store/planner.reducer';
|
||||
import { unique } from '../../../util/unique';
|
||||
import { TODAY_TAG } from '../../../features/tag/tag.const';
|
||||
import { TaskSharedActions } from '../task-shared.actions';
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
|
|
@ -306,6 +308,40 @@ export const removeTasksFromList = (taskIds: string[], toRemove: string[]): stri
|
|||
return taskIds.filter((id) => !removeSet.has(id));
|
||||
};
|
||||
|
||||
/**
|
||||
* A deleteProject captured from a stale client can omit task recreations that
|
||||
* have already reached this replay prefix. Expand only through the deleted
|
||||
* project's current root lists and those roots' canonical direct children.
|
||||
* Tasks merely carrying the projectId are deliberately excluded: scanning the
|
||||
* whole TASK store would turn a bounded relationship cascade into a global one.
|
||||
*/
|
||||
export const enrichDeleteProjectAction = (state: RootState, action: Action): Action => {
|
||||
if (action.type !== TaskSharedActions.deleteProject.type) return action;
|
||||
|
||||
const deleteProjectAction = action as ReturnType<
|
||||
typeof TaskSharedActions.deleteProject
|
||||
>;
|
||||
const project = state[PROJECT_FEATURE_NAME].entities[deleteProjectAction.projectId];
|
||||
if (!project) return action;
|
||||
|
||||
const rootTaskIds = unique([...project.taskIds, ...project.backlogTaskIds]);
|
||||
const childTaskIds = rootTaskIds.flatMap(
|
||||
(taskId) => state[TASK_FEATURE_NAME].entities[taskId]?.subTaskIds ?? [],
|
||||
);
|
||||
const allTaskIds = unique([
|
||||
...deleteProjectAction.allTaskIds,
|
||||
...rootTaskIds,
|
||||
...childTaskIds,
|
||||
]);
|
||||
const isUnchanged =
|
||||
allTaskIds.length === deleteProjectAction.allTaskIds.length &&
|
||||
allTaskIds.every((taskId, index) => taskId === deleteProjectAction.allTaskIds[index]);
|
||||
|
||||
if (isUnchanged) return action;
|
||||
const enrichedAction = { ...deleteProjectAction, allTaskIds };
|
||||
return enrichedAction;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// PLANNER DAY HELPERS
|
||||
// =============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue