super-productivity/src/app/root-store/meta/task-shared.actions.ts
Johannes Millan 8e810edbe7
fix(sync): make marked project deletions win LWW conflicts (#9009)
deleteProject cascade-deletes a project's tasks, notes, sections, repeat
config, and archive data in one reducer pass. When that op lost an LWW
conflict to a concurrent project edit, only the PROJECT entity was
reversed: every client resurrected an empty project and the winning
client's status-blind hydration replay cascaded its tasks away after a
restart (live state != post-restart replay).

Rather than recreate every cascaded entity (payload scales with project
size and cannot restore every side effect safely), give schema-v4
deleteProject operations explicit delete-wins precedence:

- new deleteProject actions carry a shared PROJECT_DELETE_WINS_MARKER; the
  shared LWW planner accepts a host-supplied delete-wins classifier. A
  marked remote delete is applied regardless of timestamps; a marked local
  delete is replaced with one op whose vector clock dominates both sides.
- historical unmarked (schema-v3) deletions keep timestamp-based LWW; the
  absence of the marker (never added by the no-op v3->v4 migration) is the
  real discriminator, and a schema v3->v4 barrier (mirroring v2->v3) makes
  older clients block on the newer-schema gate instead of mis-resolving.

Delete-wins plans reuse the archive-win resolution pipeline, so they
inherit its atomic persistence and losing-op rejection, and disjoint
merge leaves them untouched (the delete must win the whole entity).

Hardening from multi-agent review:

- union allTaskIds/noteIds across multiple concurrent marked deletes for
  the same project, so a single replacement cannot leave orphan tasks on
  clients that only receive it (the task reducer removes by allTaskIds).
- gate the classifier on the AUTHENTICATED payload projectId matching the
  plaintext entityId, so a tampered/replayed delete retargeted onto a live
  entity cannot silently drop a concurrent edit.
- guard a null/undefined delete payload in the classifier instead of
  throwing and wedging the conflict pass.
- pin the server's legacy-misc conflict alias to the fixed v1->v2 split
  boundary, not CURRENT_SCHEMA_VERSION, so this bump does not fabricate
  false GLOBAL_CONFIG:misc/tasks conflicts during rollout.
- bind the marker with a shared const (compiler-checked on producer and
  consumer) and rename _isArchivePlan -> _isWholeEntityWinPlan.

Documents the policy as ARCHITECTURE-DECISIONS.md #7.

Addresses #8997.
2026-07-14 19:58:33 +02:00

484 lines
14 KiB
TypeScript

import { createActionGroup } from '@ngrx/store';
import { Update } from '@ngrx/entity';
import { Task, TaskWithSubTasks } from '../../features/tasks/task.model';
import { IssueDataReduced } from '../../features/issue/issue.model';
import { WorkContextType } from '../../features/work-context/work-context.model';
import { BatchOperation } from '@super-productivity/plugin-api';
import { PersistentActionMeta } from '../../op-log/core/persistent-action.interface';
import { OpType } from '../../op-log/core/operation.types';
/**
* Payload marker stamped on every new `deleteProject` operation so the LWW
* conflict planner can give it delete-wins precedence. Shared with the sync
* classifier (`conflict-resolution.service.ts`) so a rename is compiler-checked
* on both ends — dropping/renaming it silently would regress to resurrecting an
* empty project (see ARCHITECTURE-DECISIONS.md #7).
*/
export const PROJECT_DELETE_WINS_MARKER = 'projectDeleteWins';
/**
* Shared actions that affect multiple reducers (tasks, projects, tags)
* These actions are handled by the task-shared meta-reducer
*/
export const TaskSharedActions = createActionGroup({
source: 'Task Shared',
events: {
// Task Management
addTask: (taskProps: {
task: Task;
issue?: IssueDataReduced;
workContextId: string;
workContextType: WorkContextType;
isAddToBacklog: boolean;
isAddToBottom: boolean;
isIgnoreShortSyntax?: boolean;
autoPlanToday?: string;
autoPlanStartOfNextDayDiffMs?: number;
isExampleTask?: boolean;
}) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityId: taskProps.task.id,
opType: OpType.Create,
} satisfies PersistentActionMeta,
}),
convertToMainTask: (taskProps: {
task: Task;
parentTagIds?: string[];
isPlanForToday?: boolean;
afterTaskId?: string | null;
isDone?: boolean;
today?: string;
doneOn?: number;
modified?: number;
}) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityId: taskProps.task.id,
opType: OpType.Update,
} satisfies PersistentActionMeta,
}),
convertToSubTask: (taskProps: {
taskId: string;
targetParentId: string;
afterTaskId: string | null;
}) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityId: taskProps.taskId,
opType: OpType.Update,
} satisfies PersistentActionMeta,
}),
deleteTask: (taskProps: { task: TaskWithSubTasks }) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityId: taskProps.task.id,
opType: OpType.Delete,
} satisfies PersistentActionMeta,
}),
// Issue metadata for remote issue deletion still travels through
// DeletedTaskIssueSidecarService. Task snapshots are persisted separately
// so a concurrent winning update can recreate an entity after this delete.
deleteTasks: (taskProps: { taskIds: string[]; tasks?: Task[] }) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityIds: taskProps.taskIds,
opType: OpType.Delete,
isBulk: true,
} satisfies PersistentActionMeta,
}),
// TODO rename to `moveTaskToArchive__` to indicate it should not be called directly
// Note: Full task payload is required for sync reliability.
// Remote clients need task data to write to their local archive.
// See docs/archive-operation-redesign.md for detailed analysis.
moveToArchive: (taskProps: { tasks: TaskWithSubTasks[] }) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityIds: taskProps.tasks.map((t) => t.id),
opType: OpType.Update,
isBulk: true,
} satisfies PersistentActionMeta,
}),
restoreTask: (taskProps: { task: Task | TaskWithSubTasks; subTasks: Task[] }) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityId: taskProps.task.id,
opType: OpType.Update,
} satisfies PersistentActionMeta,
}),
// Restore a deleted task (undo delete) - syncs across devices
restoreDeletedTask: (payload: {
task: TaskWithSubTasks;
projectContext?: {
projectId: string;
taskIdsForProject: string[];
taskIdsForProjectBacklog: string[];
};
parentContext?: {
parentTaskId: string;
subTaskIds: string[];
};
tagTaskIdMap: Record<string, string[]>;
deletedTaskEntities: Record<string, Task | undefined>;
}) => ({
...payload,
meta: {
isPersistent: true,
entityType: 'TASK',
entityId: payload.task.id,
opType: OpType.Update,
} satisfies PersistentActionMeta,
}),
// Task Scheduling
scheduleTaskWithTime: (taskProps: {
task: Task;
dueWithTime: number;
remindAt?: number;
isMoveToBacklog: boolean;
isSkipAutoRemoveFromToday?: boolean;
}) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityId: taskProps.task.id,
opType: OpType.Update,
} satisfies PersistentActionMeta,
}),
reScheduleTaskWithTime: (taskProps: {
task: Task;
dueWithTime: number;
remindAt?: number;
isMoveToBacklog: boolean;
isSkipAutoRemoveFromToday?: boolean;
}) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityId: taskProps.task.id,
opType: OpType.Update,
} satisfies PersistentActionMeta,
}),
unscheduleTask: (taskProps: {
id: string;
isSkipToast?: boolean;
isLeaveInToday?: boolean;
today?: string;
}) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityId: taskProps.id,
opType: OpType.Update,
} satisfies PersistentActionMeta,
}),
dismissReminderOnly: (taskProps: { id: string }) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityId: taskProps.id,
opType: OpType.Update,
} satisfies PersistentActionMeta,
}),
// Task Deadlines
setDeadline: (taskProps: {
taskId: string;
deadlineDay?: string;
deadlineWithTime?: number;
deadlineRemindAt?: number;
autoPlanToday?: string;
autoPlanStartOfNextDayDiffMs?: number;
}) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityId: taskProps.taskId,
opType: OpType.Update,
} satisfies PersistentActionMeta,
}),
planDeadlineTasksForToday: (taskProps: {
taskIds: string[];
today: string;
startOfNextDayDiffMs: number;
}) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityIds: taskProps.taskIds,
opType: OpType.Update,
isBulk: true,
} satisfies PersistentActionMeta,
}),
removeDeadline: (taskProps: { taskId: string }) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityId: taskProps.taskId,
opType: OpType.Update,
} satisfies PersistentActionMeta,
}),
clearDeadlineReminder: (taskProps: { taskId: string }) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityId: taskProps.taskId,
opType: OpType.Update,
} satisfies PersistentActionMeta,
}),
// Task Updates
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
updateTasks: (taskProps: { tasks: Update<Task>[] }) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityIds: taskProps.tasks.map((t) => t.id as string),
opType: OpType.Update,
isBulk: true,
} satisfies PersistentActionMeta,
}),
// Project Management
moveToOtherProject: (taskProps: {
task: TaskWithSubTasks;
targetProjectId: string;
}) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityId: taskProps.task.id,
opType: OpType.Update,
} satisfies PersistentActionMeta,
}),
deleteProject: (taskProps: {
projectId: string;
noteIds: string[];
allTaskIds: string[];
}) => ({
...taskProps,
[PROJECT_DELETE_WINS_MARKER]: true as const,
meta: {
isPersistent: true,
entityType: 'PROJECT',
entityId: taskProps.projectId,
opType: OpType.Delete,
} satisfies PersistentActionMeta,
}),
// Today Tag Management
planTasksForToday: (taskProps: {
taskIds: string[];
// The logical day "today" referred to when the action was created.
// Optional only for legacy operation replay and older tests.
today?: string;
startOfNextDayDiffMs?: number;
parentTaskMap?: { [taskId: string]: string | undefined };
isShowSnack?: boolean;
isSkipRemoveReminder?: boolean;
isClearScheduledTime?: boolean;
}) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityIds: taskProps.taskIds,
opType: OpType.Update,
isBulk: true,
} satisfies PersistentActionMeta,
}),
removeTasksFromTodayTag: (taskProps: { taskIds: string[] }) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityIds: taskProps.taskIds,
opType: OpType.Update,
isBulk: true,
} satisfies PersistentActionMeta,
}),
// Non-persistent variant for automated day-change overdue removal.
// Every device runs removeOverdueFormToday$ independently, so syncing this
// operation is redundant and causes LWW conflicts with user actions on other
// devices (see #6992). The meta-reducer handles it identically to
// removeTasksFromTodayTag but the operation log ignores it.
localRemoveOverdueFromToday: (taskProps: { taskIds: string[] }) => ({
...taskProps,
}),
// Tag Management
addTagToTask: (props: { tagId: string; taskId: string }) => ({
...props,
meta: {
isPersistent: true,
entityType: 'TASK',
entityIds: [props.taskId, props.tagId],
opType: OpType.Update,
} satisfies PersistentActionMeta,
}),
removeTagsForAllTasks: (taskProps: { tagIdsToRemove: string[] }) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TAG',
entityIds: taskProps.tagIdsToRemove,
opType: OpType.Update,
isBulk: true,
} satisfies PersistentActionMeta,
}),
moveTaskInTodayTagList: (taskProps: { toTaskId: string; fromTaskId: string }) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'TASK',
entityIds: [taskProps.toTaskId, taskProps.fromTaskId],
opType: OpType.Move,
isBulk: true,
} satisfies PersistentActionMeta,
}),
// Batch Operations
batchUpdateForProject: (taskProps: {
projectId: string;
operations: BatchOperation[];
createdTaskIds: { [tempId: string]: string };
/** Captured before dispatch so replay creates identical task state. */
createdTaskTimestamp?: number;
}) => ({
...taskProps,
meta: {
isPersistent: true,
entityType: 'PROJECT',
entityId: taskProps.projectId,
opType: OpType.Batch,
} satisfies PersistentActionMeta,
}),
// Issue Provider Management
deleteIssueProvider: (props: {
issueProviderId: string;
taskIdsToUnlink: string[];
}) => ({
...props,
meta: {
isPersistent: true,
entityType: 'ISSUE_PROVIDER',
entityId: props.issueProviderId,
opType: OpType.Delete,
} satisfies PersistentActionMeta,
}),
deleteIssueProviders: (props: { ids: string[]; taskIdsToUnlink: string[] }) => ({
...props,
meta: {
isPersistent: true,
entityType: 'ISSUE_PROVIDER',
entityIds: props.ids,
opType: OpType.Delete,
isBulk: true,
} satisfies PersistentActionMeta,
}),
// Task Repeat Config Management
deleteTaskRepeatCfg: (props: { taskRepeatCfgId: string }) => ({
...props,
meta: {
isPersistent: true,
entityType: 'TASK_REPEAT_CFG',
entityId: props.taskRepeatCfgId,
opType: OpType.Delete,
} satisfies PersistentActionMeta,
}),
// Short Syntax - Atomic compound action
// Combines task updates, project moves, and scheduling into one atomic operation
applyShortSyntax: (props: {
task: Task;
taskChanges: Partial<Task>;
targetProjectId?: string;
schedulingInfo?: {
day?: string;
isAddToTop?: boolean;
dueWithTime?: number;
remindAt?: number | null;
isMoveToBacklog?: boolean;
};
autoPlanToday?: string;
autoPlanStartOfNextDayDiffMs?: number;
}) => ({
...props,
meta: {
isPersistent: true,
entityType: 'TASK',
entityId: props.task.id,
opType: OpType.Update,
} satisfies PersistentActionMeta,
}),
},
});