mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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.
484 lines
14 KiB
TypeScript
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,
|
|
}),
|
|
},
|
|
});
|