/*! * Copyright (c) 2026 Interop Alliance. All rights reserved. */ /** * `SyncEngine` -- drives one `(replica, collection)` feed through the pull/push * cycle. Single-flight (concurrent `sync()` calls coalesce and set a rerun * flag), migrate-once (the initial pull-before-migrate ordering that keeps * envelope minting from creating server-side duplicates), and self-healing via * exponential backoff + jitter on failure. * * All side effects are injected ({@link SyncEngineDeps}) so the engine runs * anywhere -- browser, Node, or React Native -- with a fake port, an in-memory * store, and a non-firing scheduler. The consuming app wires the real port, * DocCipher, provisioning, and lazy migration. */ import type { SyncStatus } from '@interop/was-client/sync'; import type { Json, ResolveConflict, SyncStore, WasSyncPort } from './types.js'; /** * Everything the engine needs, injected. The pure protocol ({@link WasSyncPort}, * {@link SyncStore}) plus the app-supplied seams: provisioning, the * migrated/last-synced stamps, decryption, and status/refetch callbacks. The * `schedule` / `random` seams make backoff deterministic under test. */ export interface SyncEngineDeps { port: WasSyncPort; store: SyncStore; /** * Decrypts a pulled body to its plaintext payload (DocCipher). `id` is the * resource id the replica read the body under, and the cipher verifies the * envelope was sealed for it -- an envelope addressed to some other * resource is refused with was-client's `IntegrityError`. */ decryptDoc: (options: { id: string; envelope: Json; }) => Promise; /** * The collection's payload guard, when it has one: a pulled document that * decrypts but fails it is stored without being projected (see `pull.ts`). */ validatePayload?: (payload: Json) => boolean; /** * The 412 policy for a mutable (LWW) collection. Absent for insert-only * content-addressed collections, whose built-in push settlement covers every * conflict. */ resolveConflict?: ResolveConflict; batchSize?: number; /** * Idempotent space + collection provisioning. For an encrypted collection * this MUST include publishing the collection's encryption descriptor with * its key-epoch roster (the wallet Space two-step: `provisionWalletSpace` * creates the collections bare, then `ensureWalletSpaceEpochs` declares * each encrypted one and lands its epoch[0], as the genesis of the * collection's own governing history log; `walletSpaceProvisioner` in * `@interop/wallet-core/keys` builds the closure that runs both). The * engine runs it ahead of every cycle's migration sweep and push, which is * what enforces the descriptor-before-first-content-push ordering * invariant: no envelope reaches the feed sealed under an epoch the * published descriptor does not carry. * * The engine memoizes it: once a call has resolved, later cycles skip the * seam until {@link SyncEngine.invalidateProvisioning} is called, so the * ordering stays structural on every cycle while only the first cycle pays * the descriptor round trip. A call that throws is not memoized, and the * next cycle runs it again. The caller invalidates whenever the account's * provisioning state can have changed under the replica: an unlock with a * fresh key set, a re-bind to a different account pointer, or a recovery. */ ensureProvisioned: () => Promise; /** * The eager minter's create-loss re-mint, run on EVERY cycle between * provisioning and the migration sweep, so no pending envelope sealed under * a losing epoch reaches the push: the consumer builds its cipher from the * descriptor provisioning settled on and runs `remintPendingEnvelopes` * (`remint.ts`) with it. The re-mint decides per row from the envelope * itself, so in the settled case the call is free. A lazy minter, whose * envelopes are always minted under the settled descriptor, leaves it * absent. */ remintPending?: (signal: AbortSignal) => Promise; /** * Has this feed's lazy migration already run (per-collection milestone)? */ isMigrated: () => Promise; /** * Mint bodies for this feed's still-unlinked local rows. */ runLazyMigration: (signal: AbortSignal) => Promise; /** * Record this feed's migrated milestone after a successful first migration. */ stampMigrated: () => Promise; /** * Stamp the replica's last-synced time after a successful cycle. */ stampLastSynced: () => Promise; /** * Called on every status transition (drives the app's state layer). */ onStatusChange?: (status: SyncStatus) => void; /** * Called at the end of a cycle whose pulls applied >= 1 document in total * (triggers the refetch). A first cycle pulls twice -- before the migration * sweep and after the push -- and both counts feed this. Not called when the * cycle unwinds early on an abort. */ onPullApplied?: () => void; backoff?: { baseDelayMs?: number; maxDelayMs?: number; }; /** * Schedules a retry; returns a canceller. Defaults to setTimeout. */ schedule?: (fn: () => void, delayMs: number) => () => void; /** * Jitter source in [0, 1). Defaults to Math.random. */ random?: () => number; } export declare class SyncEngine { private readonly deps; status: SyncStatus; private readonly batchSize; private readonly baseDelayMs; private readonly maxDelayMs; private readonly schedule; private readonly random; private running; private rerunRequested; private stopped; private failureCount; private currentRun; private abortController; private cancelRetry; private provisioned; private provisioningGeneration; constructor(deps: SyncEngineDeps); /** * Requests a sync. Single-flight: if a cycle is in flight this only flags a * rerun (so writes that land mid-cycle are not lost) and resolves with the * in-flight run; otherwise it starts a fresh run. Never rejects -- failures * settle into `status = 'error'` plus a scheduled backoff retry, per the * local-first invariant (sync must never surface as a rejected write). * * @returns {Promise} */ sync(): Promise; /** * Forgets that provisioning was observed complete, so the next cycle runs * the `ensureProvisioned` seam again. Call it whenever the account's * provisioning state can have changed under this replica (a fresh key set, * a re-bound account pointer, a recovery); the memo is otherwise held for * the engine's life. */ invalidateProvisioning(): void; /** * Stops the engine: aborts any in-flight cycle (the injected signal unwinds * pull/push between pages/rows), cancels a pending retry, and resets to idle. * The caller drops the cached agents/ciphers so key material does not outlive * the unlocked session. */ stop(): void; private run; /** * One full replication cycle. On the very first run (never migrated) it pulls * before the sweep so existing local rows hash-link to any bodies already on * the server -- the sweep then only encrypts genuinely-new records * (re-encrypting an existing one would mint a different content id and leave a * permanent server duplicate). The unlinked-record sweep (`runLazyMigration`) * runs on EVERY cycle, not just the first, so records that enter the replica * outside the synced write path -- an import, or a write whose minting failed * and fell back to a plain insert -- are still picked up and pushed (it is a * cheap no-op when there are none). Steady state is sweep-then-push-then-pull: * our own writes echo back in the same cycle's pull, idempotently. * * `ensureProvisioned` runs first (memoized once it has resolved, see its doc * on {@link SyncEngineDeps}): everything that mints or pushes envelopes is * downstream of it, which is what enforces the * descriptor-before-first-content-push invariant. The optional * `remintPending` runs right after it, ahead of the sweep and the push, so * an eager minter's envelopes sealed under a losing epoch are re-minted * under the settled descriptor before anything reaches the feed. */ private runCycle; private pull; private scheduleRetry; private clearRetry; private setStatus; } //# sourceMappingURL=engine.d.ts.map