* 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. |
||
|---|---|---|
| .. | ||
| src | ||
| tests | ||
| .gitignore | ||
| package.json | ||
| README.md | ||
| tsconfig.build.json | ||
| tsconfig.json | ||
| tsconfig.spec.json | ||
| tsup.config.ts | ||
| vitest.config.ts | ||
@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 everyencrypt/encryptBatchcall in that session. This is intentional — it lets the session cache amortize the ~500 ms–2 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 (~500–2000 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,VectorClockand friends — op-log primitive typescompareVectorClocks,mergeVectorClocks,limitVectorClockSize— clock algebraclassifyOpAgainstSyncImport— full-state-import op dispositioncreateSyncFilePrefixHelpers— host-configured file prefix codeccompressWithGzip,decompressGzipFromString— gzip helpersreplayOperationBatch,applyRemoteOperations— replay and apply coordinatorsplanRegularOpsAfterFullStateUpload,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).