super-productivity/packages/sync-core/src
Johannes Millan 8e9f7ba10a
fix(sync): accept legacy singleton LWW ops rejected as tampering (#9256) (#9294)
* test(migration): wait for migrated state persistence

* fix(sync): accept legacy singleton LWW operations

Treat payload IDs as canonical only for adapter-backed LWW targets so legacy time-tracking operations can replay without weakening task retarget protection.

* fix(sync): harden singleton LWW replay

Preserve compatibility payload IDs for composite singleton conflicts while keeping adapter IDs canonical. Ignore malformed non-record singleton payloads before they can overwrite state.

* fix(sync): harden malformed LWW payload handling

* fix(sync): scope malformed LWW array handling

Normalize legacy numeric-key singleton payloads while preserving valid array-backed map values such as planner days.

* fix(sync): drop unreproduced LWW array-spread guard; add lockstep test

Review follow-up for the singleton LWW replay fix (#9256).

The #9256 repro (issue log: op 019d1e73, TIME_TRACKING, entityId
PROJECT:eP8tBLmm0tBgJThAZOxcT:2026-03-24) shows the failing payload is a
well-formed { project, tag } record with payloadId "<undefined>" — the
core isLwwPayloadIdCanonical fix already handles it. There is NO
array-spread or non-record payload anywhere in the repro, so the
numeric-key "legacy-array-spread-record" detector had no reproduced
failure behind it and carried a latent footgun (it would zero any future
singleton with all-numeric top-level keys). Remove it, per the repo rule
"start from a reproducible problem":

- Drop isLegacyArraySpreadRecord / isCanonicalArrayIndexKey /
  MAX_ARRAY_INDEX from the converter; keep the footgun-free !isRecord
  normalization (a bare string/array payload still no-ops).
- Drop the producer's isMalformedSingletonState; basePayload again
  includes arrays, which preserves PLANNER (map) day-array spreading and
  normal singletons alike.
- Remove the two tests that only exercised the deleted guard.

Also (no behavior change):
- Add a converter test pinning that an op whose plaintext actionType is
  swapped to a singleton type lands as a whole-slice singleton replace
  (id stripped), never a TASK retarget — gate/converter/reducer branch
  on the same actionType in lockstep.
- Document the integrity gate's adapter-only scope, the compat-id
  sunset, and the un-migrated entityId==='*' check in sync-core.

* test(sync): add end-to-end recovery, back-compat & classification coverage

Confidence-raising tests for #9256 (no production-code change):

- operation-encryption: weld the full seam in one test — real AES-GCM
  decrypt -> convertOpToAction -> lwwUpdateMetaReducer — proving the
  reporter's legacy TIME_TRACKING op actually restores { project, tag }
  state, not merely that it "wasn't rejected".
- conflict-resolution: simulate the shipped v18.15.1 metadata-integrity
  gate (faithful copy, verified via git show) and assert the new
  producer's compatibility id makes old clients accept the op — and,
  non-vacuously, that stripping it reproduces the #9256 rejection.
- entity-registry: exhaustive table asserting isLwwPayloadIdCanonical is
  true for exactly the adapter entities across all 18 configs, guarding
  the misclassification that caused #9256.

* docs(sync): correct singleton-classification comments (#9256)

Review follow-up. Comment-only — no behavior change, verified by diff.

- sync-core convertLocalDeleteRemoteUpdatesToLww: the NOTE claimed
  "singletons never emit per-entity deletes". They do —
  menuTreeDeleteFolder emits MENU_TREE + OpType.Delete with a folderId
  entityId. The branch is still unreachable, but via a different guard:
  extractEntityFromPayload finds no base entity in that delete payload
  (no `menuTree` key, no id-matching array element, and the field is
  named `folderId`, not `id`). Name the real guard, since the stated one
  invites the exact refactor that would arm the line.

- SINGLETON_ENTITY_ID docstring: no shipped singleton producer emits '*'.
  GLOBAL_CONFIG addresses ops by section key, MENU_TREE by tree name /
  folderId, TIME_TRACKING by a composite TYPE🆔date key. Point readers
  at isLwwPayloadIdCanonical for the storage-pattern question.

- createLWWUpdateOp SUNSET note: the compat id covers every singleton,
  not just TIME_TRACKING, so the fleet-age cleanup is broader than the
  note said; the '*' else branch is unreachable in practice.

- validate-operation-payload: flag the third un-migrated
  isSingletonEntityId site (inert — warning-only, and LWW ops carry
  `entityChanges: []`) so it is not the one silent site left.

* test(sync): correct the non-vacuity note on the v18.15.1 gate sim (#9256)

Comment-only. The old note credited the second assertion for the test's
non-vacuity, but that one strips `id` from the test's own copy and re-runs
the test's own helper — it can only prove the simulated predicate still
discriminates.

The load-bearing assertion is the first: it fails when the producer stops
emitting the compat id (i.e. when `|| !isSingletonEntityId(entityId)` is
dropped from createLWWUpdateOp — the SUNSET cleanup performed too early),
which is exactly the regression this test exists to catch. Say so, and
label the second assertion as the guard on the simulation itself.
2026-07-24 21:59:17 +02:00
..
encryption fix(sync): harden encryption batching and SuperSync abort timer 2026-05-13 17:26:55 +02:00
apply.types.ts fix(sync): harden op-log replay and recovery (#8978) 2026-07-13 20:03:42 +02:00
compression.ts refactor(sync-core): extract compression helpers 2026-05-11 21:41:02 +02:00
conflict-resolution.ts fix(sync): accept legacy singleton LWW ops rejected as tampering (#9256) (#9294) 2026-07-24 21:59:17 +02:00
download-planning.ts refactor(sync): extract snapshot hydration planning 2026-05-12 00:52:50 +02:00
encryption.ts refactor(sync): post-extraction review cleanup of @sp/sync-core and @sp/sync-providers (#7595) 2026-05-14 13:06:08 +02:00
entity-frontier.ts refactor(sync): post-extraction review cleanup of @sp/sync-core and @sp/sync-providers (#7595) 2026-05-14 13:06:08 +02:00
entity-key.util.ts refactor(sync-core): prepare core boundary for providers 2026-05-12 16:37:12 +02:00
entity-registry.types.ts refactor(sync): post-extraction review cleanup of @sp/sync-core and @sp/sync-providers (#7595) 2026-05-14 13:06:08 +02:00
error.util.ts refactor(sync): extract sync file prefix helpers 2026-05-11 21:21:25 +02:00
full-state-op-types.ts refactor: remove dead SyncStateCorruptedError, compat exports, and 0 byte sync-providers barrel #8328 (#8396) 2026-06-15 14:16:14 +02:00
index.ts fix(sync): preserve multi-entity conflict recovery (#8990) 2026-07-14 14:10:52 +02:00
lww-update-action-types.ts refactor(sync-core): prepare core boundary for providers 2026-05-12 16:37:12 +02:00
operation.types.ts fix(sync): authenticate LWW project-move footprint (#9053) (#9054) 2026-07-15 21:52:06 +02:00
ports.ts fix(sync): harden op-log replay and recovery (#8978) 2026-07-13 20:03:42 +02:00
remote-apply.ts fix(sync): harden op-log replay and recovery (#8978) 2026-07-13 20:03:42 +02:00
replay-coordinator.ts fix(sync): harden op-log replay and recovery (#8978) 2026-07-13 20:03:42 +02:00
sync-file-prefix.ts refactor(sync): extract sync file prefix helpers 2026-05-11 21:21:25 +02:00
sync-import-filter.ts refactor(sync-core): extract sync import filter decision 2026-05-11 18:47:53 +02:00
sync-logger.ts refactor(sync): add sync-core boundary contracts 2026-05-11 10:09:15 +02:00
upload-planning.ts fix(sync): remediate sync-correctness review findings 2026-07-10 13:25:30 +02:00
vector-clock.ts fix(sync): keep import author in client-side clock pruning (#9096) (#9102) 2026-07-17 12:35:15 +02:00
web-crypto-error.ts refactor(sync-core): extract encryption primitives 2026-05-13 17:26:55 +02:00