Authoring a cloudSyncModule.ts
SkillDev toolsWrite, 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`.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Authoring a cloudSyncModule.ts skill
What this skill tells your AI
The instructions your AI receives, as published by ledgerhq/ledger-live in .agents/skills/cloud-sync-module/SKILL.md and read by ahel’s review.
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
resolveIncrementalUpdateshort-circuits onincoming == null, onincoming === latest, and onincomingbeing content-equal to local. The reference check alone only coversmakeLocalIncrementalUpdate, 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
nextStateyieldshasChanges: false. NoDate.now(), no random ids, no unstable key order. - Round-trip: the comparator must ignore any field
applyUpdatecannot reproduce exactly (recent-addresses storeslastUsedbut never compares it — comparing it would loop forever). - Convergence: after
applyUpdatefrom 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, anddiffLocalToDistant(empty, latest)pushes an empty state that deletes the data everywhere. - A too-strict schema kills sync, or overwrites data.
CloudSyncSDKparses 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 pathparseDistantState(walletSyncComposition.ts)safeParses and returnsnullinstead — 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 owndiffLocalToDistantproduced (the idempotence case runs one back throughschema.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
nextStatefrom 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 oflatestas accounts does, or you silently delete the other version's data. recent-addresses already violates this: it drops theensNameits 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.
resolveIncrementalUpdateruns inside the pull's atomic lock. Network work must be batched and recoverable — see accounts'nonImportedAccountInfos+ backoff queue. - Injected dependencies? The interface has no ctx: export a
bindCtx(ctx)factory, as accounts does.
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.
// src/__tests__/cloudSyncModule.test.ts
import { describeCloudSyncModuleContract } from "@shared/cloud-sync-module/moduleRequirements";
describeCloudSyncModuleContract("<name>SyncModule contract", <name>SyncModule, {
emptyLocalState,
nonEmptyLocalState,
matchingDistantState, // optional — always provide it, it unlocks 2 extra checks
});
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 tocreateAggregator({...})and to the hand-maintainedwalletSyncSchema(they are duplicated; miss the second and your key silently vanishes fromWalletSyncDistantState).- Reducers — LWD
wallet.core.ts, LWMwallet.ts: reducer,ExportedWalletState,exportWalletState,walletStateExportShouldDiffer,importWalletState. useWatchWalletSync.ts— LWD, LWM: add the key tolocalStateSelectorand dispatch it insave.AppAccountsSync.tsx— a third full registration site (its owncreateAggregator,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 inscenarios.md.
Signals
- GitHub stars
- 618
- Forks
- 490
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
cloud-sync-module- Source
- github.com/ledgerhq/ledger-live