fix(sync): preserve partial LWW relationships

This commit is contained in:
Johannes Millan 2026-05-30 18:42:43 +02:00
parent 24725c9360
commit c5080af1e9
4 changed files with 315 additions and 67 deletions

View file

@ -335,6 +335,33 @@ describe('lwwUpdateMetaReducer', () => {
expect(recreated.projectId).toBe('INBOX_PROJECT');
});
it('should add recreated task to backfilled project taskIds when partial TASK LWW Update lacks projectId', () => {
const state = createMockState();
const action = {
type: '[TASK] LWW Update',
id: 'rpt_no_project_visible',
dueDay: '2026-04-29',
meta: {
isPersistent: true,
entityType: 'TASK',
entityId: 'rpt_no_project_visible',
},
};
reducer(state, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
const recreated = updatedState[TASK_FEATURE_NAME]?.entities[
'rpt_no_project_visible'
] as Task;
const inboxProject = updatedState[PROJECT_FEATURE_NAME]?.entities[
INBOX_PROJECT.id
] as Project;
expect(recreated.projectId).toBe(INBOX_PROJECT.id);
expect(inboxProject.taskIds).toContain('rpt_no_project_visible');
});
// #7330 follow-up #2: a producer that emits `title: null` (rather than
// omitting it) used to slip past the partial-keys check (=== undefined)
// AND have `null` overwrite the DEFAULT_TASK default in the spread.
@ -1380,6 +1407,59 @@ describe('lwwUpdateMetaReducer', () => {
// Project B should still be empty
expect(projectB.taskIds).not.toContain(TASK_ID);
});
it('should preserve project.taskIds when projectId is omitted from partial LWW payload', () => {
const state = createStateWithProjects(PROJECT_A, [TASK_ID], []);
const action = {
type: '[TASK] LWW Update',
id: TASK_ID,
title: 'Updated Task Title Only',
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
};
reducer(state, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
const projectA = updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A] as Project;
expect(projectA.taskIds).toContain(TASK_ID);
});
it('should preserve backlog membership when projectId is omitted from partial LWW payload', () => {
const state = {
...createStateWithProjects(PROJECT_A, [], []),
[PROJECT_FEATURE_NAME]: {
ids: [PROJECT_A, PROJECT_B],
entities: {
[PROJECT_A]: createMockProject({
id: PROJECT_A,
title: 'Project A',
taskIds: [],
backlogTaskIds: [TASK_ID],
}),
[PROJECT_B]: createMockProject({
id: PROJECT_B,
title: 'Project B',
taskIds: [],
}),
},
},
} as Partial<RootState>;
const action = {
type: '[TASK] LWW Update',
id: TASK_ID,
title: 'Updated Backlog Task Title Only',
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
};
reducer(state, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
const projectA = updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A] as Project;
expect(projectA.taskIds).not.toContain(TASK_ID);
expect(projectA.backlogTaskIds).toContain(TASK_ID);
});
});
describe('tag.taskIds sync on task tagIds change', () => {
@ -1559,6 +1639,25 @@ describe('lwwUpdateMetaReducer', () => {
expect(tagC.taskIds).not.toContain(TASK_ID);
});
it('should preserve tag.taskIds when tagIds is omitted from partial LWW payload', () => {
const state = createStateWithTags([TAG_A, TAG_B], [TASK_ID], [TASK_ID]);
const action = {
type: '[TASK] LWW Update',
id: TASK_ID,
title: 'Updated Task Title Only',
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
};
reducer(state, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
const tagA = updatedState[TAG_FEATURE_NAME]?.entities[TAG_A] as Tag;
const tagB = updatedState[TAG_FEATURE_NAME]?.entities[TAG_B] as Tag;
expect(tagA.taskIds).toContain(TASK_ID);
expect(tagB.taskIds).toContain(TASK_ID);
});
it('should handle task with no previous tags getting tags', () => {
const state = createStateWithTags([], [], []);
const action = {
@ -1726,9 +1825,8 @@ describe('lwwUpdateMetaReducer', () => {
expect(projectA.taskIds).not.toContain(SUBTASK_ID);
});
it('should handle subtask becoming orphan (parentId removed via LWW, same project)', () => {
it('should add promoted subtask to project.taskIds when parentId is removed in same project', () => {
// Subtask loses its parent via LWW update but stays in same project.
// Since oldProjectId === newProjectId, syncProjectTaskIds returns early — no taskIds change.
const state = {
[TASK_FEATURE_NAME]: {
ids: [SUBTASK_ID],
@ -1774,8 +1872,61 @@ describe('lwwUpdateMetaReducer', () => {
// Task should now be a top-level task
expect(subtask.parentId).toBeNull();
// projectId didn't change (same project), so syncProjectTaskIds exits early
expect(projectA.taskIds).toContain(SUBTASK_ID);
});
it('should remove task from project.taskIds when it becomes a subtask in same project', () => {
const state = {
[TASK_FEATURE_NAME]: {
ids: [SUBTASK_ID, PARENT_TASK],
entities: {
[SUBTASK_ID]: createMockTask({
id: SUBTASK_ID,
parentId: undefined,
projectId: PROJECT_A,
}),
[PARENT_TASK]: createMockTask({
id: PARENT_TASK,
parentId: undefined,
projectId: PROJECT_A,
subTaskIds: [],
}),
},
currentTaskId: null,
selectedTaskId: null,
taskDetailTargetPanel: null,
isDataLoaded: true,
lastCurrentTaskId: null,
},
[PROJECT_FEATURE_NAME]: {
ids: [PROJECT_A],
entities: {
[PROJECT_A]: createMockProject({ id: PROJECT_A, taskIds: [SUBTASK_ID] }),
},
},
[TAG_FEATURE_NAME]: {
ids: [],
entities: {},
},
} as Partial<RootState>;
const action = {
type: '[TASK] LWW Update',
id: SUBTASK_ID,
parentId: PARENT_TASK,
projectId: PROJECT_A,
title: 'Now a subtask',
meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID },
};
reducer(state, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
const projectA = updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A] as Project;
const parent = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_TASK] as Task;
expect(projectA.taskIds).not.toContain(SUBTASK_ID);
expect(parent.subTaskIds).toContain(SUBTASK_ID);
});
it('should add promoted subtask to new project.taskIds when parentId is cleared and projectId changes', () => {
@ -2330,6 +2481,23 @@ describe('lwwUpdateMetaReducer', () => {
expect(todayTag.taskIds).not.toContain(TASK_ID);
});
it('should preserve TODAY_TAG.taskIds when due fields are omitted from partial LWW payload', () => {
const state = createStateWithTodayTag(TODAY_STR, [TASK_ID]);
const action = {
type: '[TASK] LWW Update',
id: TASK_ID,
title: 'Updated Task Title Only',
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
};
reducer(state, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
const todayTag = updatedState[TAG_FEATURE_NAME]?.entities[TODAY_TAG.id] as Tag;
expect(todayTag.taskIds).toContain(TASK_ID);
});
});
describe('TODAY_TAG.taskIds sync on task dueWithTime change', () => {
@ -2446,6 +2614,23 @@ describe('lwwUpdateMetaReducer', () => {
expect(todayTag.taskIds).toEqual([TASK_ID, 'other-task']);
});
it('should preserve TODAY_TAG.taskIds when dueWithTime is omitted from partial LWW payload', () => {
const state = createStateWithDueWithTime(undefined, TODAY_TIMESTAMP, [TASK_ID]);
const action = {
type: '[TASK] LWW Update',
id: TASK_ID,
title: 'Updated Task Title Only',
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
};
reducer(state, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
const todayTag = updatedState[TAG_FEATURE_NAME]?.entities[TODAY_TAG.id] as Tag;
expect(todayTag.taskIds).toContain(TASK_ID);
});
it('should use dueWithTime over dueDay when both present (mutual exclusivity)', () => {
// Task has dueDay = today, LWW update sets dueWithTime to tomorrow
// dueWithTime should take precedence — task should be removed from today
@ -2640,6 +2825,23 @@ describe('lwwUpdateMetaReducer', () => {
expect(parentB.subTaskIds).not.toContain(SUBTASK_ID);
});
it('should preserve parent.subTaskIds when parentId is omitted from partial LWW payload', () => {
const state = createStateWithParents(PARENT_A, [SUBTASK_ID], []);
const action = {
type: '[TASK] LWW Update',
id: SUBTASK_ID,
title: 'Updated Subtask Title Only',
meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID },
};
reducer(state, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
const parentA = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_A] as Task;
expect(parentA.subTaskIds).toContain(SUBTASK_ID);
});
it('should handle parentId change when old parent does not exist', () => {
// Old parent was deleted, new parent exists
const state = {

View file

@ -30,12 +30,12 @@ import { getDbDateStr, isDBDateStr } from '../../../util/get-db-date-str';
import { isTodayWithOffset } from '../../../util/is-today.util';
/**
* Updates project.taskIds arrays when a task's projectId changes via LWW Update.
* Updates project.taskIds arrays when a task's project membership changes via LWW Update.
*
* When LWW conflict resolution updates a task's projectId, we must also update
* the corresponding project.taskIds arrays to maintain bidirectional consistency:
* - Remove task from old project's taskIds (if it exists there)
* - Add task to new project's taskIds (if not already there)
* When LWW conflict resolution updates a task's projectId or parentId, we must
* also update the corresponding project.taskIds arrays to maintain consistency:
* - Remove task from old project's taskIds if it moved away or became a subtask
* - Add task to new project's taskIds if it is now a root task
*
* This is necessary because the original moveToOtherProject action updates both
* the task and project entities atomically, but LWW Update only syncs the TASK
@ -46,22 +46,17 @@ const syncProjectTaskIds = (
taskId: string,
oldProjectId: string | undefined,
newProjectId: string | undefined,
isSubTask: boolean,
oldIsSubTask: boolean,
newIsSubTask: boolean,
): RootState => {
// Don't add subtasks to project.taskIds - only parent tasks should be there
if (isSubTask) {
return state;
}
// If projectId didn't change, nothing to do
if (oldProjectId === newProjectId) {
return state;
}
let projectState = state[PROJECT_FEATURE_NAME];
const shouldRemoveFromOldProject =
!!oldProjectId && (oldProjectId !== newProjectId || newIsSubTask);
const shouldAddToNewProject =
!!newProjectId && !newIsSubTask && (oldProjectId !== newProjectId || oldIsSubTask);
// Remove from old project's taskIds
if (oldProjectId && projectState.entities[oldProjectId]) {
if (shouldRemoveFromOldProject && oldProjectId && projectState.entities[oldProjectId]) {
const oldProject = projectState.entities[oldProjectId] as Project;
const filteredTaskIds = oldProject.taskIds.filter((id) => id !== taskId);
const filteredBacklogTaskIds = oldProject.backlogTaskIds.filter(
@ -84,7 +79,7 @@ const syncProjectTaskIds = (
projectState,
);
}
} else if (oldProjectId) {
} else if (shouldRemoveFromOldProject && oldProjectId) {
// Old project was deleted before LWW update arrived - benign race condition
OpLog.warn(
`lwwUpdateMetaReducer: syncProjectTaskIds: old project ${oldProjectId} not found for task ${taskId}`,
@ -92,7 +87,7 @@ const syncProjectTaskIds = (
}
// Add to new project's taskIds
if (newProjectId && projectState.entities[newProjectId]) {
if (shouldAddToNewProject && newProjectId && projectState.entities[newProjectId]) {
const newProject = projectState.entities[newProjectId] as Project;
// Only add if not already present
if (!newProject.taskIds.includes(taskId)) {
@ -106,17 +101,19 @@ const syncProjectTaskIds = (
projectState,
);
}
} else if (newProjectId) {
} else if (shouldAddToNewProject && newProjectId) {
// New project was deleted before LWW update arrived - benign race condition
OpLog.warn(
`lwwUpdateMetaReducer: syncProjectTaskIds: new project ${newProjectId} not found for task ${taskId}`,
);
}
return {
...state,
[PROJECT_FEATURE_NAME]: projectState,
};
return projectState === state[PROJECT_FEATURE_NAME]
? state
: {
...state,
[PROJECT_FEATURE_NAME]: projectState,
};
};
/**
@ -608,38 +605,41 @@ export const lwwUpdateMetaReducer: MetaReducer = (
...rootState,
[featureName]: updatedFeatureState,
};
const updatedEntity = (
updatedFeatureState as unknown as {
entities?: Record<string, Record<string, unknown>>;
}
).entities?.[entityId];
// For TASK entities, sync related entities when relationships change
if (entityType === 'TASK') {
if (entityType === 'TASK' && updatedEntity) {
// Sync project.taskIds when projectId changes
const oldProjectId = existingEntity?.projectId as string | undefined;
const newProjectId = entityData['projectId'] as string | undefined;
// Use the NEW parentId to decide subtask status: if the LWW update
// clears parentId (promoting to main task), we should add it to project.taskIds.
// Fall back to existing only when the LWW update doesn't include parentId at all.
const isSubTask = !!('parentId' in entityData
? entityData['parentId']
: existingEntity?.parentId);
const newProjectId = updatedEntity.projectId as string | undefined;
const oldIsSubTask = !!existingEntity?.parentId;
const newParentId = updatedEntity.parentId as string | undefined;
const newIsSubTask = !!newParentId;
updatedState = syncProjectTaskIds(
updatedState,
entityId,
oldProjectId,
newProjectId,
isSubTask,
oldIsSubTask,
newIsSubTask,
);
// Sync tag.taskIds when tagIds changes
const oldTagIds = (existingEntity?.tagIds as string[]) || [];
const newTagIds = (entityData['tagIds'] as string[]) || [];
const newTagIds = (updatedEntity.tagIds as string[]) || [];
updatedState = syncTagTaskIds(updatedState, entityId, oldTagIds, newTagIds);
// Sync TODAY_TAG.taskIds when dueDay or dueWithTime changes (virtual tag based on dueDay/dueWithTime)
const oldDueDay = existingEntity?.dueDay as string | undefined;
const newDueDay = entityData['dueDay'] as string | undefined;
const newDueDay = updatedEntity.dueDay as string | undefined;
const oldDueWithTime = existingEntity?.dueWithTime as number | undefined;
const newDueWithTime = entityData['dueWithTime'] as number | undefined;
const newDueWithTime = updatedEntity.dueWithTime as number | undefined;
updatedState = syncTodayTagTaskIds(
updatedState,
entityId,
@ -651,7 +651,6 @@ export const lwwUpdateMetaReducer: MetaReducer = (
// Sync parent.subTaskIds when parentId changes
const oldParentId = existingEntity?.parentId as string | undefined;
const newParentId = entityData['parentId'] as string | undefined;
updatedState = syncParentSubTaskIds(
updatedState,
entityId,

View file

@ -466,6 +466,46 @@ describe('validateAndFixDataConsistencyAfterBatchUpdate', () => {
expect(tag1?.taskIds).toContain('valid-task');
});
it('should not mutate original tag state when orphan cleanup removes tag references', () => {
const state = createStateWithExistingTasks(['orphan-with-tag', 'valid-task']);
state[TASK_FEATURE_NAME].entities['orphan-with-tag'] = {
...state[TASK_FEATURE_NAME].entities['orphan-with-tag']!,
parentId: 'non-existent-parent',
tagIds: ['tag1'],
};
state[TAG_FEATURE_NAME].entities = {
tag1: createMockTag({
id: 'tag1',
title: 'Tag 1',
taskIds: ['orphan-with-tag', 'valid-task'],
}),
};
const originalTagState = state[TAG_FEATURE_NAME];
const originalTagEntities = state[TAG_FEATURE_NAME].entities;
const originalTag = state[TAG_FEATURE_NAME].entities['tag1'];
const result = validateAndFixDataConsistencyAfterBatchUpdate(
state,
'project1',
[], // tasksToAdd
[], // tasksToUpdate
[], // taskIdsToDelete
null, // newTaskOrder
);
expect(result[TAG_FEATURE_NAME]).not.toBe(originalTagState);
expect(result[TAG_FEATURE_NAME].entities).not.toBe(originalTagEntities);
expect(result[TAG_FEATURE_NAME].entities['tag1']).not.toBe(originalTag);
expect(state[TAG_FEATURE_NAME].entities['tag1']?.taskIds).toEqual([
'orphan-with-tag',
'valid-task',
]);
expect(result[TAG_FEATURE_NAME].entities['tag1']?.taskIds).toEqual(['valid-task']);
});
it('should handle cascading deletion of orphaned subtasks', () => {
const state = createStateWithExistingTasks(['parent', 'child', 'grandchild']);

View file

@ -19,6 +19,36 @@ const arraysHaveSameElements = (a: string[], b: string[]): boolean => {
return b.every((id) => setA.has(id));
};
type TagUpdate = { id: string; changes: Partial<Tag> };
const applyTagUpdates = (state: RootState, updates: TagUpdate[]): RootState => {
if (updates.length === 0) {
return state;
}
const tagState = {
...state[TAG_FEATURE_NAME],
entities: {
...state[TAG_FEATURE_NAME].entities,
},
};
updates.forEach((update) => {
const tag = tagState.entities[update.id];
if (tag) {
tagState.entities[update.id] = {
...tag,
...update.changes,
};
}
});
return {
...state,
[TAG_FEATURE_NAME]: tagState,
};
};
/**
* Validates and fixes data consistency across tasks, projects, and tags
* Ensures bidirectional consistency: project.taskIds task.projectId, tag.taskIds task.tagIds
@ -31,7 +61,6 @@ export const validateAndFixDataConsistencyAfterBatchUpdate = (
taskIdsToDelete: string[],
newTaskOrder: string[] | null,
): RootState => {
// eslint-disable-next-line prefer-const
let newState = { ...state };
// Get all affected task IDs for validation
@ -125,7 +154,7 @@ export const validateAndFixDataConsistencyAfterBatchUpdate = (
});
// Apply tag updates
const tagUpdates: { id: string; changes: Partial<Tag> }[] = [];
const tagUpdates: TagUpdate[] = [];
tagsToUpdate.forEach((taskIds, tagId) => {
const currentTag = newState[TAG_FEATURE_NAME].entities[tagId];
if (currentTag) {
@ -142,21 +171,7 @@ export const validateAndFixDataConsistencyAfterBatchUpdate = (
}
});
if (tagUpdates.length > 0) {
newState[TAG_FEATURE_NAME] = {
...newState[TAG_FEATURE_NAME],
entities: {
...newState[TAG_FEATURE_NAME].entities,
},
};
tagUpdates.forEach((update) => {
newState[TAG_FEATURE_NAME].entities[update.id] = {
...newState[TAG_FEATURE_NAME].entities[update.id]!,
...update.changes,
};
});
}
newState = applyTagUpdates(newState, tagUpdates);
// =========================================================================
// 3. PARENT-CHILD CONSISTENCY: Validate parent.subTaskIds ↔ child.parentId
@ -319,7 +334,7 @@ export const validateAndFixDataConsistencyAfterBatchUpdate = (
}
// Clean up non-existent task references from tags
const tagCleanupUpdates: { id: string; changes: Partial<Tag> }[] = [];
const tagCleanupUpdates: TagUpdate[] = [];
const remainingTaskIds = new Set(Object.keys(newState[TASK_FEATURE_NAME].entities));
Object.values(newState[TAG_FEATURE_NAME].entities).forEach((tag) => {
@ -334,15 +349,7 @@ export const validateAndFixDataConsistencyAfterBatchUpdate = (
}
});
// Apply tag cleanup updates
if (tagCleanupUpdates.length > 0) {
tagCleanupUpdates.forEach((update) => {
newState[TAG_FEATURE_NAME].entities[update.id] = {
...newState[TAG_FEATURE_NAME].entities[update.id]!,
...update.changes,
};
});
}
newState = applyTagUpdates(newState, tagCleanupUpdates);
return newState;
};