/** * vapor-chamber — Offline outbox * * Queue commands durably while offline, replay them in order on reconnect, * and deduplicate server-side via the Idempotency-Key header. * * The outbox composes three existing primitives into one headline feature: * - the `idempotent()` key convention (`commandKey(action, target)` stamped * onto `cmd.meta.idempotencyKey`), * - the HTTP bridge, which forwards `meta.idempotencyKey` as an * `Idempotency-Key` header so the backend can reject duplicate writes, * - the `persist()` storage style for durable, SSR-safe queue snapshots. * * Install the plugin OUTERMOST (higher priority than `idempotent` and the * transport) so offline commands are captured before any wire work happens. */ import type { AsyncCommandBus, AsyncPlugin, Command } from './command-bus'; import type { Signal } from './signal'; /** * A queued command awaiting replay. JSON-serializable by design — `target` and * `payload` round-trip through the storage adapter, so keep them plain data * (no functions, no DOM nodes) for actions routed through the outbox. */ export type OutboxRecord = { /** Unique record ID (distinct from the command's `meta.id`, which is re-stamped on replay). */ id: string; /** The command's action name. */ action: string; /** The command's target, exactly as dispatched. */ target: any; /** The command's payload, exactly as dispatched. */ payload?: any; /** * The idempotency key derived at ENQUEUE time. Replays stamp this original * key onto `cmd.meta.idempotencyKey`, so the backend sees the same * `Idempotency-Key` header for every delivery attempt of this command. */ key: string; /** Date.now() at enqueue time. */ queuedAt: number; }; /** * Durable storage for the outbox queue. All methods may be sync or async — * the outbox awaits them either way. `load()` returns `null` when nothing is * persisted (or the backing store is unavailable, e.g. SSR). */ export type OutboxStorage = { load(): Promise | OutboxRecord[] | null; save(records: OutboxRecord[]): Promise | void; clear(): Promise | void; }; /** * localStorageOutbox — default storage adapter: the whole queue as one JSON * value in `localStorage`. SSR-safe: when `localStorage` is unavailable, * `load()` returns null and `save()`/`clear()` are no-ops (with a console * warning on save failure, e.g. quota exceeded). * * @param storageKey Storage key. Default: `'vc:outbox'`. * * @example * const outbox = createOutbox({ storage: localStorageOutbox('vc:cart-outbox') }); */ export declare function localStorageOutbox(storageKey?: string): OutboxStorage; /** * indexedDbOutbox — zero-dependency IndexedDB adapter. Stores the whole queue * as one value under a fixed key, so reads and writes are single-transaction * and atomic. Prefer this over `localStorageOutbox` when queued payloads are * large (localStorage has a ~5 MB origin quota and synchronous I/O). * * SSR-safe: the database is opened lazily on first use; when `indexedDB` is * unavailable, `load()` resolves to null and `save()`/`clear()` warn and no-op. * * @param dbName Database name. Default: `'vc-outbox'`. * @param storeName Object store name. Default: `'records'`. * * @example * const outbox = createOutbox({ storage: indexedDbOutbox() }); * await outbox.hydrate(); */ export declare function indexedDbOutbox(dbName?: string, storeName?: string): OutboxStorage; export type OutboxOptions = { /** Which actions to capture. Glob patterns supported: '*', 'cart*'. Default: all. */ actions?: string[]; /** Durable queue storage. Default: `localStorageOutbox()`. */ storage?: OutboxStorage; /** * Connectivity probe, checked per dispatch and per replay step. * Default: `navigator.onLine` when a navigator exists, otherwise `true` (SSR). */ isOnline?: () => boolean; /** * Listen for the window `'online'` event and `flush()` automatically once a * bus ref exists (via `install()`). Default: true. No timers, no polling — * the listener is removed by `dispose()`. */ autoFlush?: boolean; /** * Derive the idempotency key stored on each record and replayed to the * backend. Default: `commandKey(action, target)` — the same convention the * `idempotent()` plugin uses, so both layers agree on what "the same * logical command" means. */ key?: (cmd: Command) => string; /** * Max queued records — bounded memory. When exceeded, the OLDEST record is * dropped with a console warning. Default: 200. */ maxQueue?: number; }; /** The object returned by {@link createOutbox}. */ export type Outbox = { /** * The outbox plugin. Install OUTERMOST — before `idempotent()` and the * transport — so offline commands are captured before any wire work: * `bus.use(outbox.plugin, { priority: 200 })`. */ plugin: AsyncPlugin; /** Convenience: `bus.use(plugin, { priority: 200 })` + keep the bus ref for `flush()` / auto-flush. */ install(bus: AsyncCommandBus): void; /** * Replay the queue sequentially (strict FIFO). Each record is re-dispatched * through the full pipeline with its ORIGINAL idempotency key stamped on * `cmd.meta.idempotencyKey`, so the HTTP bridge sends the same * `Idempotency-Key` the backend may have already seen. The first failed * replay stops the flush — that record and everything after it stay queued. * Re-entrant calls join the in-progress flush. */ flush(bus?: AsyncCommandBus): Promise<{ replayed: number; failed: number; }>; /** Reactive queue depth — bindable in templates ("3 changes pending sync"). */ pending: Signal; /** Load the persisted queue from storage. Call once at startup, before the first flush. */ hydrate(): Promise; /** Drop every queued record (memory + storage). */ clear(): Promise; /** Remove the window `'online'` listener. Idempotent. */ dispose(): void; }; /** * createOutbox — offline outbox: queue commands durably while offline, replay * them in order on reconnect, deduplicate server-side via Idempotency-Key. * * While offline (or while queued records exist — later commands must not * overtake earlier ones), matching dispatches are intercepted before the * transport: the command is recorded, persisted, stamped with an idempotency * key, and resolved as `{ ok: true, value: { queued: true, id } }`. The * `'outboxQueued'` bus event fires with the record. On reconnect (window * `'online'` event, or a manual `flush()`), records replay sequentially * through the full pipeline with `meta.origin = 'replay'` and their original * idempotency keys, so the backend can reject any duplicate it already * applied. `'outboxFlushed'` fires with the `{ replayed, failed }` summary. * * Failure-safe: the first failed replay (an `ok: false` result or a throw) * stops the flush and keeps that record plus everything behind it queued — * order is never reshuffled, and the next flush retries from the same spot. * * SSR-safe: no window/navigator access at module load; the `'online'` * listener is only attached when a window exists and is removed by `dispose()`. * * @example * const bus = createAsyncCommandBus(); * const outbox = createOutbox({ actions: ['cart*', 'order*'] }); * outbox.install(bus); // outermost (priority 200) * bus.use(idempotent({ actions: ['order*'] }), { priority: 100 }); * bus.use(createHttpBridge({ endpoint: '/api/vc', csrf: true })); * * await outbox.hydrate(); // restore a previous session's queue * const result = await bus.dispatch('cartAdd', { id: 1 }, { qty: 2 }); * if (result.ok && result.value?.queued) { * toast(`Saved offline — ${outbox.pending.value} pending`); * } * // back online: the 'online' event flushes automatically (autoFlush: true) */ export declare function createOutbox(options?: OutboxOptions): Outbox; //# sourceMappingURL=outbox.d.ts.map