super-productivity/packages/sync-core
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
..
src fix(sync): preserve multi-entity conflict recovery (#8990) 2026-07-14 14:10:52 +02:00
tests fix(sync): preserve multi-entity conflict recovery (#8990) 2026-07-14 14:10:52 +02:00
.gitignore build: ignore files 2026-05-13 19:59:02 +02:00
package.json chore(deps): bump vitest from 3.2.4 to 4.1.6 (#7687) 2026-05-20 12:50:58 +02:00
README.md refactor(sync): post-extraction review cleanup of @sp/sync-core and @sp/sync-providers (#7595) 2026-05-14 13:06:08 +02:00
tsconfig.build.json refactor(sync): post-extraction review cleanup of @sp/sync-core and @sp/sync-providers (#7595) 2026-05-14 13:06:08 +02:00
tsconfig.json refactor(sync): post-extraction review cleanup of @sp/sync-core and @sp/sync-providers (#7595) 2026-05-14 13:06:08 +02:00
tsconfig.spec.json feat: migrate to capacitor 8 2026-05-23 20:42:00 +02:00
tsup.config.ts refactor(sync): post-extraction review cleanup of @sp/sync-core and @sp/sync-providers (#7595) 2026-05-14 13:06:08 +02:00
vitest.config.ts refactor(sync): move vector clocks to sync-core 2026-05-11 15:21:08 +02:00

@sp/sync-core

Framework-agnostic primitives for the Super Productivity sync engine: operation-log types, vector clocks, conflict resolution, gzip compression, and end-to-end encryption. Consumed by the main app and the SuperSync server; no Angular/Electron/Capacitor dependencies.

Encryption

The encryption layer provides Argon2id key derivation and AES-256-GCM authenticated encryption, with a WebCrypto path and an @noble/ciphers fallback for environments where crypto.subtle is unavailable (notably Android Capacitor on http://localhost).

import {
  encrypt,
  decrypt,
  encryptBatch,
  decryptBatch,
  clearSessionKeyCache,
  setLegacyKdfWarningHandler,
} from '@sp/sync-core';

const cipher = await encrypt('hello', password);
const plain = await decrypt(cipher, password);

Wire format (public contract)

Format Bytes
Argon2id [SALT (16)] [IV (12)] [AES-GCM ciphertext + auth tag (>= 16)]
Legacy [IV (12)] [AES-GCM ciphertext + auth tag (>= 16)]

All ciphertexts are base64-encoded for transport. The format is discriminated by length: < 28 bytes is invalid, < 44 bytes is unambiguously legacy, >= 44 bytes is treated as Argon2id with a legacy fallback on auth failure. Do not change this without a versioning migration.

Salt and IV semantics

  • The IV (12 bytes) is freshly random per call. AES-GCM security under a fixed key reduces to IV uniqueness, which this guarantees.
  • The salt (16 bytes) is derived once per (process session, password) pair and reused across every encrypt/encryptBatch call in that session. This is intentional — it lets the session cache amortize the ~500 ms2 s Argon2id derivation. Two encryptions of the same plaintext within a session therefore share the salt prefix and differ only in IV and ciphertext. Do not assert per-call salt uniqueness in tests.

Session key caching

encrypt/decrypt/encryptBatch/decryptBatch all share three in-memory caches (encrypt key, decrypt key by salt, legacy PBKDF2 key) that survive across sync cycles. Argon2id derivation is expensive (~5002000 ms on mobile with the default 64 MiB / 3 iterations); the cache turns repeated syncs from minutes into seconds.

Call clearSessionKeyCache() whenever the user changes their password or logs out. Keys live in memory only and are never persisted.

Legacy-KDF migration

Old data was encrypted with PBKDF2 using the password as its own salt — cryptographically weak. decrypt() and decryptBatch() still read legacy ciphertexts so existing sync data remains accessible.

setLegacyKdfWarningHandler(fn) registers a callback fired on every successful legacy decrypt, regardless of which entry point was used. The host throttles user-facing messages (e.g. show a deprecation banner once per session).

Argon2id parameters

Defaults are OWASP 2023 mobile guidance (parallelism: 1, iterations: 3, memorySize: 64 MiB). Tests can weaken them via setArgon2ParamsForTesting({ ... }) — this throws when called with NODE_ENV === 'production' in Node bundles. Restore defaults by calling with no argument.

Other exports

  • OpType, Operation, VectorClock and friends — op-log primitive types
  • compareVectorClocks, mergeVectorClocks, limitVectorClockSize — clock algebra
  • classifyOpAgainstSyncImport — full-state-import op disposition
  • createSyncFilePrefixHelpers — host-configured file prefix codec
  • compressWithGzip, decompressGzipFromString — gzip helpers
  • replayOperationBatch, applyRemoteOperations — replay and apply coordinators
  • planRegularOpsAfterFullStateUpload, planSnapshotHydration, etc. — sync planning

See src/index.ts for the full barrel and the JSDoc on individual symbols for usage.

Tests

npm test           # typecheck specs + vitest run, Node WebCrypto + @noble fallback
npm run test:watch # watch mode
npm run build      # tsup -> ESM + CJS + .d.ts

Browser-context smoke coverage lives in the consuming app at src/app/op-log/encryption/encryption.browser.spec.ts (Karma + real Chrome).