Commit graph

8 commits

Author SHA1 Message Date
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
Johannes Millan
46e98c67dd
fix(sync): harden passkey registration verification (#8985)
* fix(sync): preserve offline ops and harden auth

* fix(sync): prevent unverified passkey replacement

Keep existing credentials until email ownership is verified and scope failed-delivery cleanup to the exact pending registration. Reject negative operation timestamps while still allowing old offline operations.\n\nRefs #8961

* test(sync): cover registration races in PostgreSQL

* ci(sync): run PostgreSQL integration tests

* fix(sync): bind passkeys to email verification
2026-07-13 22:58:37 +02:00
Johannes Millan
329da9b9f3
fix(sync): harden full-state operation recovery (#8973)
* fix(sync): prevent destructive full-state sync races

Use causal ordering and server-sequence preconditions for full-state operations, surface snapshot rejection, deduplicate migrations, and retain queued WebSocket downloads.\n\nFixes #8959

* fix(sync): block uploads after rejected imports

Keep incremental operations behind a durable barrier when a local import or restore is rejected. Release the barrier only after a newer full-state snapshot is accepted, and surface the blocked state instead of reporting sync success.

* fix(sync): harden causal repair recovery

Persist repair base cursors across the client, provider, and server so stale snapshots are rejected before quota or history mutation and legacy repairs cannot poison restore state.

Atomically rebase stale repairs after downloading the missing suffix, preserve rejected-import barriers and WebSocket watermarks, and cover PostgreSQL serialization.

* fix(sync): harden repair recovery edge cases

Block dependent uploads after any rejected full-state operation, reset negotiated capabilities across provider config changes, and bound WebSocket download retries.

Update sync test doubles for causal repairs and the expanded duplicate-operation identity.
2026-07-13 22:58:17 +02:00
Johannes Millan
eff41c041d
feat(project): project completion experience (#8036)
* feat(project): add project completion with celebration + trophy view

Completing a project marks it done and archives it, with a celebration
dialog (confetti + live stats) and a prompt to resolve unfinished tasks
(move to Inbox / mark done). Completed projects show a trophy badge and
a Reopen action on the archived-projects page.

isDone stays distinct from isArchived; selectArchivedProjects is left
intact so completed projects' tasks stay filtered out of Today/Overdue.
Append/merge deferred to #8032. Plan: docs/plans/2026-06-05-project-completion.md

* refactor(project): drop Archive menu item; Complete is the retire path

Archive and Complete produced near-identical end states; collapse to one
user-facing action. Removes the 'Archive project' menu item and handler so
Complete is the single way to retire a project. The archiveProject action,
reducer and ProjectService.archive() stay (needed for op-log replay of
historical archive ops and the legacy unarchive/restore path). Wiki updated;
menu specs cover the Complete flow.

* fix(project): keep completion dialog open for the active project

MatDialog closeOnNavigation (default true) dismissed the celebration the
moment completing the currently-active project navigated to '/'. Navigate
first, then open the dialog.

* test(project): e2e for completion flow (complete, celebrate, reopen)

Covers the resolve-unfinished-tasks prompt, the celebration dialog with
stats, the trophy badge on the archived page, and reopening. Adds an
openProjectContextMenu page-object helper.

* feat(project): confirm project completion

* feat(project): show completion celebration fullscreen

* fix(project): harden completion celebration flow

* style(project): use spacing tokens in completion dialog

* fix(project): align completion screen with project context

* style(project): soften completion screen coloring

* style(project): reuse completion screen surfaces

* fix: refine project completion dialog actions

* fix: complete projects with atomic task resolution

* fix: restore archive path in project completion UI

* refactor(project): remove dead completion code

The project-level completeProject action (OpType.Update) was never
dispatched — completion goes exclusively through the atomic
TaskSharedActions.completeProject (OpType.Batch) meta-reducer. Drop the
dead action, its reducer case, the PROJECT_COMPLETE enum member and the
immutable 'PCO' op-log code (which would otherwise be permanently
reserved for an op that can never be produced); enum count 147->146.

Also remove the unused selectCompletedProjects / selectPlainArchivedProjects
selectors (no consumers) and the misspelled, unused 'angel' confetti
field, keeping the regression tests that guard selectArchivedProjects.

* fix(project): make project completion non-reversible

Completion resolves a project's open tasks (move-to-inbox / mark-done),
which reopen cannot truly restore, so the "Reopen"/"Undo" affordances on
the completion path were misleading. Drop the celebration dialog's Reopen
button and the post-complete undo snack; the fullscreen celebration is
the feedback and deliberate reactivation still lives on the
archived-projects page (Project.reopen kept for it).

Also restore the project title param on the archive confirm dialog (it
was rendering a raw {{title}} placeholder), remove the now-unused
moveTasksToInbox / markTasksDone resolution helpers (the meta-reducer
resolves tasks atomically), and drop the orphaned UNDO / S.COMPLETED
i18n keys. Updates the completion e2e to the close flow and asserts the
resolution props are forwarded to the atomic action.

* fix(tasks): cancel native reminders for project-completed tasks

Completing a project marks its unfinished tasks done inside the
meta-reducer (no per-task updateTask), so unscheduleDoneTask$'s
native-reminder cancellation is bypassed and an OS-scheduled Android
notification could still fire for a now-done task. Add a local-only
effect that cancels native reminders for the force-completed task ids.

Local-only by design: it dispatches no actions (the persistent
dismissReminderOnly/clearDeadlineReminder would each be an extra synced
op), and done tasks are already filtered from reminders$ on all
platforms — only the native Android notification needs explicit removal.

* test(project): drop TaskService spies orphaned by helper removal

getByIdWithSubTaskData$/moveToProject/setDone/setUnDone were only used
by the removed moveTasksToInbox/markTasksDone helpers and their deleted
tests.

* feat(sync): affectedEntities multi-entity conflict detection for atomic completion

WIP checkpoint of the atomic completeProject approach. The Batch op declares
every touched entity (PROJECT, INBOX, TASKs, TODAY_TAG) via a new
affectedEntities field threaded through op-log capture, conflict detection,
the sync server (+Prisma migration) and shared-schema. Per-effect
completeProject handlers (issue two-way-sync, time-block, repeat-cfg)
re-derive the task changes the atomic op bypasses.

* revert(sync): remove affectedEntities multi-entity conflict detection

Reverts 0893a86162. The affectedEntities feature existed solely to make the
atomic completeProject Batch op sync-correct (its only producer). Decoupling
project completion into normal per-task ops (next commit) makes the existing
per-entity conflict detection and effects fire naturally, so this entire
layer — sync-core, super-sync-server, shared-schema, op-log plumbing, the
Prisma migration, and the per-effect completeProject listeners — is no longer
needed. Preserved in history via the checkpoint commit.

* refactor(project): decouple completion from task resolution (Option C)

Completion was an atomic multi-entity Batch op (completeProject) that marked
tasks done / moved them to Inbox inside the project-shared meta-reducer.
Because it bypassed the normal per-task actions, every downstream consumer had
to be taught about it separately — conflict detection (the affectedEntities
feature, reverted in the previous commit), native-reminder cancellation, issue
two-way-sync, time-block and repeat-cfg effects.

Decouple instead: completion is now a plain single-entity PROJECT flag flip
(completeProject = OpType.Update, mirroring archiveProject). Unfinished-task
resolution runs first as the normal per-task actions (moveToOtherProject /
updateTask isDone) from the completion flow, so the existing effects and
per-entity conflict detection fire naturally — no special-casing anywhere.

- project.actions/reducer: restore plain completeProject action + on() handler
- project.service: complete() is a flag dispatch; restore moveTasksToInbox /
  markTasksDone (normal per-task dispatch + Rule #6 flush)
- work-context-menu: resolve unfinished work before the flag flip
- drop the completeProject meta-reducer block, the Batch action, the
  TASK_SHARED_COMPLETE_PROJECT op code, and the reminder-cancel effect
  (unscheduleDoneTask$ already cancels native reminders on the normal path);
  current-task clearing is covered by the existing task-internal effect

Net: ~190 LOC removed here on top of ~1565 (affectedEntities + a Prisma
migration) in the revert. Completion's task resolution is not undone either
way, so the atomic bundle never bought a clean reversal.

* docs(project): record decoupled-completion decision (ADR #5)

Document why project completion uses decoupled per-task resolution + a plain
single-entity flag flip instead of an atomic multi-entity op: the atomic op
forced a cross-stack affectedEntities conflict-detection feature and per-effect
listeners, for an undo guarantee it never delivered. Adds ARCHITECTURE-DECISIONS
#5 and a revision note + corrected undo/bulk-mechanic notes in the plan doc.

* test(project): pin completion ordering + resolution edge cases

Address multi-agent review of the decoupled-completion refactor:
- assert resolution (moveTasksToInbox / markTasksDone) runs BEFORE the
  completeProject flag flip (toHaveBeenCalledBefore) — the core invariant of
  the decoupled design that was previously not pinned
- cover the not-done branch of moveTasksToInbox (no setUnDone) and assert
  markTasksDone dispatches exactly the passed set
- add explicit PCO encode/decode round-trip assertions
- document the inbox-path current-task carry-forward nuance in ADR #5

Composition is covered end-to-end by e2e/tests/project/project-completion.spec.ts.

* refactor(project): tighten completion flow per review

- collapse 3x getCompletionInfo() to <=2: gate the resolve prompt once,
  recompute only after a resolution; each call now wrapped with an error
  snack so a failed archive load no longer aborts silently
- drop the dead post-confirm re-prompt branch (unreachable between two
  sequential single-user modals)
- reuse getDiffInDays + dateStrToUtcDate in completion-stats util instead
  of hand-rolled local-midnight/duration helpers
- use a Set for the top-level-task membership check (was O(n*m))
- drop redundant inline dialog sizing; the panelClass owns fullscreen
- remove dead --project-complete-accent test assertion

* refactor(project): finish review follow-ups for completion flow

- W3: reset the celebration confetti instance on dialog destroy so its
  rAF loop + window resize listener are torn down when the dialog closes
  before the animation ends (ConfettiService now returns the handle and
  fires without awaiting completion)
- S2: extract resolveBgImageToDataUrl() shared by app.component and the
  celebration dialog (was duplicated file://->data-url resolution)
- S3: split completeProject() into _getCompletionInfoOrNotify (dedupes the
  error handling), _promptResolveUnfinishedTasks and _confirmCompletion
- W2: keep prefers-reduced-motion gating app-wide (a11y) + document intent

* fix(project): close confetti teardown race on early dialog close

If the celebration dialog is dismissed while canvas-confetti is still
loading, the instance was assigned after ngOnDestroy ran, so reset() never
fired and the rAF loop + resize listener leaked. Guard with an _isDestroyed
flag and reset the instance immediately if it arrives post-destroy.

Also drop the now-dead CanvasConfetti type alias (superseded by
ConfettiInstance, zero references).

* docs(project): drop non-existent undo-snack from completion wiki

* refactor(project): extract completion task-tree and dialog helpers

* refactor(project): hide Archive menu item; Complete is the retire path

* refactor(project): drop dead archive(), reuse resolve-choice type

Multi-review follow-ups on the completion feature:
- Remove orphaned ProjectService.archive() (+ unused import, spec) — the
  menu collapsed Archive into Complete, leaving no caller. The
  archiveProject action/reducer stay for op-log decode of historical ops.
- Reuse the exported ResolveUnfinishedTasksChoice type instead of
  re-spelling the union three times in work-context-menu.
- Fix misleading moveTasksToInbox comment (setUnDone re-opens, not move).
- Note the as-shipped deviations (no extra selectors, no celebration
  effect) in the design plan so they aren't hunted for later.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-08 13:43:38 +02:00
Johannes Millan
2d9988dd73
perf(sync): SuperSync server speed + correctness hardening (#7621)
* docs(sync): add super sync server perf plan

* perf(sync): implement supersync server perf phases

* fix(sync): bracket auth cache invalidation

* fix(sync): avoid empty replay state stringify

* fix(sync): harden supersync batch uploads

* fix super sync review findings

* fix(sync): guard payload bytes backfill rollout

* perf(sync): speed up payload_bytes backfill and index its scan

Raise the backfill batch size (DEFAULT 5->500, MAX 25->1000) so a
100M-row operations table backfills in minutes rather than tens of
hours. Add a CONCURRENTLY partial index on (user_id, id) WHERE
payload_bytes = 0: it drains to empty post-backfill so the boot-time
backfill self-check and the BOOL_OR quota probe stop doing a full
sequential scan to prove absence, and it makes the backfill's per-user
keyset paging a true index seek. Wire the new concurrent-index
migration into both deploy scripts' P3018 recovery path. Add
migration-SQL guard tests for the ADD COLUMN (metadata-only fast path)
and the new partial index.

* fix(sync): bound auth cache invalidation map and bracket every delete

The auth verification cache's invalidationVersions map grew one entry
per lifetime-invalidated user with no eviction (unbounded heap on a
long-lived single replica). Cap it at the same 10k LRU bound as the
entries map, re-inserting the just-invalidated user at the MRU tail so
the CAS race protection still holds for the only window that matters
(one DB round trip). Bracket the passkey/magic-link registration
cleanup deletes with pre+post invalidate to match the documented
convention, and invalidate on verifyEmail so a freshly-verified user
isn't denied for up to the cache TTL.

* perf(sync): skip the redundant exact replay-state measurement

The delta accounting is a proven over-estimate of the serialized state
size, so when the running bound stays within the cap the true size is
too and the final exact JSON.stringify is provably redundant. Skip it
in that case (still measure-and-throw whenever the bound does not prove
safety). This collapses the common small/incremental replay back to
zero expensive full stringifications, matching the old per-op loop
instead of regressing it. Name the entity-key JSON overhead constant
and document that assertReplayStateSize's return value is load-bearing.

* refactor(sync): split processOperationBatch into pipeline stages

Extract the 297-line batch upload method into a thin orchestrator plus
six named single-responsibility stage helpers (validate+clamp, intra-
batch dedupe, classify existing duplicates, conflict-detect, reserve
seq + insert, full-state clock). Behavior-preserving: every stage
writes terminal rejections into the shared results array by index and
the two empty-set guards short-circuit exactly as before. Also share
the timestamp clamp, the duplicate-op SELECT, and the merged
full-state clock persistence between the batch and legacy paths so
they cannot silently diverge.

* test(sync): pin batch error-code divergence and aggregate-once

Strengthen the intra-batch duplicate test to assert same-id /
different-content yields DUPLICATE_OPERATION (deliberate divergence
from the legacy INVALID_OP_ID), and document the divergence in the
plan. Replace the single-full-state aggregate test with two
full-state ops + a spy asserting _aggregatePriorVectorClock runs
exactly once and last-write-wins — the old test could not catch a
per-op-aggregate regression. Add a makeOp fixture factory. Correct
the plan's overstated replay-stringification numbers.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: johannesjo <1456265+johannesjo@users.noreply.github.com>
2026-05-15 17:24:16 +02:00
Johannes Millan
5fa8260bb8 refactor(sync): finish package extraction polish 2026-05-13 14:11:15 +02:00
Johannes Millan
c70ced204e fix(sync): fix stale comments, doc refs, and preserve lastSeq on clean-slate
Remove references to deleted docs/ai/ files, update stale comments about
protectedClientIds and pruning-aware comparison to reflect current REPLACE
semantics, fix MAX=30→20 heading, client_0..29→19 comment, use ?? over ||,
preserve lastSeq in server clean-slate path to prevent sequence reuse, and
add clarifying comments for mock limitations and validation guards.
2026-02-12 16:27:56 +01:00
Johannes Millan
bfdac6d19a refactor(op-log): merge LWWOperationFactory into ConflictResolutionService
- Consolidate LWWOperationFactory methods (createLWWUpdateOp, mergeAndIncrementClocks)
  into ConflictResolutionService to reduce service count
- Remove redundant caching in SuperSyncProvider (SyncCredentialStore already caches)
- Remove _onBlurWhenNotTracking$ sync trigger from SyncTriggerService
- Add documentation for dueDay/dueWithTime mutual exclusivity pattern
- Change docker-compose postgres port to 55432 to avoid conflicts
2026-01-29 18:24:15 +01:00