Commit graph

3 commits

Author SHA1 Message Date
Johannes Millan
d0b7bda01a
fix(sync): heal missing idle config field on schema-migration upgrade (#9124)
A v18.14 snapshot (schema v2) lacks globalConfig.idle.isSuppressIdleDuring
FocusMode, which #8965 added as a required field without its own schema bump.
Upgrading bumps the snapshot onto the v2->v4 migration path, whose validation
gate — uniquely, the only validator that runs on the RAW snapshot before the
loadAllData reducer can backfill defaults, and the only one that is fatal
rather than repair-or-tolerate — rejects it. Hydration then aborts, recovery
refuses because a snapshot still exists, and the app boots to an empty store,
deterministically, every launch.

Two fixes:
- v2->v3 migration backfills the opt-in default (false) when the field is not
  already a boolean (never clobbering a real user choice), so the migrated
  snapshot validates and a clean v4 snapshot persists immediately.
- The migration-path state-validation gate is now non-fatal: on failure it
  rolls the on-disk cache back to the pre-migration backup (never persisting an
  unvalidated snapshot) but returns the migrated snapshot for reducer-healed
  hydration, instead of throwing into disaster recovery. Genuinely corrupt
  state is still caught at Checkpoint C and not persisted. Metadata-validation
  failures remain fatal.

Both paths are sabotage-tested to fail without their fix. A known follow-up
(noted in code) can persist a fresh snapshot after any migration-then-valid
hydration so the safety-net path converges in one boot instead of re-migrating
each launch for a not-yet-backfilled field.
2026-07-17 18:35:49 +02:00
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
51bf689bd5
fix(sync): preserve multi-entity conflict recovery (#8990)
* chore: add project-scoped Angular MCP

* chore: update npm for release-age policy

* fix(sync): preserve LWW outcomes across clients

Distinguish replacement snapshots from partial merge operations, protect device-local sync configuration, recreate winning deletes, and resolve every conflicted entity in bulk operations.

Fixes #8956

* fix(sync): harden mixed LWW conflict replay

Preserve unaffected remote and local bulk intents, keep device-local sync settings during replacement replay, and gate replacement semantics behind schema v3.

* fix(sync): recover subtask subtree and harden LWW replay follow-ups

Follow-up hardening for #8956 after multi-agent review:

- Recreate a locally-winning parent's subtasks when a remote bulk delete
  is a mixed winner. The full remote delete is applied (cascade-deleting
  the parent's subtasks via handleDeleteTasks) but only the parent had a
  compensation op, so the subtree was silently lost across devices.
- extractUpdateChanges: scan array-valued payload props instead of guessing
  `${payloadKey}s`, so irregular bulk keys (e.g. taskUpdates) no longer
  return {} and drop a remote winner's changes.
- Degrade gracefully instead of throwing when a remote update wins over a
  local delete with no reconstructable base entity, matching the
  single-entity path (a permanent sync wedge is worse than the bounded
  divergence it already accepts).
- Remove dead code (unused `deleting` set + zero-caller wrapper), use the
  Set-based scoped-bulk-delete filter, type meta via LwwUpdateMode, and
  restore the withLocalOnlySyncSettings rationale comment.

Adds regression tests for the subtree-recovery and irregular-bulk-key paths.

* fix(sync): preserve conflict outcomes during replay

Persist replacement LWW operations and bulk-delete snapshots so reconstructed state matches the result applied live. Bump the op-log DB version to prevent older clients from opening the incompatible schema.

* fix(sync): persist conflict outcomes atomically

Write remote losers, local compensations, and final remote winners in one IndexedDB transaction so crashes cannot expose a partial replay order. Add real-store coverage for live/replay equivalence and transaction rollback.

* fix(sync): preserve multi-entity conflict recovery

* fix(sync): close review gaps in multi-entity conflict recovery

- recreate a winning parent's subtasks when a remote DELETE loses
  outright (single-entity or all-local-win bulk), so clients that
  applied the delete and status-blind hydration replay converge
- apply the combined resolution batch in durable seq order so a
  pending row reused from a prior failed attempt replays identically
  live and after a crash
- restamp converted remote updates carrying the v3 replacement
  envelope to the current schema version
- strip the virtual TODAY tag from LWW task payloads and shallow-merge
  patch-mode singleton payloads instead of replacing feature state
- pin the server snapshot fast-path spec to CURRENT_SCHEMA_VERSION
  (fixes the CI failure from the v2-to-v3 bump)

* test(sync): pin outright-losing delete convergence across clients

Three-way real-reducer/real-store convergence for the pure-loser path
(live == restart replay == originating client), covering both the
same-batch recreate exemption and the cross-batch recreate path.
Verified to fail against the pre-fix service.
2026-07-14 14:10:52 +02:00