| name | cloud-sync-module |
| description | Write, review or debug a `cloudSyncModule.ts` — a `CloudSyncDataManager` that syncs one slice of
user data through Ledger Sync (Cloud Sync). Covers the contract, its invariants, the mandatory
contract test, the wiring checklist and the data-loss traps.
Use for any work on `**/cloudSyncModule.ts`, `createAggregator`, or `@shared/cloud-sync-module`.
|
Authoring a cloudSyncModule.ts
A module reconciles LocalState (what the app holds, from Redux) with DistantState (what is
stored encrypted in Cloud Sync). createAggregator composes every module into one blob shared by all
of the user's instances (LWD, LWM, web-tools).
Concepts: 05-wallet-sync-data-manager,
then 06-watch-loop for when each hook fires. Steps and a
video: cookbook. Testing
layers: test-strategy. Contract:
@shared/cloud-sync-module. Copy
account-name (simplest);
accounts for async + injected ctx;
recent-addresses for the
tolerant-list schema only — its .transform() use is a known bug (see below).
The three hooks
| Hook | Sync? | Runs | Must return |
|---|
diffLocalToDistant(local, latest) | sync | every loop tick (~10s) before push | { hasChanges, nextState } — what to push |
resolveIncrementalUpdate(local, latest, incoming) | async | on pull and every tick with incoming === latest | { hasChanges: false } or { hasChanges: true, update } |
applyUpdate(local, update) | sync | only when the above reported changes | the new LocalState (pure, no mutation) |
Live under domain/entity/<name>/src/cloudSyncModule.ts beside schema.ts + slice.ts (see
domain/entity/README.md). Type it
CloudSyncDataManager<LocalState, Update, typeof schema>, with hasChanges: false as const so the
union narrows. A 4th generic DistantState defaults to z.infer<Schema> — pass the raw wire type
explicitly if your schema transforms, since that is what the hooks actually receive.
Invariants — break one and the loop misbehaves
resolveIncrementalUpdate short-circuits on incoming == null, on incoming === latest, and on
incoming being content-equal to local. The reference check alone only covers
makeLocalIncrementalUpdate, which passes one object as both args every tick; every pull hands you
a freshly decrypted one
(incrementalUpdates.ts,
makeSaveNewUpdate). Without the content check, an unchanged pull dispatches → re-renders → pushes,
forever.
diffLocalToDistant(emptyLocal, null) → hasChanges: false, or every user who never touched
the feature creates Cloud Sync data on first launch.
- Stability: re-diffing against your own
nextState yields hasChanges: false. No Date.now(),
no random ids, no unstable key order.
- Round-trip: the comparator must ignore any field
applyUpdate cannot reproduce exactly
(recent-addresses stores lastUsed but never compares it — comparing it would loop forever).
- Convergence: after
applyUpdate from an incoming state, diffing against that state reports no
changes.
- Purity: LocalState comes from Redux and is Immer-frozen. Never mutate — return new objects.
Risks to evaluate before writing a line
- Wiping every instance's data. Persist LocalState with the same lifetime as
walletSyncState.version (exportWalletState / importWalletState, both apps). Otherwise a restart
finds the version "up-to-date", never pulls, and diffLocalToDistant(empty, latest) pushes an empty
state that deletes the data everywhere.
- A too-strict schema kills sync, or overwrites data.
CloudSyncSDK parses the whole aggregate on
every pull and push, so one module rejecting another version's payload stops sync for accounts,
names, everything. On the persisted path parseDistantState
(walletSyncComposition.ts) safeParses
and returns null instead — which the loop reads as "no distant state" and pushes over. Stay
permissive: optional fields, z.unknown() + safeParse-and-filter for lists, never .strict().
- A
.transform()'s output is discarded in production. Both call sites above do parse — the
transform executes — then pass the raw JSON on, to preserve unknown fields. Hooks therefore get
un-normalised data typed as the transform's output, a lie TypeScript will not catch; normalise in the
hooks instead. The contract suite won't catch it either: it only ever replays states your own
diffLocalToDistant produced (the idempotence case runs one back through schema.parse()), never
the legacy payload an older version actually wrote.
- No migrations, ever. The wire format is permanent and shared across app versions: add optional
fields only, never rename, repurpose or require a key. Rebuilding
nextState from scratch
(account-name, recent-addresses) is safe only while your subtree is a total function of LocalState —
once it can carry a field you don't own, apply your diff on top of latest as accounts does, or you
silently delete the other version's data. recent-addresses already violates this: it drops the
ensName its own schema declares.
- Payload and privacy. One blob holds every module: store the minimum needed to restore the state
(descriptors, ids), nothing refetchable, no raw device/user ids (
client-ids).
- Async blocks the loop.
resolveIncrementalUpdate runs inside the pull's atomic lock. Network
work must be batched and recoverable — see accounts' + backoff queue.
Tests
describeCloudSyncModuleContract is mandatory, but a floor rather than a proof. It covers
invariants 1-3 and 5, JSON serialisability, a 1MB ceiling, and a 5ms average over 100 calls — for
diffLocalToDistant and applyUpdate only. It skips invariant 4 and resolveIncrementalUpdate's
cost, and wraps its convergence and applyUpdate cases in if (result.hasChanges), so a module that
always answers hasChanges: false passes them silently.
import { describeCloudSyncModuleContract } from "@shared/cloud-sync-module/moduleRequirements";
describeCloudSyncModuleContract("<name>SyncModule contract", <name>SyncModule, {
emptyLocalState,
nonEmptyLocalState,
matchingDistantState,
});
Then add module-specific cases:
| Target | Cover |
|---|
schema | valid payload · garbage/unknown entries tolerated, not thrown · fields written by a future version survive |
diffLocalToDistant | null latest · exact match · added · modified · removed · fields it must ignore (invariant 4) |
resolveIncrementalUpdate | null incoming · same reference · same content, different reference · real change · the raw payload rather than schema.parse()'s output (the only way to catch the transform trap) |
applyUpdate | expected state · does not mutate localData · is reached at all — assert hasChanges: true explicitly instead of trusting the contract suite |
Run pnpm --filter @domain/entity-<name> test; copy a sibling entity package's jest config
(testEnvironment: "node", roots: ["<rootDir>/src"]).
Wiring checklist
Registration is where the module becomes real — and where the persistence risk above lives.
walletSyncComposition.ts — add the key to
createAggregator({...}) and to the hand-maintained walletSyncSchema (they are duplicated;
miss the second and your key silently vanishes from WalletSyncDistantState).
- Reducers — LWD
wallet.core.ts,
LWM wallet.ts: reducer,
ExportedWalletState, exportWalletState, walletStateExportShouldDiffer, importWalletState.
useWatchWalletSync.ts — LWD,
LWM:
add the key to localStateSelector and dispatch it in save.
AppAccountsSync.tsx — a
third full registration site (its own createAggregator, AggLocalState, localStateSelector).
It is also the only place to verify by hand, with several
web-tools tabs as separate
instances.
- Changeset (
create-changeset) and a line in
scenarios.md.