fix(tasks): keep REST project moves atomic across replay (#9001)

* fix(tasks): make REST project moves atomic

Capture affected subtasks in the persisted update action so project moves replay consistently, and repair stale project and section references during archive and restore.

Fixes #8983

* fix(tasks): harden project move replay

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Johannes Millan 2026-07-14 17:25:37 +02:00 committed by GitHub
parent 3ae2360345
commit 91a1f0eda9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2717 additions and 174 deletions

View file

@ -317,6 +317,8 @@ curl -X POST http://127.0.0.1:3876/tasks/task-id/archive
`parentId` and `subTaskIds` cannot be set via `PATCH` (returns 400 `UNSUPPORTED_FIELD`). Re-parenting an existing task is not supported by this API; delete and recreate the task instead.
Changing `projectId` on a top-level task moves the task and all of its subtasks atomically. The destination must be an existing active project; missing or archived destinations return 404 `PROJECT_NOT_FOUND`. A subtask's `projectId` is inherited from its parent and cannot be changed directly (400 `UNSUPPORTED_FIELD`); echoing its unchanged inherited value is accepted for GET-to-PATCH round trips. A project move can be combined with other writable fields in the same PATCH request.
---
### Task Control Endpoints
@ -397,12 +399,14 @@ curl http://127.0.0.1:3876/tags
### Local REST API Error Codes
| Code | HTTP Status | Description |
| ---------------- | ----------- | ------------------------------------------------------------------------------- |
| `TASK_NOT_FOUND` | 404 | Task does not exist |
| `INVALID_INPUT` | 400 | Invalid request body, or a field has the wrong value type (see `error.details`) |
| `NOT_FOUND` | 404 | Route not found |
| `INTERNAL_ERROR` | 500 | Internal server error |
| Code | HTTP Status | Description |
| ------------------- | ----------- | ------------------------------------------------------------------------------- |
| `TASK_NOT_FOUND` | 404 | Task does not exist |
| `PROJECT_NOT_FOUND` | 404 | Target project does not exist or is archived |
| `INVALID_INPUT` | 400 | Invalid request body, or a field has the wrong value type (see `error.details`) |
| `UNSUPPORTED_FIELD` | 400 | Field cannot be changed through this endpoint |
| `NOT_FOUND` | 404 | Route not found |
| `INTERNAL_ERROR` | 500 | Internal server error |
---

View file

@ -4,6 +4,7 @@ import { LocalRestApiHandlerService } from './local-rest-api-handler.service';
import { TaskService } from '../../features/tasks/task.service';
import { TaskArchiveService } from '../../features/archive/task-archive.service';
import { ProjectService } from '../../features/project/project.service';
import { Project } from '../../features/project/project.model';
import { TagService } from '../../features/tag/tag.service';
import { TODAY_TAG } from '../../features/tag/tag.const';
import { DateService } from '../date/date.service';
@ -20,6 +21,7 @@ describe('LocalRestApiHandlerService', () => {
let projectServiceMock: jasmine.SpyObj<ProjectService>;
let tagServiceMock: jasmine.SpyObj<TagService>;
let dateServiceMock: jasmine.SpyObj<DateService>;
let activeProjects: Project[];
let requestHandler: ((payload: LocalRestApiRequestPayload) => void) | null = null;
let responsePromiseResolve: ((response: LocalRestApiResponsePayload) => void) | null =
null;
@ -100,6 +102,7 @@ describe('LocalRestApiHandlerService', () => {
beforeEach(() => {
requestHandler = null;
responsePromiseResolve = null;
activeProjects = [];
mockElectronApi();
@ -142,6 +145,9 @@ describe('LocalRestApiHandlerService', () => {
list$: of([]),
},
);
Object.defineProperty(projectServiceMock, 'list', {
value: (() => activeProjects) as ProjectService['list'],
});
tagServiceMock = jasmine.createSpyObj(
'TagService',
@ -831,6 +837,227 @@ describe('LocalRestApiHandlerService', () => {
isDone: true,
});
});
it('should update projectId together with other fields in one dispatch', async () => {
let mockTask = createMockTask('task-1', { projectId: 'project-1' });
Object.defineProperty(taskServiceMock, 'getByIdOnce$', {
get: () => (_id: string) => of(mockTask),
});
taskServiceMock.update.and.callFake((id, changes) => {
if (id === mockTask.id) {
mockTask = { ...mockTask, ...changes };
}
});
activeProjects = [{ id: 'project-2' } as Project];
const response = await sendRequestAndWait(
createRequest('PATCH', '/tasks/task-1', {
body: { projectId: 'project-2', title: 'Moved task' },
}),
);
expect(response.body.ok).toBe(true);
expect(taskServiceMock.update).toHaveBeenCalledOnceWith('task-1', {
projectId: 'project-2',
title: 'Moved task',
});
if (!response.body.ok) {
throw new Error('Expected a success response');
}
expect(response.body.data).toEqual(
jasmine.objectContaining({
projectId: 'project-2',
title: 'Moved task',
}),
);
});
it('should reject projectId changes for subtasks', async () => {
const mockTask = createMockTask('subtask-1', {
parentId: 'parent-1',
projectId: 'project-1',
});
Object.defineProperty(taskServiceMock, 'getByIdOnce$', {
get: () => (_id: string) => of(mockTask),
});
const response = await sendRequestAndWait(
createRequest('PATCH', '/tasks/subtask-1', {
body: { projectId: 'project-2' },
}),
);
expect(response.status).toBe(400);
expect(response.body.ok).toBe(false);
if (response.body.ok) {
throw new Error('Expected an error response');
}
expect(response.body.error.code).toBe('UNSUPPORTED_FIELD');
expect(taskServiceMock.update).not.toHaveBeenCalled();
});
it('should allow an unchanged inherited projectId on subtasks', async () => {
let mockTask = createMockTask('subtask-1', {
parentId: 'parent-1',
projectId: 'project-1',
});
Object.defineProperty(taskServiceMock, 'getByIdOnce$', {
get: () => (_id: string) => of(mockTask),
});
taskServiceMock.update.and.callFake((id, changes) => {
if (id === mockTask.id) {
mockTask = { ...mockTask, ...changes };
}
});
activeProjects = [{ id: 'project-1' } as Project];
const response = await sendRequestAndWait(
createRequest('PATCH', '/tasks/subtask-1', {
body: { projectId: 'project-1', title: 'Round-tripped subtask' },
}),
);
expect(response.body.ok).toBe(true);
expect(taskServiceMock.update).toHaveBeenCalledOnceWith('subtask-1', {
projectId: 'project-1',
title: 'Round-tripped subtask',
});
});
it('should allow an unchanged archived projectId in a task round trip', async () => {
const mockTask = createMockTask('task-1', {
projectId: 'archived-project',
});
Object.defineProperty(taskServiceMock, 'getByIdOnce$', {
get: () => (_id: string) => of(mockTask),
});
const response = await sendRequestAndWait(
createRequest('PATCH', '/tasks/task-1', {
body: {
projectId: 'archived-project',
title: 'Round-tripped task',
},
}),
);
expect(response.body.ok).toBe(true);
expect(taskServiceMock.update).toHaveBeenCalledOnceWith('task-1', {
projectId: 'archived-project',
title: 'Round-tripped task',
});
});
it('should reject an unknown destination project', async () => {
const mockTask = createMockTask('task-1', { projectId: 'project-1' });
Object.defineProperty(taskServiceMock, 'getByIdOnce$', {
get: () => (_id: string) => of(mockTask),
});
const response = await sendRequestAndWait(
createRequest('PATCH', '/tasks/task-1', {
body: { projectId: 'missing-project' },
}),
);
expect(response.status).toBe(404);
expect(response.body.ok).toBe(false);
if (response.body.ok) {
throw new Error('Expected an error response');
}
expect(response.body.error.code).toBe('PROJECT_NOT_FOUND');
expect(taskServiceMock.update).not.toHaveBeenCalled();
});
it('should reject an archived destination project', async () => {
const mockTask = createMockTask('task-1', { projectId: 'project-1' });
Object.defineProperty(taskServiceMock, 'getByIdOnce$', {
get: () => (_id: string) => of(mockTask),
});
activeProjects = [
{
id: 'archived-project',
isArchived: true,
} as Project,
];
const response = await sendRequestAndWait(
createRequest('PATCH', '/tasks/task-1', {
body: { projectId: 'archived-project' },
}),
);
expect(response.status).toBe(404);
expect(response.body.ok).toBe(false);
if (response.body.ok) {
throw new Error('Expected an error response');
}
expect(response.body.error.code).toBe('PROJECT_NOT_FOUND');
expect(taskServiceMock.update).not.toHaveBeenCalled();
});
it('should reject prototype-property names as missing project ids', async () => {
const mockTask = createMockTask('task-1', { projectId: 'project-1' });
Object.defineProperty(taskServiceMock, 'getByIdOnce$', {
get: () => (_id: string) => of(mockTask),
});
const response = await sendRequestAndWait(
createRequest('PATCH', '/tasks/task-1', {
body: { projectId: 'constructor' },
}),
);
expect(response.status).toBe(404);
expect(response.body.ok).toBe(false);
if (response.body.ok) {
throw new Error('Expected an error response');
}
expect(response.body.error.code).toBe('PROJECT_NOT_FOUND');
expect(taskServiceMock.update).not.toHaveBeenCalled();
});
it('should reject prototype-property names as missing task ids', async () => {
Object.defineProperty(taskServiceMock, 'getByIdOnce$', {
get: () => (_id: string) => of(Object.prototype as Task),
});
const response = await sendRequestAndWait(
createRequest('PATCH', '/tasks/constructor', {
body: { title: 'Ignored' },
}),
);
expect(response.status).toBe(404);
expect(response.body.ok).toBe(false);
if (response.body.ok) {
throw new Error('Expected an error response');
}
expect(response.body.error.code).toBe('TASK_NOT_FOUND');
expect(taskServiceMock.update).not.toHaveBeenCalled();
});
it('should reject empty or whitespace-only projectIds', async () => {
const mockTask = createMockTask('task-1', { projectId: 'project-1' });
Object.defineProperty(taskServiceMock, 'getByIdOnce$', {
get: () => (_id: string) => of(mockTask),
});
for (const projectId of ['', ' ']) {
const response = await sendRequestAndWait(
createRequest('PATCH', '/tasks/task-1', {
body: { projectId },
}),
);
expect(response.status).toBe(400);
expect(response.body.ok).toBe(false);
if (response.body.ok) {
throw new Error('Expected an error response');
}
expect(response.body.error.code).toBe('INVALID_INPUT');
}
expect(taskServiceMock.update).not.toHaveBeenCalled();
});
});
describe('DELETE /tasks/:id', () => {

View file

@ -512,6 +512,46 @@ export class LocalRestApiHandlerService {
return createErrorResponse(requestId, 404, 'TASK_NOT_FOUND', 'Task not found');
}
if (Object.prototype.hasOwnProperty.call(changes, 'projectId')) {
const targetProjectId = changes.projectId;
if (typeof targetProjectId !== 'string' || !targetProjectId.trim()) {
return createErrorResponse(
requestId,
400,
'INVALID_INPUT',
'projectId must be a non-empty string',
);
}
const isProjectChange = targetProjectId !== task.projectId;
// Echoing back the unchanged projectId is allowed on subtasks so
// GET→PATCH round-trips don't fail; only actual changes are rejected.
if (task.parentId && isProjectChange) {
return createErrorResponse(
requestId,
400,
'UNSUPPORTED_FIELD',
'projectId cannot be changed directly on a subtask — move its parent task instead',
);
}
if (isProjectChange) {
// list() only contains unarchived projects, and matching by iteration
// (not entity-map lookup) keeps prototype-property names like
// 'constructor' from resolving to a truthy non-project.
const targetProject = this._projectService
.list()
.find((project) => project.id === targetProjectId && !project.isArchived);
if (!targetProject) {
return createErrorResponse(
requestId,
404,
'PROJECT_NOT_FOUND',
'Destination project not found or archived',
);
}
}
}
this._taskService.update(taskId, changes);
return createSuccessResponse(requestId, 200, await this._getTaskById(taskId));
}
@ -624,16 +664,17 @@ export class LocalRestApiHandlerService {
return createSuccessResponse(requestId, 200, tags);
}
// The id equality checks reject prototype-property names ('constructor',
// 'toString', …) that entity-map lookups resolve to truthy non-tasks.
private async _getTaskById(taskId: string): Promise<Task | undefined> {
return (await firstValueFrom(this._taskService.getByIdOnce$(taskId))) || undefined;
const task = await firstValueFrom(this._taskService.getByIdOnce$(taskId));
return task?.id === taskId ? task : undefined;
}
private async _getTaskWithSubTasksById(
taskId: string,
): Promise<TaskWithSubTasks | undefined> {
return (
(await firstValueFrom(this._taskService.getByIdWithSubTaskData$(taskId))) ||
undefined
);
const task = await firstValueFrom(this._taskService.getByIdWithSubTaskData$(taskId));
return task?.id === taskId ? task : undefined;
}
}

View file

@ -399,6 +399,47 @@ describe('TaskService', () => {
expect(flushOneSpy).not.toHaveBeenCalled();
});
it('should capture linked and reverse-linked subtasks for project moves', () => {
const parent = createMockTask('parent', { subTaskIds: ['listed-subtask'] });
const listedSubTask = createMockTask('listed-subtask', {
parentId: 'parent',
});
const reverseLinkedSubTask = createMockTask('reverse-linked-subtask', {
parentId: 'parent',
});
store.setState({
tasks: {
ids: ['parent', 'listed-subtask', 'reverse-linked-subtask'],
entities: {
parent,
['listed-subtask']: listedSubTask,
['reverse-linked-subtask']: reverseLinkedSubTask,
},
currentTaskId: null,
selectedTaskId: null,
taskDetailTargetPanel: null,
isDataLoaded: true,
},
});
store.refreshState();
service.update('parent', { projectId: 'project-2', title: 'Moved' });
const expectedAction = TaskSharedActions.updateTask({
task: {
id: 'parent',
changes: { projectId: 'project-2', title: 'Moved' },
},
projectMoveSubTaskIds: ['listed-subtask', 'reverse-linked-subtask'],
});
expect(store.dispatch).toHaveBeenCalledWith(expectedAction);
expect(expectedAction.meta.entityIds).toEqual([
'parent',
'listed-subtask',
'reverse-linked-subtask',
]);
});
});
describe('updateTags', () => {

View file

@ -511,9 +511,28 @@ export class TaskService {
if (Object.prototype.hasOwnProperty.call(changedFields, 'timeSpentOnDay')) {
this._taskTimeSync.flushOne(id);
}
const entities = this._taskEntities();
const task = entities[id];
const projectMoveSubTaskIds =
Object.prototype.hasOwnProperty.call(changedFields, 'projectId') &&
task &&
!task.parentId
? unique([
...task.subTaskIds,
...Object.values(entities)
.filter(
(candidate): candidate is Task =>
!!candidate && candidate.parentId === id,
)
.map((subTask) => subTask.id),
])
: undefined;
this._store.dispatch(
TaskSharedActions.updateTask({
task: { id, changes: changedFields },
...(projectMoveSubTaskIds !== undefined && { projectMoveSubTaskIds }),
}),
);
}

View file

@ -4182,6 +4182,33 @@ describe('ConflictResolutionService', () => {
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-upd']);
});
it('should preserve the local winner operation footprint in its LWW op', async () => {
const now = Date.now();
const localOp = createOpWithTimestamp('local-upd', 'client-a', now);
localOp.actionType = ActionType.TASK_SHARED_UPDATE;
localOp.entityIds = ['task-1', 'subtask-1'];
localOp.payload = {
actionPayload: {
task: { id: 'task-1', changes: { projectId: 'project-2' } },
projectMoveSubTaskIds: ['subtask-1'],
},
entityChanges: [],
};
mockStore.select.and.returnValue(
of({ id: 'task-1', title: 'Local winner', projectId: 'project-2' }),
);
const lwwOp = await (service as any)._createLocalWinUpdateOp(
createConflict(
'task-1',
[localOp],
[createOpWithTimestamp('remote-upd', 'client-b', now - 1000)],
),
);
expect(lwwOp.entityIds).toEqual(['task-1', 'subtask-1']);
});
it('should not create local-win op when clientId is unavailable', async () => {
const now = Date.now();
mockClientIdProvider.loadClientId.and.returnValue(Promise.resolve(null));
@ -6037,6 +6064,21 @@ describe('ConflictResolutionService', () => {
expect(extractActionPayload(op.payload)['id']).toBe('task-canonical');
});
it('should preserve and normalize an explicit operation footprint', () => {
const op = service.createLWWUpdateOp(
'TASK',
'task-canonical',
{ title: 'Local winner' },
TEST_CLIENT_ID,
{ [TEST_CLIENT_ID]: 1 },
Date.now(),
'replace',
['subtask-1', 'task-canonical', 'subtask-1'],
);
expect(op.entityIds).toEqual(['task-canonical', 'subtask-1']);
});
it('should ensure _convertToLWWUpdatesIfNeeded merged payload has id even when base entity lacks id', () => {
// Edge case: a malformed local DELETE payload whose embedded entity
// somehow lacks an id (e.g. corruption). The merged LWW Update payload

View file

@ -114,6 +114,70 @@ interface AutoResolveConflictsLwwOptions {
remoteApplyLifecycleOwnedByCaller?: boolean;
}
const getTaskProjectMoveEntityIds = (operation: Operation): string[] | undefined => {
if (
operation.actionType === toLwwUpdateActionType('TASK') &&
operation.entityId &&
Array.isArray(operation.entityIds)
) {
return Array.from(new Set([operation.entityId, ...operation.entityIds]));
}
if (
operation.actionType !== ActionType.TASK_SHARED_UPDATE ||
!operation.entityId ||
!operation.payload ||
typeof operation.payload !== 'object'
) {
return undefined;
}
const payload = operation.payload as Record<string, unknown>;
const actionPayload =
payload['actionPayload'] && typeof payload['actionPayload'] === 'object'
? (payload['actionPayload'] as Record<string, unknown>)
: payload;
const subTaskIds = actionPayload['projectMoveSubTaskIds'];
if (!Array.isArray(subTaskIds)) return undefined;
return Array.from(
new Set([
operation.entityId,
...subTaskIds.filter((id): id is string => typeof id === 'string'),
]),
);
};
export const getLatestTaskProjectMoveEntityIds = (
operations: Operation[],
): string[] | undefined => {
let latest: { operation: Operation; entityIds: string[] } | undefined;
for (const operation of operations) {
const entityIds = getTaskProjectMoveEntityIds(operation);
if (!entityIds) continue;
if (
!latest ||
operation.timestamp > latest.operation.timestamp ||
(operation.timestamp === latest.operation.timestamp &&
operation.id > latest.operation.id)
) {
latest = { operation, entityIds };
}
}
return latest?.entityIds;
};
const latestProjectMoveEntityIds = (
entityId: string,
operations: Operation[],
): string[] | undefined => {
const projectMoveEntityIds = getLatestTaskProjectMoveEntityIds(operations);
if (!projectMoveEntityIds) return undefined;
return Array.from(new Set([entityId, ...projectMoveEntityIds]));
};
const markLwwDeleteRecreation = (op: Operation): Operation =>
isLwwUpdatePayload(op.payload)
? {
@ -262,6 +326,7 @@ export class ConflictResolutionService {
* @param clientId - Client creating this operation
* @param vectorClock - Merged vector clock (should dominate all conflicting ops)
* @param timestamp - Preserved timestamp for correct LWW semantics
* @param entityIds - Captured task-project-move footprint, when applicable
* @returns New UPDATE operation ready for upload
*/
createLWWUpdateOp(
@ -272,6 +337,7 @@ export class ConflictResolutionService {
vectorClock: VectorClock,
timestamp: number,
lwwUpdateMode: LwwUpdateMode = 'replace',
entityIds?: string[],
): Operation {
// NOTE: LWW Update action types (e.g., '[TASK] LWW Update') are intentionally
// NOT in the ActionType enum. They are dynamically constructed here and matched
@ -303,6 +369,9 @@ export class ConflictResolutionService {
opType: OpType.Update,
entityType,
entityId,
...(entityIds !== undefined && {
entityIds: Array.from(new Set([entityId, ...entityIds])),
}),
payload,
clientId,
vectorClock,
@ -1914,6 +1983,10 @@ export class ConflictResolutionService {
newClock,
mergedTimestamp,
'patch',
latestProjectMoveEntityIds(conflict.entityId, [
...conflict.localOps,
...conflict.remoteOps,
]),
);
}
@ -2026,6 +2099,8 @@ export class ConflictResolutionService {
clientId,
newClock,
preservedTimestamp,
'replace',
latestProjectMoveEntityIds(conflict.entityId, conflict.localOps),
);
return conflict.remoteOps.some((op) => op.opType === OpType.Delete)
? markLwwDeleteRecreation(localWinOp)

View file

@ -91,12 +91,15 @@ describe('SupersededOperationResolverService', () => {
clientId: string,
vectorClock: VectorClock,
timestamp: number,
_lwwUpdateMode?: 'replace' | 'patch',
entityIds?: string[],
) => ({
id: 'generated-id-' + Math.random().toString(36).substring(7),
actionType: `[${entityType}] LWW Update` as ActionType,
opType: OpType.Update,
entityType,
entityId,
entityIds,
payload: entityState,
clientId,
vectorClock,
@ -220,6 +223,15 @@ describe('SupersededOperationResolverService', () => {
{ clientA: 5 },
1000,
);
supersededOp.actionType = ActionType.TASK_SHARED_UPDATE;
supersededOp.entityIds = ['task-1', 'subtask-1'];
supersededOp.payload = {
actionPayload: {
task: { id: 'task-1', changes: { projectId: 'project-2' } },
projectMoveSubTaskIds: ['subtask-1'],
},
entityChanges: [],
};
const entityState = { id: 'task-1', title: 'Test Task' };
mockVectorClockService.getCurrentVectorClock.and.returnValue(
@ -243,11 +255,55 @@ describe('SupersededOperationResolverService', () => {
expect(appendedOp.opType).toBe(OpType.Update);
expect(appendedOp.entityType).toBe('TASK');
expect(appendedOp.entityId).toBe('task-1');
expect(appendedOp.entityIds).toEqual(['task-1', 'subtask-1']);
expect(appendedOp.payload).toEqual(entityState);
expect(appendedOp.clientId).toBe(TEST_CLIENT_ID);
expect(appendedOp.timestamp).toBe(1000); // Preserved from original
});
it('should not reuse a generic multi-task footprint for an LWW update', async () => {
const supersededOp = createMockOperation(
'op-1',
'TASK',
'task-1',
{ clientA: 5 },
1000,
);
supersededOp.entityIds = ['task-1', 'unrelated-task'];
mockConflictResolutionService.getCurrentEntityState.and.resolveTo({
id: 'task-1',
title: 'Test Task',
});
await service.resolveSupersededLocalOps([{ opId: 'op-1', op: supersededOp }]);
const appendedOp = mockOpLogStore.appendWithVectorClockUpdate.calls.first()
.args[0] as Operation;
expect(appendedOp.entityIds).toBeUndefined();
});
it('should preserve a project-move footprint through another LWW replacement', async () => {
const supersededOp = createMockOperation(
'op-1',
'TASK',
'task-1',
{ clientA: 5 },
1000,
);
supersededOp.actionType = '[TASK] LWW Update' as ActionType;
supersededOp.entityIds = ['task-1', 'subtask-1'];
mockConflictResolutionService.getCurrentEntityState.and.resolveTo({
id: 'task-1',
title: 'Test Task',
});
await service.resolveSupersededLocalOps([{ opId: 'op-1', op: supersededOp }]);
const appendedOp = mockOpLogStore.appendWithVectorClockUpdate.calls.first()
.args[0] as Operation;
expect(appendedOp.entityIds).toEqual(['task-1', 'subtask-1']);
});
it('should create single merged op for multiple superseded ops on same entity', async () => {
const supersededOp1 = createMockOperation(
'op-1',
@ -270,6 +326,20 @@ describe('SupersededOperationResolverService', () => {
{ clientA: 5 },
1500,
);
supersededOp1.actionType = ActionType.TASK_SHARED_UPDATE;
supersededOp1.payload = {
actionPayload: {
projectMoveSubTaskIds: ['former-subtask'],
},
entityChanges: [],
};
supersededOp2.actionType = ActionType.TASK_SHARED_UPDATE;
supersededOp2.payload = {
actionPayload: {
projectMoveSubTaskIds: ['current-subtask'],
},
entityChanges: [],
};
const entityState = { id: 'task-1', title: 'Latest State' };
mockVectorClockService.getCurrentVectorClock.and.returnValue(
@ -293,6 +363,7 @@ describe('SupersededOperationResolverService', () => {
.args[0] as Operation;
// Timestamp should be max of all superseded ops (2000)
expect(appendedOp.timestamp).toBe(2000);
expect(appendedOp.entityIds).toEqual(['task-1', 'current-subtask']);
});
it('should create separate ops for different entities', async () => {

View file

@ -3,7 +3,10 @@ import { OperationLogStoreService } from '../persistence/operation-log-store.ser
import { ActionType, Operation, OpType, VectorClock } from '../core/operation.types';
import { mergeVectorClocks } from '../../core/util/vector-clock';
import { OpLog } from '../../core/log';
import { ConflictResolutionService } from './conflict-resolution.service';
import {
ConflictResolutionService,
getLatestTaskProjectMoveEntityIds,
} from './conflict-resolution.service';
import { VectorClockService } from './vector-clock.service';
import { LockService } from './lock.service';
import { toEntityKey } from '../util/entity-key.util';
@ -245,6 +248,12 @@ export class SupersededOperationResolverService {
// would have a later timestamp than the original user action, causing it to
// incorrectly win against concurrent ops that were actually made earlier.
const preservedTimestamp = Math.max(...entityOps.map((e) => e.op.timestamp));
const projectMoveEntityIds = getLatestTaskProjectMoveEntityIds(
entityOps.map(({ op }) => op),
);
const declaredEntityIds = projectMoveEntityIds
? Array.from(new Set([entityId, ...projectMoveEntityIds]))
: undefined;
// Create new UPDATE op with current state and merged clock
const newOp = this.conflictResolutionService.createLWWUpdateOp(
@ -254,6 +263,8 @@ export class SupersededOperationResolverService {
clientId,
mergedClock,
preservedTimestamp,
'replace',
declaredEntityIds,
);
newOpsCreated.push(newOp);

View file

@ -1602,6 +1602,326 @@ describe('lwwUpdateMetaReducer', () => {
expect(projectB.taskIds).toContain(TASK_ID);
});
for (const invalidTarget of [
{ label: 'missing', id: 'missing-project', isArchived: false },
{ label: 'prototype-like', id: '__proto__', isArchived: false },
]) {
it(`should retain the current project for a ${invalidTarget.label} target`, () => {
const state = createStateWithProjects(PROJECT_A, [TASK_ID], []);
if (invalidTarget.isArchived) {
state[PROJECT_FEATURE_NAME]!.entities[PROJECT_B] = createMockProject({
id: PROJECT_B,
title: 'Archived Project',
taskIds: [],
isArchived: true,
});
}
const action = {
type: '[TASK] LWW Update',
id: TASK_ID,
projectId: invalidTarget.id,
title: 'Keep this title',
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
};
expect(() => reducer(state, action)).not.toThrow();
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
expect(updatedState[TASK_FEATURE_NAME]?.entities[TASK_ID]).toEqual(
jasmine.objectContaining({
projectId: PROJECT_A,
title: 'Keep this title',
}),
);
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A]?.taskIds).toEqual([
TASK_ID,
]);
});
}
it('should replay a move to an archived-but-existing project', () => {
const state = createStateWithProjects(PROJECT_A, [TASK_ID], []);
state[PROJECT_FEATURE_NAME]!.entities[PROJECT_B] = createMockProject({
id: PROJECT_B,
title: 'Archived Project',
taskIds: [],
isArchived: true,
});
const action = {
type: '[TASK] LWW Update',
id: TASK_ID,
projectId: PROJECT_B,
title: 'Archived destination',
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
};
reducer(state, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
expect(updatedState[TASK_FEATURE_NAME]?.entities[TASK_ID]?.projectId).toBe(
PROJECT_B,
);
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A]?.taskIds).toEqual(
[],
);
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_B]?.taskIds).toEqual([
TASK_ID,
]);
});
it('should move state-only subtasks with their parent when projectId changes', () => {
const state = createStateWithProjects(PROJECT_A, [TASK_ID], []);
state[TASK_FEATURE_NAME] = {
...state[TASK_FEATURE_NAME]!,
ids: [TASK_ID, 'subtask1'],
entities: {
...state[TASK_FEATURE_NAME]!.entities,
[TASK_ID]: createMockTask({
projectId: PROJECT_A,
subTaskIds: [],
}),
subtask1: createMockTask({
id: 'subtask1',
parentId: TASK_ID,
projectId: PROJECT_A,
}),
},
};
const action = {
type: '[TASK] LWW Update',
id: TASK_ID,
projectId: PROJECT_B,
title: 'Updated Task',
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
};
reducer(state, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
expect(updatedState[TASK_FEATURE_NAME]?.entities.subtask1?.projectId).toBe(
PROJECT_B,
);
});
it('should replay only the LWW operation footprint on divergent state', () => {
const state = createStateWithProjects(
PROJECT_A,
[TASK_ID, 'captured-child', 'receiver-child'],
[],
);
state[TASK_FEATURE_NAME] = {
...state[TASK_FEATURE_NAME]!,
ids: [TASK_ID, 'captured-child', 'receiver-child'],
entities: {
[TASK_ID]: createMockTask({
projectId: PROJECT_A,
subTaskIds: [],
}),
['captured-child']: createMockTask({
id: 'captured-child',
parentId: TASK_ID,
projectId: PROJECT_A,
}),
['receiver-child']: createMockTask({
id: 'receiver-child',
parentId: TASK_ID,
projectId: PROJECT_A,
}),
},
};
const action = {
type: '[TASK] LWW Update',
id: TASK_ID,
projectId: PROJECT_B,
title: 'Moved task',
meta: {
isPersistent: true,
entityType: 'TASK',
entityId: TASK_ID,
entityIds: [TASK_ID, 'captured-child'],
},
};
reducer(state, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
expect(updatedState[TASK_FEATURE_NAME]?.entities['captured-child']?.projectId).toBe(
PROJECT_B,
);
expect(updatedState[TASK_FEATURE_NAME]?.entities['receiver-child']?.projectId).toBe(
PROJECT_A,
);
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A]?.taskIds).toEqual([
'receiver-child',
]);
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_B]?.taskIds).toEqual([
TASK_ID,
]);
});
it('should repair stale project references without changing target backlog placement', () => {
const state = createStateWithProjects(PROJECT_A, [], [TASK_ID]);
state[PROJECT_FEATURE_NAME]!.entities[PROJECT_A] = createMockProject({
id: PROJECT_A,
taskIds: [],
backlogTaskIds: [TASK_ID],
});
const action = {
type: '[TASK] LWW Update',
id: TASK_ID,
projectId: PROJECT_A,
title: 'Same project',
meta: {
isPersistent: true,
entityType: 'TASK',
entityId: TASK_ID,
entityIds: [TASK_ID],
},
};
reducer(state, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A]?.taskIds).toEqual(
[],
);
expect(
updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A]?.backlogTaskIds,
).toEqual([TASK_ID]);
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_B]?.taskIds).toEqual(
[],
);
});
for (const unsafeTaskId of ['constructor', '__proto__']) {
it(`should reject prototype-like task id ${unsafeTaskId}`, () => {
const state = createStateWithProjects(PROJECT_A, [TASK_ID], []);
const action = {
type: '[TASK] LWW Update',
id: unsafeTaskId,
projectId: PROJECT_B,
title: 'Unsafe task',
meta: { isPersistent: true, entityType: 'TASK', entityId: unsafeTaskId },
};
spyOn(OpLog, 'warn');
expect(() => reducer(state, action)).not.toThrow();
expect(mockReducer).toHaveBeenCalledWith(state, action);
});
}
it('should reject a prototype-like parent task id', () => {
const state = createStateWithProjects(PROJECT_A, [TASK_ID], []);
const action = {
type: '[TASK] LWW Update',
id: TASK_ID,
parentId: 'constructor',
projectId: PROJECT_A,
title: 'Unsafe parent',
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
};
expect(() => reducer(state, action)).not.toThrow();
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
expect(updatedState[TASK_FEATURE_NAME]?.entities[TASK_ID]?.parentId).toBeFalsy();
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A]?.taskIds).toEqual([
TASK_ID,
]);
});
it('should preserve an empty project id as a valid no-project assignment', () => {
const state = createStateWithProjects(PROJECT_A, [TASK_ID], []);
const action = {
type: '[TASK] LWW Update',
id: TASK_ID,
projectId: '',
title: 'No project',
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
};
reducer(state, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
expect(updatedState[TASK_FEATURE_NAME]?.entities[TASK_ID]?.projectId).toBe('');
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A]?.taskIds).toEqual(
[],
);
});
it('should keep a directly updated subtask in its parent project', () => {
const state = createStateWithProjects(PROJECT_A, [TASK_ID], []);
state[TASK_FEATURE_NAME] = {
...state[TASK_FEATURE_NAME]!,
ids: [TASK_ID, 'subtask1'],
entities: {
...state[TASK_FEATURE_NAME]!.entities,
[TASK_ID]: createMockTask({
projectId: PROJECT_A,
subTaskIds: ['subtask1'],
}),
subtask1: createMockTask({
id: 'subtask1',
parentId: TASK_ID,
projectId: PROJECT_A,
}),
},
};
const action = {
type: '[TASK] LWW Update',
id: 'subtask1',
parentId: TASK_ID,
projectId: PROJECT_B,
title: 'Updated Subtask',
meta: { isPersistent: true, entityType: 'TASK', entityId: 'subtask1' },
};
reducer(state, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
expect(updatedState[TASK_FEATURE_NAME]?.entities.subtask1?.projectId).toBe(
PROJECT_A,
);
});
it('should move reverse-linked subtasks when recreating their parent', () => {
const state = createStateWithProjects(PROJECT_A, ['subtask1'], []);
state[TASK_FEATURE_NAME] = {
...state[TASK_FEATURE_NAME]!,
ids: ['subtask1'],
entities: {
subtask1: createMockTask({
id: 'subtask1',
parentId: TASK_ID,
projectId: PROJECT_A,
}),
},
};
const action = {
type: '[TASK] LWW Update',
id: TASK_ID,
projectId: PROJECT_B,
title: 'Recreated Parent',
tagIds: [],
subTaskIds: [],
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
};
spyOn(OpLog, 'log');
reducer(state, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
expect(updatedState[TASK_FEATURE_NAME]?.entities.subtask1?.projectId).toBe(
PROJECT_B,
);
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A]?.taskIds).toEqual(
[],
);
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_B]?.taskIds).toEqual([
TASK_ID,
]);
});
it('should remove task from old project backlogTaskIds when projectId changes', () => {
// Task is in Project A's backlog, LWW update moves it to Project B
const state = {
@ -1950,25 +2270,24 @@ describe('lwwUpdateMetaReducer', () => {
expect(tagB.taskIds.filter((id) => id === TASK_ID).length).toBe(1);
});
it('should handle tag that does not exist gracefully', () => {
// Task references a non-existent tag
const state = createStateWithTags([TAG_A], [TASK_ID], []);
const action = {
type: '[TASK] LWW Update',
id: TASK_ID,
tagIds: [TAG_A, 'non-existent-tag'],
title: 'Updated Task',
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
};
for (const missingTagId of ['non-existent-tag', 'constructor', '__proto__']) {
it(`should handle missing or unsafe tag id ${missingTagId} gracefully`, () => {
const state = createStateWithTags([TAG_A], [TASK_ID], []);
const action = {
type: '[TASK] LWW Update',
id: TASK_ID,
tagIds: [TAG_A, missingTagId],
title: 'Updated Task',
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
};
// Should not throw
reducer(state, action);
expect(() => reducer(state, action)).not.toThrow();
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
const tagA = updatedState[TAG_FEATURE_NAME]?.entities[TAG_A] as Tag;
expect(tagA.taskIds).toContain(TASK_ID);
});
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
const tagA = updatedState[TAG_FEATURE_NAME]?.entities[TAG_A] as Tag;
expect(tagA.taskIds).toContain(TASK_ID);
});
}
it('should not update tags when tagIds does not change', () => {
const state = createStateWithTags([TAG_A, TAG_B], [TASK_ID], [TASK_ID]);

View file

@ -28,6 +28,7 @@ import { filterTaskIdArraysFromTagOrProjectPayload } from '../../../op-log/apply
import { appStateFeatureKey } from '../../app-state/app-state.reducer';
import { getDbDateStr, isDBDateStr } from '../../../util/get-db-date-str';
import { isTodayWithOffset } from '../../../util/is-today.util';
import { getProjectOrUndefined, repairTaskProjectForLww } from './task-shared-helpers';
import { withLocalOnlySyncSettings } from '../../../features/config/local-only-sync-settings.util';
import { SyncConfig } from '../../../features/config/global-config.model';
import { LwwUpdateMode } from '../../../op-log/core/operation.types';
@ -58,9 +59,16 @@ const syncProjectTaskIds = (
const shouldAddToNewProject =
!!newProjectId && !newIsSubTask && (oldProjectId !== newProjectId || oldIsSubTask);
// Remove from old project's taskIds
if (shouldRemoveFromOldProject && oldProjectId && projectState.entities[oldProjectId]) {
const oldProject = projectState.entities[oldProjectId] as Project;
// Remove from old project's taskIds. The id equality check rejects
// inherited Object.prototype members that a bare entities[id] lookup
// returns truthy for when the id originates from a remote op.
const oldProjectCandidate = oldProjectId
? (projectState.entities[oldProjectId] as Project | undefined)
: undefined;
const oldProject =
oldProjectCandidate?.id === oldProjectId ? oldProjectCandidate : undefined;
if (shouldRemoveFromOldProject && oldProjectId && oldProject) {
const filteredTaskIds = oldProject.taskIds.filter((id) => id !== taskId);
const filteredBacklogTaskIds = oldProject.backlogTaskIds.filter(
(id) => id !== taskId,
@ -89,9 +97,17 @@ const syncProjectTaskIds = (
);
}
// Add to new project's taskIds
if (shouldAddToNewProject && newProjectId && projectState.entities[newProjectId]) {
const newProject = projectState.entities[newProjectId] as Project;
// Add to the new project's taskIds. Archived projects remain valid owners:
// the archive operation can race with this task update during replay.
const newProjectCandidate = newProjectId
? (projectState.entities[newProjectId] as Project | undefined)
: undefined;
const newProject =
newProjectCandidate && newProjectCandidate.id === newProjectId
? newProjectCandidate
: undefined;
if (shouldAddToNewProject && newProjectId && newProject) {
// Only add if not already present
if (!newProject.taskIds.includes(taskId)) {
projectState = projectAdapter.updateOne(
@ -153,8 +169,9 @@ const syncTagTaskIds = (
// Remove task from removed tags' taskIds
for (const tagId of removedTags) {
if (tagState.entities[tagId]) {
const tag = tagState.entities[tagId] as Tag;
const tagCandidate = tagState.entities[tagId] as Tag | undefined;
const tag = tagCandidate?.id === tagId ? tagCandidate : undefined;
if (tag) {
if (tag.taskIds.includes(taskId)) {
tagState = tagAdapter.updateOne(
{
@ -176,8 +193,9 @@ const syncTagTaskIds = (
// Add task to added tags' taskIds
for (const tagId of addedTags) {
if (tagState.entities[tagId]) {
const tag = tagState.entities[tagId] as Tag;
const tagCandidate = tagState.entities[tagId] as Tag | undefined;
const tag = tagCandidate?.id === tagId ? tagCandidate : undefined;
if (tag) {
if (!tag.taskIds.includes(taskId)) {
tagState = tagAdapter.updateOne(
{
@ -307,8 +325,12 @@ const syncParentSubTaskIds = (
let taskState = state[TASK_FEATURE_NAME];
// Remove from old parent's subTaskIds
if (oldParentId && taskState.entities[oldParentId]) {
const oldParent = taskState.entities[oldParentId] as Task;
const oldParentCandidate = oldParentId
? (taskState.entities[oldParentId] as Task | undefined)
: undefined;
const oldParent =
oldParentCandidate?.id === oldParentId ? oldParentCandidate : undefined;
if (oldParentId && oldParent) {
if (oldParent.subTaskIds.includes(taskId)) {
taskState = taskAdapter.updateOne(
{
@ -328,8 +350,12 @@ const syncParentSubTaskIds = (
}
// Add to new parent's subTaskIds
if (newParentId && taskState.entities[newParentId]) {
const newParent = taskState.entities[newParentId] as Task;
const newParentCandidate = newParentId
? (taskState.entities[newParentId] as Task | undefined)
: undefined;
const newParent =
newParentCandidate?.id === newParentId ? newParentCandidate : undefined;
if (newParentId && newParent) {
// Only add if not already present
if (!newParent.subTaskIds.includes(taskId)) {
taskState = taskAdapter.updateOne(
@ -532,12 +558,16 @@ export const lwwUpdateMetaReducer: MetaReducer = (
// set from op.entityId before reaching this reducer. Producers also
// force the canonical id on-disk. The check below remains as a hard
// guard for actions arriving with no usable id at all.
if (!entityData['id']) {
if (typeof entityData['id'] !== 'string' || !entityData['id']) {
OpLog.warn('lwwUpdateMetaReducer: Entity data has no id');
return reducer(state, action);
}
const entityId = entityData['id'] as string;
if (Object.prototype.hasOwnProperty.call(Object.prototype, entityId)) {
OpLog.warn(`lwwUpdateMetaReducer: Unsafe entity id: ${entityId}`);
return reducer(state, action);
}
// Sanitize date string fields to prevent corrupted data from sync (#6908)
if (entityType === 'TASK') {
@ -563,11 +593,51 @@ export const lwwUpdateMetaReducer: MetaReducer = (
}
}
const existingEntity = (
const existingEntityCandidate = (
featureState as unknown as {
entities?: Record<string, Record<string, unknown>>;
}
).entities?.[entityId];
const existingEntity =
existingEntityCandidate?.['id'] === entityId ? existingEntityCandidate : undefined;
if (
entityType === 'TASK' &&
Object.prototype.hasOwnProperty.call(entityData, 'parentId') &&
typeof entityData['parentId'] === 'string' &&
Object.prototype.hasOwnProperty.call(Object.prototype, entityData['parentId'])
) {
entityData['parentId'] = existingEntity?.parentId;
}
// A remote projectId pointing at a project this client deleted would
// orphan an existing task from every project list. Archived projects are
// still valid owners: their archive op can race with the task update.
// Recreated tasks deliberately keep out-of-order project references so a
// later project op can complete the relationship.
if (
entityType === 'TASK' &&
existingEntity &&
Object.prototype.hasOwnProperty.call(entityData, 'projectId')
) {
const requestedProjectId = entityData['projectId'];
const currentProjectId = existingEntity?.projectId;
const fallbackProjectId =
typeof currentProjectId === 'string' &&
(currentProjectId === '' || getProjectOrUndefined(rootState, currentProjectId))
? currentProjectId
: undefined;
if (requestedProjectId == null) {
entityData['projectId'] = undefined;
} else if (
typeof requestedProjectId !== 'string' ||
(requestedProjectId !== '' &&
!getProjectOrUndefined(rootState, requestedProjectId))
) {
entityData['projectId'] = fallbackProjectId;
}
}
let updatedFeatureState: unknown;
@ -675,19 +745,61 @@ export const lwwUpdateMetaReducer: MetaReducer = (
if (entityType === 'TASK' && updatedEntity) {
// Sync project.taskIds when projectId changes
const oldProjectId = existingEntity?.projectId as string | undefined;
const newProjectId = updatedEntity.projectId as string | undefined;
let 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,
oldIsSubTask,
newIsSubTask,
);
// Subtasks inherit their project from the parent — a snapshot carrying
// a diverging projectId (split state from an older client) is corrected
// rather than applied.
if (newParentId) {
const parentCandidate = updatedState[TASK_FEATURE_NAME].entities[newParentId] as
| Task
| undefined;
const parent = parentCandidate?.id === newParentId ? parentCandidate : undefined;
if (parent && parent.projectId !== newProjectId) {
newProjectId = parent.projectId;
updatedState = {
...updatedState,
[TASK_FEATURE_NAME]: taskAdapter.updateOne(
{ id: entityId, changes: { projectId: newProjectId } },
updatedState[TASK_FEATURE_NAME],
),
};
}
}
const meta = actionAny['meta'];
const explicitEntityIds =
meta &&
typeof meta === 'object' &&
Array.isArray((meta as { entityIds?: unknown }).entityIds)
? (meta as { entityIds: unknown[] }).entityIds.filter(
(id): id is string => typeof id === 'string',
)
: undefined;
if (!newIsSubTask) {
// Root snapshots repair every project list, even when projectId is
// unchanged. New synthetic LWW ops replay their source footprint;
// old ops without entityIds retain receiving-state repair behavior.
updatedState = repairTaskProjectForLww(
updatedState,
updatedEntity as unknown as Task,
newProjectId,
explicitEntityIds,
);
} else {
updatedState = syncProjectTaskIds(
updatedState,
entityId,
oldProjectId,
newProjectId,
oldIsSubTask,
newIsSubTask,
);
}
// Sync tag.taskIds when tagIds changes
const oldTagIds = (existingEntity?.tagIds as string[]) || [];

View file

@ -39,6 +39,19 @@ const stateWith = (
};
};
const addProject = (state: RootState, projectId: string): void => {
const template = state[PROJECT_FEATURE_NAME].entities.project1;
if (!template) throw new Error('Expected project1 test fixture');
state[PROJECT_FEATURE_NAME].entities[projectId] = {
...template,
id: projectId,
title: projectId,
taskIds: [],
backlogTaskIds: [],
};
(state[PROJECT_FEATURE_NAME].ids as string[]).push(projectId);
};
describe('sectionSharedMetaReducer', () => {
let mockReducer: jasmine.Spy;
let metaReducer: ActionReducer<any, Action>;
@ -151,6 +164,92 @@ describe('sectionSharedMetaReducer', () => {
expect(updated.entities['s2']?.taskIds).toEqual([]);
});
it('clears stale project-section references when restoring a reverse-linked subtask', () => {
const state = stateWith(
{
sub1: { parentId: 'parent', projectId: 'oldP' },
},
[
{
id: 'sOld',
contextId: 'oldP',
contextType: WorkContextType.PROJECT,
title: 'old project section',
taskIds: ['parent', 'sub1'],
},
{
id: 'sTag',
contextId: 'tag1',
contextType: WorkContextType.TAG,
title: 'tag section',
taskIds: ['sub1'],
},
{
id: 'sToday',
contextId: TODAY_TAG.id,
contextType: WorkContextType.TAG,
title: 'today section',
taskIds: ['sub1'],
},
],
);
metaReducer(
state,
TaskSharedActions.restoreTask({
task: createMockTask({ id: 'parent', projectId: 'project1', subTaskIds: [] }),
subTasks: [],
}),
);
const updated = (
mockReducer.calls.mostRecent().args[0] as RootState & {
[SECTION_FEATURE_NAME]: SectionState;
}
)[SECTION_FEATURE_NAME];
expect(updated.entities['sOld']?.taskIds).toEqual([]);
expect(updated.entities['sTag']?.taskIds).toEqual(['sub1']);
expect(updated.entities['sToday']?.taskIds).toEqual(['sub1']);
});
it('keeps section membership for a payload child owned by another parent', () => {
const state = stateWith(
{
otherParent: { projectId: 'oldP', subTaskIds: ['collision'] },
collision: { projectId: 'oldP', parentId: 'otherParent' },
},
[
{
id: 'sOld',
contextId: 'oldP',
contextType: WorkContextType.PROJECT,
title: 'old project section',
taskIds: ['collision'],
},
],
);
metaReducer(
state,
TaskSharedActions.restoreTask({
task: createMockTask({
id: 'parent',
projectId: 'project1',
subTaskIds: ['collision', 'missing-child'],
}),
subTasks: [
createMockTask({
id: 'collision',
parentId: 'parent',
projectId: 'project1',
}),
],
}),
);
expect(mockReducer.calls.mostRecent().args[0]).toBe(state);
});
it('handles deleteTasks (bulk) across multiple sections', () => {
const state = stateWith({ t1: {}, t2: {}, t3: {}, t4: {} }, [
{
@ -456,6 +555,383 @@ describe('sectionSharedMetaReducer', () => {
expect(updated.entities['sTag']?.taskIds).toEqual(['parent']);
});
it('strips project sections when updateTask changes projectId', () => {
const state = stateWith(
{
parent: { projectId: 'oldP', subTaskIds: ['sub1'] },
sub1: { projectId: 'oldP', parentId: 'parent' },
},
[
{
id: 'sOld',
contextId: 'oldP',
contextType: WorkContextType.PROJECT,
title: 'old project section',
taskIds: ['parent', 'sub1', 'unrelated'],
},
{
id: 'sOther',
contextId: 'newP',
contextType: WorkContextType.PROJECT,
title: 'target project section',
taskIds: [],
},
],
);
addProject(state, 'newP');
const action = TaskSharedActions.updateTask({
task: { id: 'parent', changes: { projectId: 'newP' } },
});
metaReducer(state, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as RootState & {
[SECTION_FEATURE_NAME]: SectionState;
};
expect(updatedState[SECTION_FEATURE_NAME].entities['sOld']?.taskIds).toEqual([
'unrelated',
]);
expect(updatedState[SECTION_FEATURE_NAME].entities['sOther']?.taskIds).toEqual([]);
});
it('strips old-project sections when a task moves through LWW resolution', () => {
const state = stateWith(
{
parent: { projectId: 'oldP', subTaskIds: [] },
sub1: { projectId: 'oldP', parentId: 'parent' },
receiverOnly: { projectId: 'oldP', parentId: 'parent' },
},
[
{
id: 'sOld',
contextId: 'oldP',
contextType: WorkContextType.PROJECT,
title: 'old project section',
taskIds: ['parent', 'sub1', 'receiverOnly'],
},
],
);
addProject(state, 'newP');
metaReducer(state, {
type: '[TASK] LWW Update',
id: 'parent',
projectId: 'newP',
meta: { entityIds: ['parent', 'sub1'] },
} as Action);
const updated = (
mockReducer.calls.mostRecent().args[0] as RootState & {
[SECTION_FEATURE_NAME]: SectionState;
}
)[SECTION_FEATURE_NAME];
expect(updated.entities['sOld']?.taskIds).toEqual(['receiverOnly']);
});
it('repairs stale other-project sections for a same-project LWW snapshot', () => {
const state = stateWith({ parent: { projectId: 'project1' } }, [
{
id: 'sTarget',
contextId: 'project1',
contextType: WorkContextType.PROJECT,
title: 'target section',
taskIds: ['parent'],
},
{
id: 'sStale',
contextId: 'oldP',
contextType: WorkContextType.PROJECT,
title: 'stale section',
taskIds: ['parent'],
},
]);
metaReducer(state, {
type: '[TASK] LWW Update',
id: 'parent',
projectId: 'project1',
meta: { entityIds: ['parent'] },
} as Action);
const updated = (
mockReducer.calls.mostRecent().args[0] as RootState & {
[SECTION_FEATURE_NAME]: SectionState;
}
)[SECTION_FEATURE_NAME];
expect(updated.entities['sTarget']?.taskIds).toEqual(['parent']);
expect(updated.entities['sStale']?.taskIds).toEqual([]);
});
it('strips old-project sections when LWW clears projectId', () => {
const state = stateWith({ parent: { projectId: 'oldP' } }, [
{
id: 'sOld',
contextId: 'oldP',
contextType: WorkContextType.PROJECT,
title: 'old project section',
taskIds: ['parent'],
},
]);
metaReducer(state, {
type: '[TASK] LWW Update',
id: 'parent',
projectId: '',
} as Action);
const updated = (
mockReducer.calls.mostRecent().args[0] as RootState & {
[SECTION_FEATURE_NAME]: SectionState;
}
)[SECTION_FEATURE_NAME];
expect(updated.entities['sOld']?.taskIds).toEqual([]);
});
it('strips project sections for reverse-linked children when LWW recreates a parent', () => {
const state = stateWith({ sub1: { projectId: 'oldP', parentId: 'parent' } }, [
{
id: 'sOld',
contextId: 'oldP',
contextType: WorkContextType.PROJECT,
title: 'old project section',
taskIds: ['sub1'],
},
]);
metaReducer(state, {
type: '[TASK] LWW Update',
id: 'parent',
projectId: 'project1',
subTaskIds: [],
} as Action);
const updated = (
mockReducer.calls.mostRecent().args[0] as RootState & {
[SECTION_FEATURE_NAME]: SectionState;
}
)[SECTION_FEATURE_NAME];
expect(updated.entities['sOld']?.taskIds).toEqual([]);
});
it('strips old-project sections when LWW reparents a subtask across projects', () => {
const state = stateWith(
{
oldParent: { projectId: 'oldP', subTaskIds: ['sub1'] },
newParent: { projectId: 'project1', subTaskIds: [] },
sub1: { projectId: 'oldP', parentId: 'oldParent' },
},
[
{
id: 'sOld',
contextId: 'oldP',
contextType: WorkContextType.PROJECT,
title: 'old project section',
taskIds: ['sub1'],
},
],
);
metaReducer(state, {
type: '[TASK] LWW Update',
id: 'sub1',
parentId: 'newParent',
projectId: 'project1',
} as Action);
const updated = (
mockReducer.calls.mostRecent().args[0] as RootState & {
[SECTION_FEATURE_NAME]: SectionState;
}
)[SECTION_FEATURE_NAME];
expect(updated.entities['sOld']?.taskIds).toEqual([]);
});
it('strips old-project sections when LWW promotes a subtask in another project', () => {
const state = stateWith(
{
oldParent: { projectId: 'oldP', subTaskIds: ['sub1'] },
sub1: { projectId: 'oldP', parentId: 'oldParent' },
},
[
{
id: 'sOld',
contextId: 'oldP',
contextType: WorkContextType.PROJECT,
title: 'old project section',
taskIds: ['sub1'],
},
],
);
addProject(state, 'newP');
metaReducer(state, {
type: '[TASK] LWW Update',
id: 'sub1',
parentId: undefined,
projectId: 'newP',
} as Action);
const updated = (
mockReducer.calls.mostRecent().args[0] as RootState & {
[SECTION_FEATURE_NAME]: SectionState;
}
)[SECTION_FEATURE_NAME];
expect(updated.entities['sOld']?.taskIds).toEqual([]);
});
it('keeps section references for a rejected direct subtask projectId update', () => {
const state = stateWith(
{
parent: { projectId: 'oldP', subTaskIds: ['sub1'] },
sub1: { projectId: 'oldP', parentId: 'parent' },
},
[
{
id: 'sOld',
contextId: 'oldP',
contextType: WorkContextType.PROJECT,
title: 'old project section',
taskIds: ['sub1'],
},
],
);
metaReducer(
state,
TaskSharedActions.updateTask({
task: { id: 'sub1', changes: { projectId: 'project1' } },
}),
);
expect(mockReducer.calls.mostRecent().args[0]).toBe(state);
});
it('cleans only the captured project-move footprint on divergent state', () => {
const state = stateWith(
{
parent: { projectId: 'oldP', subTaskIds: [] },
orphan: { projectId: 'oldP', parentId: 'parent' },
receiverOnly: { projectId: 'oldP', parentId: 'parent' },
},
[
{
id: 'sOld',
contextId: 'oldP',
contextType: WorkContextType.PROJECT,
title: 'old project section',
taskIds: ['parent', 'orphan', 'receiverOnly'],
},
],
);
addProject(state, 'newP');
const action = TaskSharedActions.updateTask({
task: { id: 'parent', changes: { projectId: 'newP' } },
projectMoveSubTaskIds: ['orphan'],
});
metaReducer(state, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as RootState & {
[SECTION_FEATURE_NAME]: SectionState;
};
expect(updatedState[SECTION_FEATURE_NAME].entities['sOld']?.taskIds).toEqual([
'receiverOnly',
]);
});
it('keeps sections unchanged for a missing project destination', () => {
const state = stateWith({ task1: { projectId: 'project1' } }, [
{
id: 'sCurrent',
contextId: 'project1',
contextType: WorkContextType.PROJECT,
title: 'current project section',
taskIds: ['task1'],
},
]);
metaReducer(
state,
TaskSharedActions.updateTask({
task: { id: 'task1', changes: { projectId: 'missing-project' } },
}),
);
const updatedState = mockReducer.calls.mostRecent().args[0] as RootState & {
[SECTION_FEATURE_NAME]: SectionState;
};
expect(updatedState[SECTION_FEATURE_NAME].entities['sCurrent']?.taskIds).toEqual([
'task1',
]);
});
it('removes stale section references when restoring a task family', () => {
const state = stateWith({}, [
{
id: 'sOld',
contextId: 'oldP',
contextType: WorkContextType.PROJECT,
title: 'old project section',
taskIds: ['parent', 'subtask', 'unrelated'],
},
]);
const parent = createMockTask({
id: 'parent',
projectId: 'newP',
subTaskIds: ['subtask'],
});
const subTask = createMockTask({
id: 'subtask',
parentId: 'parent',
projectId: 'oldP',
});
metaReducer(
state,
TaskSharedActions.restoreTask({ task: parent, subTasks: [subTask] }),
);
const updatedState = mockReducer.calls.mostRecent().args[0] as RootState & {
[SECTION_FEATURE_NAME]: SectionState;
};
expect(updatedState[SECTION_FEATURE_NAME].entities['sOld']?.taskIds).toEqual([
'unrelated',
]);
});
it('repairs stale project sections when projectId is patched unchanged', () => {
const state = stateWith({ task1: { projectId: 'newP' } }, [
{
id: 'sStale',
contextId: 'oldP',
contextType: WorkContextType.PROJECT,
title: 'stale project section',
taskIds: ['task1'],
},
{
id: 'sTarget',
contextId: 'newP',
contextType: WorkContextType.PROJECT,
title: 'target project section',
taskIds: ['task1'],
},
]);
addProject(state, 'newP');
const action = TaskSharedActions.updateTask({
task: { id: 'task1', changes: { projectId: 'newP' } },
});
metaReducer(state, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as RootState & {
[SECTION_FEATURE_NAME]: SectionState;
};
expect(updatedState[SECTION_FEATURE_NAME].entities['sStale']?.taskIds).toEqual([]);
expect(updatedState[SECTION_FEATURE_NAME].entities['sTarget']?.taskIds).toEqual([
'task1',
]);
});
it('updateTask without tagIds change is a no-op for sections', () => {
const state = stateWith({ t1: { tagIds: ['a'] } }, [
{

View file

@ -19,6 +19,12 @@ import { WorkContextType } from '../../../features/work-context/work-context.mod
import { TODAY_TAG } from '../../../features/tag/tag.const';
import { moveItemAfterAnchor } from '../../../features/work-context/store/work-context-meta.helper';
import { canApplyConvertToSubTask } from '../../../features/tasks/util/can-convert-task-to-sub-task';
import {
collectTaskAndSubTaskIds,
getProjectOrUndefined,
isValidTaskProjectIdUpdate,
} from './task-shared-helpers';
import { toLwwUpdateActionType } from '../../../op-log/core/lww-update-action-types';
// Must run before taskSharedCrudMetaReducer — handlers read pre-update
// task state to compute cleanups. Position pinned by
@ -29,21 +35,6 @@ interface ExtendedState extends RootState {
type Handler = (state: ExtendedState, action: Action) => ExtendedState;
const collectAffectedTaskIds = (
state: ExtendedState,
primaryTaskIds: string[],
): string[] => {
const taskState = state[TASK_FEATURE_NAME];
const all = new Set<string>(primaryTaskIds);
for (const id of primaryTaskIds) {
const t = taskState.entities[id];
if (t?.subTaskIds?.length) {
for (const sub of t.subTaskIds) all.add(sub);
}
}
return Array.from(all);
};
/**
* Walk `taskIds` once removing entries in `removedSet`. Returns `null`
* when nothing was removed so callers can keep the original array
@ -112,12 +103,13 @@ const removeProjectSections = (
/**
* Strip `taskIds` from sections owned by `projectId` (PROJECT context).
* Used when a task leaves a project (moveToOtherProject) its section
* membership in the old project becomes stale.
* membership in the old project becomes stale. Omitting `projectId`
* strips from every project's sections (repair paths).
*/
const removeTaskIdsFromProjectSections = (
sectionState: SectionState,
taskIds: string[],
projectId: string,
projectId?: string,
): SectionState => {
if (taskIds.length === 0) return sectionState;
@ -128,7 +120,7 @@ const removeTaskIdsFromProjectSections = (
const s = sectionState.entities[id];
if (!s) continue;
if (s.contextType !== WorkContextType.PROJECT) continue;
if (s.contextId !== projectId) continue;
if (projectId !== undefined && s.contextId !== projectId) continue;
const filtered = filterRemovingTaskIds(s.taskIds, taskIdSet);
if (filtered !== null) {
updates.push({ id: s.id, changes: { taskIds: filtered } });
@ -139,6 +131,36 @@ const removeTaskIdsFromProjectSections = (
return sectionAdapter.updateMany(updates, sectionState);
};
/**
* Strip task IDs from every project section except the destination project's.
* This is used by generic task updates, which may be repairing state where the
* task's current projectId no longer identifies every stale section reference.
*/
const removeTaskIdsFromOtherProjectSections = (
sectionState: SectionState,
taskIds: string[],
targetProjectId: string,
): SectionState => {
if (taskIds.length === 0) return sectionState;
const taskIdSet = new Set(taskIds);
const updates: Update<Section>[] = [];
for (const id of sectionState.ids) {
const section = sectionState.entities[id];
if (!section) continue;
if (section.contextType !== WorkContextType.PROJECT) continue;
if (section.contextId === targetProjectId) continue;
const filtered = filterRemovingTaskIds(section.taskIds, taskIdSet);
if (filtered !== null) {
updates.push({ id: section.id, changes: { taskIds: filtered } });
}
}
if (!updates.length) return sectionState;
return sectionAdapter.updateMany(updates, sectionState);
};
/**
* Strip `taskIds` from the singleton TODAY-tag section bucket.
*/
@ -178,7 +200,7 @@ const handleTaskRemoval = (
state: ExtendedState,
primaryTaskIds: string[],
): ExtendedState => {
const affectedIds = collectAffectedTaskIds(state, primaryTaskIds);
const affectedIds = collectTaskAndSubTaskIds(state, primaryTaskIds);
return withSectionStateUpdate(
state,
cleanupSectionTaskIds(state[SECTION_FEATURE_NAME], affectedIds),
@ -198,7 +220,7 @@ const handleMoveToOtherProject = (
const oldProjectId = t?.projectId;
if (!oldProjectId || oldProjectId === targetProjectId) return state;
const affectedTaskIds = collectAffectedTaskIds(state, [taskId]);
const affectedTaskIds = collectTaskAndSubTaskIds(state, [taskId]);
return withSectionStateUpdate(
state,
removeTaskIdsFromProjectSections(
@ -297,11 +319,6 @@ const handleRemoveTaskFromSection = (
* tasks within its single-action transform. Sections it would
* otherwise affect aren't pruned. Handling it here requires walking
* the operations array, which is non-trivial.
*
* - LWW conflict-resolution can reassign a task's projectId; project-
* context section.taskIds can leak phantom references until the next
* `dataRepair` pass clears them. Visible impact bounded by render-time
* intersection in `undoneTasksBySection`.
*/
const ACTION_HANDLERS: Record<string, Handler> = {
[TaskSharedActions.deleteTask.type]: (state, action) => {
@ -313,27 +330,18 @@ const ACTION_HANDLERS: Record<string, Handler> = {
return handleTaskRemoval(state, taskIds);
},
[TaskSharedActions.moveToArchive.type]: (state, action) => {
// Strip archived task ids from sections. Restore is intentionally
// NOT a counterpart — the task comes back without a section, mirror
// of how restore drops missing tagIds.
//
// Union payload-subTasks with state-derived subtasks: payload covers
// the replay-with-missing-state case; state covers callers who pass
// an empty `subTasks` array.
const { tasks } = action as ReturnType<typeof TaskSharedActions.moveToArchive>;
const idSet = new Set<string>();
for (const t of tasks) {
idSet.add(t.id);
if (t.subTasks?.length) for (const st of t.subTasks) idSet.add(st.id);
}
const stateExpanded = collectAffectedTaskIds(
const affectedTaskIds = collectTaskAndSubTaskIds(
state,
tasks.map((t) => t.id),
tasks.flatMap((t) => [...(t.subTaskIds ?? []), ...t.subTasks.map((st) => st.id)]),
);
for (const id of stateExpanded) idSet.add(id);
return withSectionStateUpdate(
state,
cleanupSectionTaskIds(state[SECTION_FEATURE_NAME], Array.from(idSet)),
cleanupSectionTaskIds(state[SECTION_FEATURE_NAME], affectedTaskIds),
);
},
[TaskSharedActions.deleteProject.type]: (state, action) => {
@ -362,6 +370,143 @@ const ACTION_HANDLERS: Record<string, Handler> = {
>;
return handleMoveToOtherProject(state, task.id, targetProjectId);
},
[TaskSharedActions.restoreTask.type]: (state, action) => {
// Restored tasks come back without a section (mirror of how restore
// drops missing tagIds) — strip any stale refs left by a pre-fix
// archive. The guard mirrors the lifecycle reducer's idempotent
// replay: when the root is already active, task state is untouched,
// so section membership must survive too.
const { task, subTasks } = action as ReturnType<typeof TaskSharedActions.restoreTask>;
if (Object.prototype.hasOwnProperty.call(Object.prototype, task.id)) return state;
if (state[TASK_FEATURE_NAME].entities[task.id]?.id === task.id) return state;
const taskIds = new Set(collectTaskAndSubTaskIds(state, [task.id]));
for (const subTaskId of task.subTaskIds ?? []) {
const existingSubTask = state[TASK_FEATURE_NAME].entities[subTaskId];
if (!existingSubTask || existingSubTask.parentId === task.id) {
taskIds.add(subTaskId);
}
}
for (const subTask of subTasks) {
const existingSubTask = state[TASK_FEATURE_NAME].entities[subTask.id];
if (
existingSubTask?.parentId === task.id ||
(!existingSubTask && subTask.parentId === task.id)
) {
taskIds.add(subTask.id);
}
}
return withSectionStateUpdate(
state,
removeTaskIdsFromProjectSections(state[SECTION_FEATURE_NAME], Array.from(taskIds)),
);
},
[TaskSharedActions.updateTask.type]: (state, action) => {
const { task, projectMoveSubTaskIds } = action as ReturnType<
typeof TaskSharedActions.updateTask
>;
const targetProjectId = task.changes.projectId;
if (typeof targetProjectId !== 'string') return state;
const currentTask = state[TASK_FEATURE_NAME].entities[task.id] as Task | undefined;
if (
!currentTask ||
!isValidTaskProjectIdUpdate(state, currentTask, targetProjectId)
) {
return state;
}
const affectedTaskIds =
projectMoveSubTaskIds !== undefined
? [task.id as string, ...projectMoveSubTaskIds]
: collectTaskAndSubTaskIds(state, [task.id as string]);
return withSectionStateUpdate(
state,
removeTaskIdsFromOtherProjectSections(
state[SECTION_FEATURE_NAME],
affectedTaskIds,
targetProjectId,
),
);
},
[toLwwUpdateActionType('TASK')]: (state, action) => {
const update = action as Action & {
id?: unknown;
parentId?: unknown;
projectId?: unknown;
meta?: { entityIds?: unknown };
};
if (typeof update.id !== 'string') return state;
const currentTaskCandidate = state[TASK_FEATURE_NAME].entities[update.id] as
| Task
| undefined;
const currentTask =
currentTaskCandidate?.id === update.id ? currentTaskCandidate : undefined;
const explicitEntityIds = Array.isArray(update.meta?.entityIds)
? update.meta.entityIds.filter((id): id is string => typeof id === 'string')
: undefined;
const affectedTaskIds =
explicitEntityIds !== undefined
? Array.from(new Set([update.id, ...explicitEntityIds]))
: collectTaskAndSubTaskIds(state, [update.id]);
const hasParentId = Object.prototype.hasOwnProperty.call(update, 'parentId');
const hasProjectId = Object.prototype.hasOwnProperty.call(update, 'projectId');
if (!hasParentId && !hasProjectId) return state;
if (!currentTask) {
return withSectionStateUpdate(
state,
removeTaskIdsFromProjectSections(state[SECTION_FEATURE_NAME], affectedTaskIds),
);
}
let targetProjectId: string | undefined = currentTask.projectId;
let requestedProjectId: string | undefined = currentTask.projectId;
if (hasProjectId) {
if (update.projectId == null) {
requestedProjectId = undefined;
} else if (
typeof update.projectId === 'string' &&
(update.projectId === '' || getProjectOrUndefined(state, update.projectId))
) {
requestedProjectId = update.projectId;
}
}
const targetParentId =
hasParentId && typeof update.parentId === 'string' && update.parentId
? update.parentId
: undefined;
if (targetParentId) {
const targetParentCandidate = state[TASK_FEATURE_NAME].entities[targetParentId] as
| Task
| undefined;
const targetParent =
targetParentCandidate?.id === targetParentId ? targetParentCandidate : undefined;
targetProjectId = targetParent?.projectId ?? requestedProjectId;
} else if (hasParentId || !currentTask.parentId) {
targetProjectId = requestedProjectId;
}
const becomesSubTask = hasParentId ? !!targetParentId : !!currentTask.parentId;
const leavesCurrentProjectSection =
(!currentTask.parentId && becomesSubTask) ||
targetProjectId !== currentTask.projectId;
const repairsRootProjectRefs = !becomesSubTask && hasProjectId;
if (!leavesCurrentProjectSection && !repairsRootProjectRefs) return state;
return withSectionStateUpdate(
state,
becomesSubTask
? removeTaskIdsFromProjectSections(state[SECTION_FEATURE_NAME], affectedTaskIds)
: removeTaskIdsFromOtherProjectSections(
state[SECTION_FEATURE_NAME],
affectedTaskIds,
targetProjectId ?? '',
),
);
},
[TaskSharedActions.convertToSubTask.type]: (state, action) => {
const { taskId, targetParentId } = action as ReturnType<
typeof TaskSharedActions.convertToSubTask
@ -411,7 +556,7 @@ export const sectionSharedMetaReducer: MetaReducer<RootState> = (
// removes ids from TODAY without going through a known action.
const removedFromToday = diffRemovedTodayTaskIds(state, next);
if (!removedFromToday) return next;
const affected = collectAffectedTaskIds(next as ExtendedState, removedFromToday);
const affected = collectTaskAndSubTaskIds(next as ExtendedState, removedFromToday);
return applyTodayTagSectionCleanup(next, affected);
};
};

View file

@ -1330,9 +1330,14 @@ describe('taskSharedCrudMetaReducer', () => {
});
describe('updateTask action', () => {
const createUpdateTaskAction = (taskId: string, changes: Partial<Task>) =>
const createUpdateTaskAction = (
taskId: string,
changes: Partial<Task>,
projectMoveSubTaskIds?: string[],
) =>
TaskSharedActions.updateTask({
task: { id: taskId, changes },
...(projectMoveSubTaskIds !== undefined && { projectMoveSubTaskIds }),
});
it('should update task properties without affecting tags', () => {
@ -1360,6 +1365,340 @@ describe('taskSharedCrudMetaReducer', () => {
);
});
it('should atomically move a task and its subtasks to another project', () => {
const testState = createStateWithExistingTasks(['task1'], ['subtask1']);
testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({
id: 'task1',
projectId: 'project1',
subTaskIds: ['subtask1'],
});
testState[TASK_FEATURE_NAME].entities.subtask1 = createMockTask({
id: 'subtask1',
projectId: 'project1',
parentId: 'task1',
});
testState[TASK_FEATURE_NAME].entities['target-task'] = createMockTask({
id: 'target-task',
projectId: 'project2',
});
(testState[TASK_FEATURE_NAME].ids as string[]).push('target-task');
testState[PROJECT_FEATURE_NAME].entities.project2 = {
...testState[PROJECT_FEATURE_NAME].entities.project1,
id: 'project2',
title: 'Project 2',
taskIds: ['target-task'],
backlogTaskIds: [],
} as Project;
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('project2');
const action = createUpdateTaskAction('task1', {
projectId: 'project2',
title: 'Moved task',
});
metaReducer(testState, action);
expectStateUpdate(
{
[PROJECT_FEATURE_NAME]: jasmine.objectContaining({
entities: jasmine.objectContaining({
project1: jasmine.objectContaining({
taskIds: [],
backlogTaskIds: [],
}),
project2: jasmine.objectContaining({
taskIds: ['target-task', 'task1'],
}),
}),
}),
[TASK_FEATURE_NAME]: jasmine.objectContaining({
entities: jasmine.objectContaining({
task1: jasmine.objectContaining({
projectId: 'project2',
title: 'Moved task',
}),
subtask1: jasmine.objectContaining({ projectId: 'project2' }),
}),
}),
},
action,
mockReducer,
testState,
);
});
it('should replay only the captured project-move footprint on divergent state', () => {
const testState = createStateWithExistingTasks(['task1'], ['orphan-subtask']);
testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({
id: 'task1',
projectId: 'project1',
subTaskIds: [],
});
testState[TASK_FEATURE_NAME].entities['orphan-subtask'] = createMockTask({
id: 'orphan-subtask',
projectId: 'project1',
parentId: 'task1',
});
(testState[TASK_FEATURE_NAME].ids as string[]).push('receiver-only-subtask');
testState[TASK_FEATURE_NAME].entities['receiver-only-subtask'] = createMockTask({
id: 'receiver-only-subtask',
projectId: 'project1',
parentId: 'task1',
});
testState[PROJECT_FEATURE_NAME].entities.project2 = {
...testState[PROJECT_FEATURE_NAME].entities.project1,
id: 'project2',
title: 'Project 2',
taskIds: [],
backlogTaskIds: [],
} as Project;
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('project2');
const action = createUpdateTaskAction('task1', { projectId: 'project2' }, [
'orphan-subtask',
]);
expect(action.meta.entityIds).toEqual(['task1', 'orphan-subtask']);
metaReducer(testState, action);
expectStateUpdate(
{
[PROJECT_FEATURE_NAME]: jasmine.objectContaining({
entities: jasmine.objectContaining({
project1: jasmine.objectContaining({
taskIds: [],
backlogTaskIds: [],
}),
project2: jasmine.objectContaining({ taskIds: ['task1'] }),
}),
}),
[TASK_FEATURE_NAME]: jasmine.objectContaining({
entities: jasmine.objectContaining({
task1: jasmine.objectContaining({ projectId: 'project2' }),
'orphan-subtask': jasmine.objectContaining({ projectId: 'project2' }),
'receiver-only-subtask': jasmine.objectContaining({
projectId: 'project1',
}),
}),
}),
},
action,
mockReducer,
testState,
);
});
it('should ignore a missing project destination while applying other fields', () => {
const testState = createStateWithExistingTasks(['task1']);
const action = createUpdateTaskAction('task1', {
projectId: 'missing-project',
title: 'Updated title',
});
metaReducer(testState, action);
expectStateUpdate(
{
...expectProjectUpdate('project1', { taskIds: ['task1'] }),
...expectTaskUpdate('task1', {
projectId: 'project1',
title: 'Updated title',
}),
},
action,
mockReducer,
testState,
);
});
for (const invalidTarget of [
{ label: 'prototype-like', id: 'constructor' },
{ label: 'prototype-like', id: '__proto__' },
]) {
it(`should ignore a ${invalidTarget.label} project destination while applying other fields`, () => {
const testState = createStateWithExistingTasks(['task1']);
const action = createUpdateTaskAction('task1', {
projectId: invalidTarget.id,
title: 'Updated title',
});
metaReducer(testState, action);
expectStateUpdate(
{
...expectProjectUpdate('project1', { taskIds: ['task1'] }),
...expectTaskUpdate('task1', {
projectId: 'project1',
title: 'Updated title',
}),
},
action,
mockReducer,
testState,
);
});
}
it('should replay a move to an archived-but-existing project', () => {
const testState = createStateWithExistingTasks(['task1']);
testState[PROJECT_FEATURE_NAME].entities['archived-project'] = {
...(testState[PROJECT_FEATURE_NAME].entities.project1 as Project),
id: 'archived-project',
isArchived: true,
taskIds: [],
backlogTaskIds: [],
};
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('archived-project');
const action = createUpdateTaskAction('task1', {
projectId: 'archived-project',
title: 'Updated title',
});
metaReducer(testState, action);
expectStateUpdate(
{
...expectProjectUpdate('project1', { taskIds: [] }),
...expectProjectUpdate('archived-project', { taskIds: ['task1'] }),
...expectTaskUpdate('task1', {
projectId: 'archived-project',
title: 'Updated title',
}),
},
action,
mockReducer,
testState,
);
});
it('should allow clearing the project with an empty project id', () => {
const testState = createStateWithExistingTasks(['task1']);
const action = createUpdateTaskAction('task1', {
projectId: '',
title: 'Updated title',
});
metaReducer(testState, action);
expectStateUpdate(
{
...expectProjectUpdate('project1', { taskIds: [] }),
...expectTaskUpdate('task1', {
projectId: '',
title: 'Updated title',
}),
},
action,
mockReducer,
testState,
);
});
it('should ignore direct project changes on subtasks', () => {
const testState = createStateWithExistingTasks(['task1'], ['subtask1']);
testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({
id: 'task1',
projectId: 'project1',
subTaskIds: ['subtask1'],
});
testState[TASK_FEATURE_NAME].entities.subtask1 = createMockTask({
id: 'subtask1',
projectId: 'project1',
parentId: 'task1',
});
testState[PROJECT_FEATURE_NAME].entities.project2 = {
...testState[PROJECT_FEATURE_NAME].entities.project1,
id: 'project2',
title: 'Project 2',
taskIds: [],
backlogTaskIds: [],
} as Project;
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('project2');
const action = createUpdateTaskAction('subtask1', {
projectId: 'project2',
title: 'Updated subtask',
});
metaReducer(testState, action);
expectStateUpdate(
expectTaskUpdate('subtask1', {
projectId: 'project1',
title: 'Updated subtask',
}),
action,
mockReducer,
testState,
);
});
it('should repair stale project memberships when projectId is patched unchanged', () => {
const testState = createStateWithExistingTasks(['task1']);
testState[PROJECT_FEATURE_NAME].entities.project2 = {
...testState[PROJECT_FEATURE_NAME].entities.project1,
id: 'project2',
title: 'Project 2',
taskIds: [],
backlogTaskIds: [],
} as Project;
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('project2');
testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({
id: 'task1',
projectId: 'project2',
});
const action = createUpdateTaskAction('task1', { projectId: 'project2' });
metaReducer(testState, action);
expectStateUpdate(
{
[PROJECT_FEATURE_NAME]: jasmine.objectContaining({
entities: jasmine.objectContaining({
project1: jasmine.objectContaining({ taskIds: [] }),
project2: jasmine.objectContaining({ taskIds: ['task1'] }),
}),
}),
},
action,
mockReducer,
testState,
);
});
it('should preserve task order when projectId is patched unchanged', () => {
const testState = createStateWithExistingTasks(['before', 'task1', 'after']);
const action = createUpdateTaskAction('task1', { projectId: 'project1' });
metaReducer(testState, action);
expectStateUpdate(
expectProjectUpdate('project1', {
taskIds: ['before', 'task1', 'after'],
}),
action,
mockReducer,
testState,
);
});
it('should preserve backlog position when projectId is patched unchanged', () => {
const testState = createStateWithExistingTasks([], ['before', 'task1', 'after']);
const action = createUpdateTaskAction('task1', { projectId: 'project1' });
metaReducer(testState, action);
expectStateUpdate(
expectProjectUpdate('project1', {
taskIds: [],
backlogTaskIds: ['before', 'task1', 'after'],
}),
action,
mockReducer,
testState,
);
});
it('should add task to new tags and remove from old tags when tagIds are updated', () => {
const testState = createStateWithExistingTasks(
['task1'],

View file

@ -33,12 +33,15 @@ import {
ActionHandlerMap,
addTaskToList,
addTaskToPlannerDay,
collectTaskAndSubTaskIds,
getProject,
getTag,
hasInvalidTodayTag,
isValidTaskProjectIdUpdate,
ProjectTaskList,
filterOutTodayTag,
removeTaskFromPlannerDays,
removeTasksFromAllProjects,
removeTasksFromAllTags,
removeTasksFromList,
TaskWithTags,
@ -633,7 +636,11 @@ const removeInProgressTagOnCompletion = (
};
};
const handleUpdateTask = (state: RootState, taskUpdate: Update<Task>): RootState => {
const handleUpdateTask = (
state: RootState,
taskUpdate: Update<Task>,
projectMoveSubTaskIds?: string[],
): RootState => {
const taskId = taskUpdate.id as string;
const currentTask = state[TASK_FEATURE_NAME].entities[taskId] as Task;
@ -648,11 +655,29 @@ const handleUpdateTask = (state: RootState, taskUpdate: Update<Task>): RootState
currentTask,
todayStr,
);
const cleanedTaskUpdate = removeInProgressTagOnCompletion(
let cleanedTaskUpdate = removeInProgressTagOnCompletion(
sanitizedTaskUpdate,
currentTask,
);
// Subtasks inherit their project from the parent, and only an existing
// project (or '' for no project) is a usable destination for a top-level
// task. Archived projects remain valid during replay because their archive
// op can race with this update. Strip null/undefined/unknown destinations
// as well as at API boundaries so malformed or legacy ops can neither split
// parent from child nor orphan a task from every project list.
if (Object.prototype.hasOwnProperty.call(cleanedTaskUpdate.changes, 'projectId')) {
const requestedProjectId = cleanedTaskUpdate.changes.projectId;
if (
typeof requestedProjectId !== 'string' ||
!isValidTaskProjectIdUpdate(state, currentTask, requestedProjectId)
) {
const changes = { ...cleanedTaskUpdate.changes };
delete changes.projectId;
cleanedTaskUpdate = { ...cleanedTaskUpdate, changes };
}
}
// Handle tag changes if tagIds are being updated
if (cleanedTaskUpdate.changes.tagIds) {
const oldTagIds = currentTask.tagIds;
@ -661,6 +686,74 @@ const handleUpdateTask = (state: RootState, taskUpdate: Update<Task>): RootState
updatedState = handleTagUpdates(updatedState, taskId, oldTagIds, newTagIds);
}
const hasProjectIdUpdate = Object.prototype.hasOwnProperty.call(
cleanedTaskUpdate.changes,
'projectId',
);
const targetProjectId = cleanedTaskUpdate.changes.projectId;
if (
hasProjectIdUpdate &&
typeof targetProjectId === 'string' &&
!currentTask.parentId
) {
const subTaskIds =
projectMoveSubTaskIds !== undefined
? unique(
projectMoveSubTaskIds.filter(
(id) =>
id !== taskId &&
!Object.prototype.hasOwnProperty.call(Object.prototype, id),
),
)
: collectTaskAndSubTaskIds(state, [taskId]).filter((id) => id !== taskId);
const allTaskIds = [taskId, ...subTaskIds];
const targetProjectBefore =
updatedState[PROJECT_FEATURE_NAME].entities[targetProjectId];
const isSameProject = currentTask.projectId === targetProjectId;
updatedState = removeTasksFromAllProjects(updatedState, allTaskIds);
const targetProject = updatedState[PROJECT_FEATURE_NAME].entities[targetProjectId];
if (targetProject && targetProjectBefore) {
if (isSameProject) {
const subTaskIdSet = new Set(subTaskIds);
let taskIds = unique(
targetProjectBefore.taskIds.filter((id) => !subTaskIdSet.has(id)),
);
let backlogTaskIds = unique(
targetProjectBefore.backlogTaskIds.filter((id) => !subTaskIdSet.has(id)),
);
if (taskIds.includes(taskId)) {
backlogTaskIds = backlogTaskIds.filter((id) => id !== taskId);
} else if (!backlogTaskIds.includes(taskId)) {
taskIds = [...taskIds, taskId];
}
updatedState = updateProject(updatedState, targetProjectId, {
taskIds,
backlogTaskIds,
});
} else {
updatedState = updateProject(updatedState, targetProjectId, {
taskIds: unique([...targetProject.taskIds, taskId]),
});
}
}
if (subTaskIds.length > 0) {
updatedState = {
...updatedState,
[TASK_FEATURE_NAME]: taskAdapter.updateMany(
subTaskIds.map((id) => ({
id,
changes: { projectId: targetProjectId },
})),
updatedState[TASK_FEATURE_NAME],
),
};
}
}
// Handle task state updates using existing task reducer logic
let taskState = updatedState[TASK_FEATURE_NAME];
const { timeSpentOnDay, timeEstimate } = cleanedTaskUpdate.changes;
@ -860,8 +953,10 @@ const createActionHandlers = (state: RootState, action: Action): ActionHandlerMa
);
},
[TaskSharedActions.updateTask.type]: () => {
const { task } = action as ReturnType<typeof TaskSharedActions.updateTask>;
return handleUpdateTask(state, task);
const { task, projectMoveSubTaskIds } = action as ReturnType<
typeof TaskSharedActions.updateTask
>;
return handleUpdateTask(state, task, projectMoveSubTaskIds);
},
});

View file

@ -3,6 +3,10 @@ import { RootState } from '../../root-state';
import { Tag } from '../../../features/tag/tag.model';
import { Project } from '../../../features/project/project.model';
import { Task } from '../../../features/tasks/task.model';
import {
TASK_FEATURE_NAME,
taskAdapter,
} from '../../../features/tasks/store/task.reducer';
import {
PROJECT_FEATURE_NAME,
projectAdapter,
@ -20,7 +24,12 @@ import { TODAY_TAG } from '../../../features/tag/tag.const';
// =============================================================================
export type ProjectTaskList = 'backlogTaskIds' | 'taskIds';
export type TaskEntity = { id: string; projectId?: string | null; tagIds?: string[] };
export type TaskEntity = {
id: string;
projectId?: string | null;
tagIds?: string[];
subTaskIds?: string[];
};
export type TaskWithTags = Task & { tagIds: string[] };
export type ActionHandler = (state: RootState) => RootState;
export type ActionHandlerMap = Record<string, ActionHandler>;
@ -46,6 +55,46 @@ export const updateTags = (state: RootState, updates: Update<Tag>[]): RootState
[TAG_FEATURE_NAME]: tagAdapter.updateMany(updates, state[TAG_FEATURE_NAME]),
});
/**
* Collects parent task IDs plus children found through either side of the
* parent/subtask relationship. The reverse lookup mirrors deleteTaskHelper's
* protection against sync races where parent.subTaskIds is incomplete.
* `payloadSubTaskIds` covers replay on clients where neither side of the
* relationship has arrived yet (children only known from the action payload).
*/
export const collectTaskAndSubTaskIds = (
state: RootState,
parentTaskIds: string[],
payloadSubTaskIds: string[] | undefined = [],
): string[] => {
const parentIdSet = new Set(parentTaskIds);
const taskIds = new Set([...parentTaskIds, ...(payloadSubTaskIds ?? [])]);
const taskState = state[TASK_FEATURE_NAME];
for (const parentTaskId of parentTaskIds) {
const parentTask = taskState.entities[parentTaskId];
for (const subTaskId of parentTask?.subTaskIds ?? []) {
taskIds.add(subTaskId);
}
}
for (const taskId of taskState.ids as string[]) {
const task = taskState.entities[taskId];
if (task?.parentId && parentIdSet.has(task.parentId)) {
taskIds.add(taskId);
}
}
return Array.from(taskIds);
};
export const isValidTaskProjectIdUpdate = (
state: RootState,
task: Task,
projectId: string,
): boolean =>
!task.parentId && (projectId === '' || !!getProjectOrUndefined(state, projectId));
/**
* Removes the given task IDs from every tag's `taskIds`. Scans ALL tags from
* the CURRENT state (not a payload-provided list or the task's own `tagIds`)
@ -59,6 +108,8 @@ export const removeTasksFromAllTags = (
state: RootState,
taskIds: string[],
): RootState => {
if (taskIds.length === 0) return state;
const taskIdSet = new Set(taskIds);
const tagUpdates = (state[TAG_FEATURE_NAME].ids as string[])
.map((tagId) => state[TAG_FEATURE_NAME].entities[tagId])
@ -67,13 +118,122 @@ export const removeTasksFromAllTags = (
(tag): Update<Tag> => ({
id: tag.id,
changes: {
taskIds: removeTasksFromList(tag.taskIds, taskIds),
taskIds: tag.taskIds.filter((id) => !taskIdSet.has(id)),
},
}),
);
return updateTags(state, tagUpdates);
};
/**
* Removes the given task IDs from every project's regular and backlog lists.
* Scanning all projects also repairs one-sided references left by older clients
* or partial updates where task.projectId no longer matches the containing list.
*/
export const removeTasksFromAllProjects = (
state: RootState,
taskIds: string[],
): RootState => {
if (taskIds.length === 0) return state;
const taskIdSet = new Set(taskIds);
const projectUpdates = (state[PROJECT_FEATURE_NAME].ids as string[])
.map((projectId) => state[PROJECT_FEATURE_NAME].entities[projectId])
.filter(
(project): project is Project =>
!!project &&
((project.taskIds ?? []).some((id) => taskIdSet.has(id)) ||
(project.backlogTaskIds ?? []).some((id) => taskIdSet.has(id))),
)
.map(
(project): Update<Project> => ({
id: project.id,
changes: {
taskIds: (project.taskIds ?? []).filter((id) => !taskIdSet.has(id)),
backlogTaskIds: (project.backlogTaskIds ?? []).filter(
(id) => !taskIdSet.has(id),
),
},
}),
);
if (projectUpdates.length === 0) {
return state;
}
return {
...state,
[PROJECT_FEATURE_NAME]: projectAdapter.updateMany(
projectUpdates,
state[PROJECT_FEATURE_NAME],
),
};
};
/**
* Repairs a root task's project relationship after an LWW update. New LWW
* operations derived from project moves carry the source operation's entityIds,
* which are the deterministic move footprint. Legacy LWW operations have no
* such footprint and fall back to deriving children from receiving state.
*
* Every project is scanned so stale one-sided references are removed. The
* destination project's existing root placement (regular vs backlog) and
* ordering are preserved when possible; subtasks never belong in either list.
*/
export const repairTaskProjectForLww = (
state: RootState,
task: Pick<Task, 'id' | 'projectId' | 'subTaskIds'>,
targetProjectId: string | undefined,
operationTaskIds?: string[],
): RootState => {
const targetProject = targetProjectId
? getProjectOrUndefined(state, targetProjectId)
: undefined;
// Project creation can arrive after a task recreation LWW. Preserve that
// out-of-order task snapshot until the referenced project is replayed.
if (targetProjectId && !targetProject) return state;
const allTaskIds = unique(
operationTaskIds !== undefined
? [task.id, ...operationTaskIds]
: collectTaskAndSubTaskIds(state, [task.id], task.subTaskIds),
).filter((id) => !Object.prototype.hasOwnProperty.call(Object.prototype, id));
let updatedState = removeTasksFromAllProjects(state, allTaskIds);
if (targetProjectId && targetProject) {
const childIds = new Set(allTaskIds.filter((id) => id !== task.id));
let taskIds = unique((targetProject.taskIds ?? []).filter((id) => !childIds.has(id)));
let backlogTaskIds = unique(
(targetProject.backlogTaskIds ?? []).filter((id) => !childIds.has(id)),
);
if (taskIds.includes(task.id)) {
backlogTaskIds = backlogTaskIds.filter((id) => id !== task.id);
} else if (!backlogTaskIds.includes(task.id)) {
taskIds = [...taskIds, task.id];
}
updatedState = updateProject(updatedState, targetProjectId, {
taskIds,
backlogTaskIds,
});
}
const taskUpdates: Update<Task>[] = allTaskIds.map((id) => ({
id,
changes: { projectId: targetProjectId },
}));
return {
...updatedState,
[TASK_FEATURE_NAME]: taskAdapter.updateMany(
taskUpdates,
updatedState[TASK_FEATURE_NAME],
),
};
};
// =============================================================================
// ENTITY GETTERS
// =============================================================================
@ -83,7 +243,7 @@ export const removeTasksFromAllTags = (
* Callers should check existence before calling if tag may not exist.
*/
export const getTag = (state: RootState, tagId: string): Tag => {
const tag = state[TAG_FEATURE_NAME].entities[tagId];
const tag = getTagOrUndefined(state, tagId);
if (!tag) {
throw new Error(
`Tag ${tagId} not found in state. This may indicate an out-of-order remote operation.`,
@ -92,13 +252,21 @@ export const getTag = (state: RootState, tagId: string): Tag => {
return tag as Tag;
};
export const getTagOrUndefined = (state: RootState, tagId: string): Tag | undefined => {
const tag = state[TAG_FEATURE_NAME].entities[tagId] as Tag | undefined;
return tag?.id === tagId ? tag : undefined;
};
/**
* Gets a project entity from state. Throws if project doesn't exist.
* Callers should check existence before calling if project may not exist.
*/
export const getProject = (state: RootState, projectId: string): Project => {
const project = state[PROJECT_FEATURE_NAME].entities[projectId];
if (!project) {
// The id equality check rejects inherited Object.prototype members
// ('constructor', 'toString', …) that a bare entities[id] lookup returns
// truthy for when the id comes from external input (REST API, remote ops).
if (!project || project.id !== projectId) {
throw new Error(
`Project ${projectId} not found in state. This may indicate an out-of-order remote operation.`,
);
@ -113,8 +281,10 @@ export const getProject = (state: RootState, projectId: string): Project => {
export const getProjectOrUndefined = (
state: RootState,
projectId: string,
): Project | undefined =>
state[PROJECT_FEATURE_NAME].entities[projectId] as Project | undefined;
): Project | undefined => {
const project = state[PROJECT_FEATURE_NAME].entities[projectId] as Project | undefined;
return project?.id === projectId ? project : undefined;
};
// =============================================================================
// LIST MANIPULATION HELPERS

View file

@ -99,6 +99,76 @@ describe('taskSharedLifecycleMetaReducer', () => {
);
});
it('should remove stale task references from every project when archiving', () => {
const testState = createStateWithExistingTasks(['task1']);
testState[PROJECT_FEATURE_NAME].entities.project2 = createMockProject({
id: 'project2',
title: 'Project 2',
taskIds: [],
backlogTaskIds: [],
});
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('project2');
testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({
id: 'task1',
projectId: 'project2',
});
const action = createArchiveAction([
createTaskWithSubTasks({ id: 'task1', projectId: 'project2' }),
]);
metaReducer(testState, action);
expectStateUpdate(
{
[PROJECT_FEATURE_NAME]: jasmine.objectContaining({
entities: jasmine.objectContaining({
project1: jasmine.objectContaining({ taskIds: [] }),
project2: jasmine.objectContaining({ taskIds: [] }),
}),
}),
},
action,
mockReducer,
testState,
);
});
it('should clean reverse-linked subtasks omitted from the archive payload', () => {
const testState = createStateWithExistingTasks(
['task1', 'orphan-subtask'],
['orphan-subtask'],
);
testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({
id: 'task1',
projectId: 'project1',
subTaskIds: [],
});
testState[TASK_FEATURE_NAME].entities['orphan-subtask'] = createMockTask({
id: 'orphan-subtask',
projectId: 'project1',
parentId: 'task1',
});
const action = createArchiveAction([
createTaskWithSubTasks({
id: 'task1',
projectId: 'project1',
subTaskIds: [],
}),
]);
metaReducer(testState, action);
expectStateUpdate(
expectProjectUpdate('project1', {
taskIds: [],
backlogTaskIds: [],
}),
action,
mockReducer,
testState,
);
});
it('should handle empty tasks array', () => {
const action = createArchiveAction([]);
@ -656,6 +726,16 @@ describe('taskSharedLifecycleMetaReducer', () => {
subTasks,
});
for (const unsafeTaskId of ['constructor', '__proto__']) {
it(`should reject prototype-like restored task id ${unsafeTaskId}`, () => {
const action = createRestoreAction({ id: unsafeTaskId });
expect(() => metaReducer(baseState, action)).not.toThrow();
expect(mockReducer).toHaveBeenCalledWith(baseState, action);
expect(baseState[PROJECT_FEATURE_NAME].entities.project1?.taskIds).toEqual([]);
});
}
it('should add task to project taskIds', () => {
const action = createRestoreAction();
@ -680,6 +760,7 @@ describe('taskSharedLifecycleMetaReducer', () => {
{
...expectProjectUpdate('project1', { taskIds: ['task1'] }),
...expectTagUpdate('tag1', { taskIds: ['task1', 'subtask1'] }),
...expectTaskUpdate('subtask1', { parentId: 'task1' }),
},
action,
mockReducer,
@ -687,6 +768,152 @@ describe('taskSharedLifecycleMetaReducer', () => {
);
});
it('should restore subtasks into the parent project', () => {
const subTasks = [
createMockTask({
id: 'subtask1',
parentId: 'task1',
projectId: 'project1',
}),
];
const testState = createBaseState();
testState[PROJECT_FEATURE_NAME].entities.project2 = createMockProject({
id: 'project2',
title: 'Project 2',
taskIds: [],
backlogTaskIds: [],
});
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('project2');
const action = createRestoreAction(
{
id: 'task1',
projectId: 'project2',
subTaskIds: ['subtask1'],
},
subTasks,
);
metaReducer(testState, action);
expectStateUpdate(
{
...expectTaskUpdate('task1', { projectId: 'project2' }),
...expectTaskUpdate('subtask1', { projectId: 'project2' }),
},
action,
mockReducer,
testState,
);
});
it('should repair an existing reverse-linked subtask omitted from restore payload', () => {
const testState = createStateWithExistingTasks(['subtask1']);
testState[TASK_FEATURE_NAME].entities.subtask1 = createMockTask({
id: 'subtask1',
parentId: 'task1',
projectId: 'project1',
});
testState[PROJECT_FEATURE_NAME].entities.project2 = createMockProject({
id: 'project2',
title: 'Destination Project',
taskIds: [],
backlogTaskIds: [],
});
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('project2');
const action = createRestoreAction({
id: 'task1',
projectId: 'project2',
subTaskIds: [],
});
metaReducer(testState, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as RootState;
expect(updatedState[TASK_FEATURE_NAME].entities.task1?.subTaskIds).toEqual([
'subtask1',
]);
expect(updatedState[TASK_FEATURE_NAME].entities.subtask1?.projectId).toBe(
'project2',
);
expect(updatedState[PROJECT_FEATURE_NAME].entities.project1?.taskIds).toEqual([]);
expect(updatedState[PROJECT_FEATURE_NAME].entities.project2?.taskIds).toEqual([
'task1',
]);
});
it('should not adopt payload child ids owned by another active parent', () => {
const testState = createStateWithExistingTasks(['other-parent', 'colliding-child']);
testState[TASK_FEATURE_NAME].entities['other-parent'] = createMockTask({
id: 'other-parent',
projectId: 'project1',
subTaskIds: ['colliding-child'],
});
testState[TASK_FEATURE_NAME].entities['colliding-child'] = createMockTask({
id: 'colliding-child',
parentId: 'other-parent',
projectId: 'project1',
});
testState[PROJECT_FEATURE_NAME].entities.project2 = createMockProject({
id: 'project2',
title: 'Restore Project',
taskIds: [],
backlogTaskIds: [],
});
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('project2');
const action = createRestoreAction(
{
id: 'task1',
projectId: 'project2',
subTaskIds: ['colliding-child', 'missing-child'],
},
[
createMockTask({
id: 'colliding-child',
parentId: 'task1',
projectId: 'project2',
}),
],
);
metaReducer(testState, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as RootState;
expect(updatedState[TASK_FEATURE_NAME].entities.task1?.subTaskIds).toEqual([]);
expect(updatedState[TASK_FEATURE_NAME].entities['colliding-child']).toEqual(
jasmine.objectContaining({
parentId: 'other-parent',
projectId: 'project1',
}),
);
expect(updatedState[TASK_FEATURE_NAME].entities['missing-child']).toBeUndefined();
expect(updatedState[TAG_FEATURE_NAME].entities.tag1?.taskIds).toEqual(['task1']);
});
it('should keep canonical relationships when replaying restore for an active task', () => {
const testState = createStateWithExistingTasks(['task1']);
testState[PROJECT_FEATURE_NAME].entities.project2 = createMockProject({
id: 'project2',
title: 'Payload Project',
taskIds: [],
backlogTaskIds: [],
});
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('project2');
const action = createRestoreAction({
id: 'task1',
projectId: 'project2',
tagIds: [],
});
metaReducer(testState, action);
const updatedState = mockReducer.calls.mostRecent().args[0] as RootState;
expect(updatedState[TASK_FEATURE_NAME].entities.task1?.projectId).toBe('project1');
expect(updatedState[PROJECT_FEATURE_NAME].entities.project1?.taskIds).toEqual([
'task1',
]);
expect(updatedState[PROJECT_FEATURE_NAME].entities.project2?.taskIds).toEqual([]);
});
it('should add tasks to existing taskIds', () => {
const testState = createStateWithExistingTasks(
['existing-task'],
@ -707,7 +934,82 @@ describe('taskSharedLifecycleMetaReducer', () => {
);
});
it('should remove stale project references before restoring a task', () => {
const testState = createStateWithExistingTasks(
['archived-task'],
['archived-task'],
);
testState[TASK_FEATURE_NAME] = {
...testState[TASK_FEATURE_NAME],
ids: [],
entities: {},
};
testState[PROJECT_FEATURE_NAME].entities.project2 = createMockProject({
id: 'project2',
title: 'Project 2',
taskIds: [],
backlogTaskIds: [],
});
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('project2');
const action = createRestoreAction({
id: 'archived-task',
projectId: 'project2',
tagIds: [],
});
metaReducer(testState, action);
expectStateUpdate(
{
[PROJECT_FEATURE_NAME]: jasmine.objectContaining({
entities: jasmine.objectContaining({
project1: jasmine.objectContaining({
taskIds: [],
backlogTaskIds: [],
}),
project2: jasmine.objectContaining({ taskIds: ['archived-task'] }),
}),
}),
},
action,
mockReducer,
testState,
);
});
describe('stale reference normalization (issue #6270)', () => {
it('should preserve an archived-but-existing project on restore', () => {
const testState = createBaseState();
testState[PROJECT_FEATURE_NAME].entities['archived-project'] = createMockProject({
id: 'archived-project',
isArchived: true,
taskIds: [],
backlogTaskIds: [],
});
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('archived-project');
const action = createRestoreAction({
id: 'archived-task',
projectId: 'archived-project',
tagIds: [],
});
metaReducer(testState, action);
expectStateUpdate(
{
...expectTaskUpdate('archived-task', {
projectId: 'archived-project',
}),
...expectProjectUpdate('archived-project', {
taskIds: ['archived-task'],
}),
},
action,
mockReducer,
testState,
);
});
it('should reassign stale projectId to INBOX on restore', () => {
const testState = createBaseState();
// Add INBOX_PROJECT to state
@ -740,7 +1042,7 @@ describe('taskSharedLifecycleMetaReducer', () => {
const action = createRestoreAction({
id: 'archived-task',
projectId: 'project1',
tagIds: ['tag1', 'DELETED_TAG'],
tagIds: ['tag1', 'DELETED_TAG', 'constructor', '__proto__'],
});
metaReducer(baseState, action);

View file

@ -6,13 +6,11 @@ import {
PROJECT_FEATURE_NAME,
projectAdapter,
} from '../../../features/project/store/project.reducer';
import { TAG_FEATURE_NAME } from '../../../features/tag/store/tag.reducer';
import {
TASK_FEATURE_NAME,
taskAdapter,
} from '../../../features/tasks/store/task.reducer';
import { Tag } from '../../../features/tag/tag.model';
import { Project } from '../../../features/project/project.model';
import { Task, TaskWithSubTasks } from '../../../features/tasks/task.model';
import { TODAY_TAG } from '../../../features/tag/tag.const';
import { INBOX_PROJECT } from '../../../features/project/project.const';
@ -20,10 +18,12 @@ import { TASK_REPEAT_CFG_FEATURE_NAME } from '../../../features/task-repeat-cfg/
import { unique } from '../../../util/unique';
import {
ActionHandlerMap,
getProject,
collectTaskAndSubTaskIds,
getProjectOrUndefined,
getTag,
getTagOrUndefined,
removeTasksFromAllProjects,
removeTasksFromAllTags,
removeTasksFromList,
TaskEntity,
updateTags,
} from './task-shared-helpers';
@ -33,44 +33,18 @@ import {
// =============================================================================
const handleMoveToArchive = (state: RootState, tasks: TaskWithSubTasks[]): RootState => {
const taskIdsToArchive = tasks.flatMap((t) => [t.id, ...t.subTasks.map((st) => st.id)]);
// Get tag/project associations from CURRENT STATE, not payload.
// This is critical for remote sync: the payload reflects the originating client's
// state, but this client may have different tag/project associations for the same tasks.
// Using current state ensures we clean up all references on THIS client.
const projectIds = unique(
taskIdsToArchive
.map((taskId) => state[TASK_FEATURE_NAME].entities[taskId]?.projectId)
.filter((pid): pid is string => !!pid),
const taskIdsToArchive = collectTaskAndSubTaskIds(
state,
tasks.map((task) => task.id),
tasks.flatMap((task) => [
...(task.subTaskIds ?? []),
...task.subTasks.map((subTask) => subTask.id),
]),
);
let updatedState = state;
if (projectIds.length > 0) {
const projectUpdates = projectIds
.filter((pid) => !!state[PROJECT_FEATURE_NAME].entities[pid])
.map((pid): Update<Project> => {
const project = getProject(state, pid);
return {
id: pid,
changes: {
taskIds: removeTasksFromList(project.taskIds, taskIdsToArchive),
backlogTaskIds: removeTasksFromList(project.backlogTaskIds, taskIdsToArchive),
},
};
});
if (projectUpdates.length > 0) {
updatedState = {
...updatedState,
[PROJECT_FEATURE_NAME]: projectAdapter.updateMany(
projectUpdates,
updatedState[PROJECT_FEATURE_NAME],
),
};
}
}
// Scan every project instead of trusting task.projectId. Older partial updates
// could leave a task referenced by a different project than the task claims.
const updatedState = removeTasksFromAllProjects(state, taskIdsToArchive);
// Scan every tag, not just each task's own `tagIds` — see
// removeTasksFromAllTags for why (one-sided tag refs after sync).
@ -86,17 +60,18 @@ const normalizeRestoredTask = <T extends TaskEntity | Task>(
t: T,
state: RootState,
): T => {
// Reassign stale projectId to INBOX
// Reassign a deleted projectId to INBOX. Archived projects remain valid
// task owners and must survive reducer replay.
let projectId = t.projectId;
if (projectId && !state[PROJECT_FEATURE_NAME].entities[projectId]) {
projectId = state[PROJECT_FEATURE_NAME].entities[INBOX_PROJECT.id]
if (projectId && !getProjectOrUndefined(state, projectId)) {
projectId = getProjectOrUndefined(state, INBOX_PROJECT.id)
? INBOX_PROJECT.id
: undefined;
}
// Strip stale tagIds and TODAY_TAG (must never be in task.tagIds)
const tagIds = (t.tagIds || []).filter(
(tagId) => tagId !== TODAY_TAG.id && !!state[TAG_FEATURE_NAME].entities[tagId],
(tagId) => tagId !== TODAY_TAG.id && !!getTagOrUndefined(state, tagId),
);
// Clear stale repeatCfgId (only present on full Task, not minimal TaskEntity)
@ -115,26 +90,92 @@ const handleRestoreTask = (
task: TaskEntity,
subTasks: Task[],
): RootState => {
if (Object.prototype.hasOwnProperty.call(Object.prototype, task.id)) return state;
// A replayed restore is idempotent when the root is already active. NgRx
// addMany would otherwise ignore the root but add payload children as
// orphans the root's subTaskIds never references.
if (state[TASK_FEATURE_NAME].entities[task.id]?.id === task.id) return state;
// Normalize stale refs before adding to active state
const restoredTask = normalizeRestoredTask(
const normalizedRestoredTask = normalizeRestoredTask(
{ ...task, isDone: false, doneOn: undefined },
state,
);
const restoredSubTasks = subTasks.map((st) => normalizeRestoredTask(st, state));
const restoredTask = {
...normalizedRestoredTask,
projectId: normalizedRestoredTask.projectId ?? '',
};
const declaredSubTaskIds = new Set(restoredTask.subTaskIds ?? []);
const restoredSubTasks = subTasks
.filter((subTask) => {
const existingSubTask = state[TASK_FEATURE_NAME].entities[subTask.id];
const belongsToRestoredTask =
subTask.parentId === restoredTask.id ||
(!subTask.parentId && declaredSubTaskIds.has(subTask.id));
return (
belongsToRestoredTask &&
(!existingSubTask || existingSubTask.parentId === restoredTask.id)
);
})
.map((subTask) => ({
...normalizeRestoredTask(subTask, state),
parentId: restoredTask.id,
projectId: restoredTask.projectId,
}));
const updatedTaskState = taskAdapter.addMany(
[restoredTask as Task, ...restoredSubTasks],
state[TASK_FEATURE_NAME],
);
let updatedState = {
let updatedState: RootState = {
...state,
[TASK_FEATURE_NAME]: updatedTaskState,
};
// Adopt reverse-linked children (already active with parentId → root but
// missing from the payload/root's subTaskIds) so the restored parent and
// its children end up in one project with a two-sided relationship.
const restoreCandidateIds = collectTaskAndSubTaskIds(
updatedState,
[restoredTask.id],
[...(restoredTask.subTaskIds ?? []), ...restoredSubTasks.map((st) => st.id)],
);
const verifiedSubTaskIds = restoreCandidateIds.filter(
(id) =>
id !== restoredTask.id &&
updatedState[TASK_FEATURE_NAME].entities[id]?.parentId === restoredTask.id,
);
const normalizedSubTaskIds = unique(verifiedSubTaskIds);
const restoredTaskIds = [restoredTask.id, ...normalizedSubTaskIds];
const restoredTaskUpdates: Update<Task>[] = normalizedSubTaskIds.map((id) => ({
id,
changes: { projectId: restoredTask.projectId },
}));
if (
normalizedSubTaskIds.length !== (restoredTask.subTaskIds ?? []).length ||
normalizedSubTaskIds.some(
(id, index) => id !== (restoredTask.subTaskIds ?? [])[index],
)
) {
restoredTaskUpdates.push({
id: restoredTask.id,
changes: { subTaskIds: normalizedSubTaskIds },
});
}
updatedState = {
...updatedState,
[TASK_FEATURE_NAME]: taskAdapter.updateMany(
restoredTaskUpdates,
updatedState[TASK_FEATURE_NAME],
),
};
updatedState = removeTasksFromAllProjects(updatedState, restoredTaskIds);
// Update project
if (restoredTask.projectId) {
const project = state[PROJECT_FEATURE_NAME].entities[restoredTask.projectId];
const project = getProjectOrUndefined(updatedState, restoredTask.projectId);
if (project) {
updatedState = {
...updatedState,
@ -152,20 +193,20 @@ const handleRestoreTask = (
}
// Update tags
const allTasks = [restoredTask, ...restoredSubTasks];
const tagTaskMap = allTasks.reduce(
(map, t) => {
(t.tagIds || []).forEach((tagId) => {
if (!map[tagId]) map[tagId] = [];
map[tagId].push(t.id);
});
return map;
},
{} as Record<string, string[]>,
);
const allTasks = restoredTaskIds
.map((id) => updatedState[TASK_FEATURE_NAME].entities[id])
.filter((restoredEntity): restoredEntity is Task => !!restoredEntity);
const tagTaskMap = new Map<string, string[]>();
for (const restoredEntity of allTasks) {
for (const tagId of restoredEntity.tagIds ?? []) {
const taskIds = tagTaskMap.get(tagId) ?? [];
taskIds.push(restoredEntity.id);
tagTaskMap.set(tagId, taskIds);
}
}
const tagUpdates = Object.entries(tagTaskMap)
.filter(([tagId]) => state[TAG_FEATURE_NAME].entities[tagId])
const tagUpdates = Array.from(tagTaskMap.entries())
.filter(([tagId]) => getTagOrUndefined(state, tagId))
.map(
([tagId, taskIds]): Update<Tag> => ({
id: tagId,

View file

@ -254,15 +254,28 @@ export const TaskSharedActions = createActionGroup({
}),
// Task Updates
updateTask: (taskProps: { task: Update<Task>; isIgnoreShortSyntax?: boolean }) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityId: taskProps.task.id as string,
opType: OpType.Update,
} satisfies PersistentActionMeta,
}),
updateTask: (taskProps: {
task: Update<Task>;
isIgnoreShortSyntax?: boolean;
projectMoveSubTaskIds?: string[];
}) => {
const entityId = taskProps.task.id as string;
return {
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityId,
...(taskProps.projectMoveSubTaskIds !== undefined && {
entityIds: Array.from(
new Set([entityId, ...taskProps.projectMoveSubTaskIds]),
),
}),
opType: OpType.Update,
} satisfies PersistentActionMeta,
};
},
// Bulk task update - creates single operation instead of N operations
// Critical for repeating task config updates that affect many archived instances