@helipod/client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-DW55SNHW.js +286 -0
- package/dist/chunk-DW55SNHW.js.map +1 -0
- package/dist/chunk-I7ZJFW4E.js +2232 -0
- package/dist/chunk-I7ZJFW4E.js.map +1 -0
- package/dist/client-mJZjEkhK.d.ts +1144 -0
- package/dist/index.d.ts +268 -0
- package/dist/index.js +642 -0
- package/dist/index.js.map +1 -0
- package/dist/outbox-fs.d.ts +102 -0
- package/dist/outbox-fs.js +361 -0
- package/dist/outbox-fs.js.map +1 -0
- package/dist/outbox-storage-C6VHkXs9.d.ts +191 -0
- package/dist/react.d.ts +114 -0
- package/dist/react.js +139 -0
- package/dist/react.js.map +1 -0
- package/package.json +72 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/transport.ts","../src/headless-drain.ts","../src/auth-client.ts","../src/index.ts"],"sourcesContent":["/**\n * Transports carry the sync protocol between the client and the engine. The client logic is\n * transport-agnostic; pick `loopbackTransport` (in-process / embedded) or `webSocketTransport`\n * (over the network) — the same `HelipodClient` runs on either.\n */\nimport type { ClientMessage, ServerMessage } from \"@helipod/sync\";\n\nexport interface ClientTransport {\n send(message: ClientMessage): void;\n onMessage(listener: (msg: ServerMessage) => void): () => void;\n /** Fires when the transport closes or errors (so the client can fail pending work / resync). */\n onClose(listener: () => void): () => void;\n /**\n * Optional (T6): fires once per successful RECONNECT — never for the initial connect. A\n * transport that never reconnects (e.g. `loopbackTransport`) simply doesn't implement this; the\n * client treats it as absent (`transport.onReopen?.(...)`). Implemented by `webSocketTransport`\n * so the client can replay `SetAuth`, resubscribe every live query, and flush unsent mutations\n * against the fresh session.\n */\n onReopen?(listener: () => void): () => void;\n close(): void;\n}\n\n/** Anything shaped like an embedded loopback connection. */\nexport interface LoopbackLike {\n send(message: ClientMessage): unknown;\n onMessage(listener: (msg: ServerMessage) => void): () => void;\n close(): void;\n}\n\nexport function loopbackTransport(connection: LoopbackLike): ClientTransport {\n const closeListeners = new Set<() => void>();\n let closed = false;\n return {\n send(message) {\n if (!closed) void Promise.resolve(connection.send(message));\n },\n onMessage(listener) {\n return connection.onMessage(listener);\n },\n onClose(listener) {\n closeListeners.add(listener);\n return () => closeListeners.delete(listener);\n },\n close() {\n if (closed) return;\n closed = true;\n connection.close();\n for (const l of closeListeners) l();\n },\n };\n}\n\nexport interface WebSocketTransportOptions {\n /** Reconnect automatically after a disconnect, with exponential backoff + jitter. Default `true`\n * — `{ reconnect: false }` restores the old terminal-on-close behavior verbatim. */\n reconnect?: boolean;\n /** Base delay before the first reconnect attempt; doubles each subsequent attempt. Default `300`. */\n initialBackoffMs?: number;\n /** Reconnect backoff cap. Default `30_000` (~30s). */\n maxBackoffMs?: number;\n /** @internal test seam — how to construct the underlying `WebSocket`. Defaults to `new WebSocket(url)`. */\n createWebSocket?: (url: string) => WebSocket;\n}\n\n/**\n * Equal-jitter exponential backoff: half the exponential delay, plus up to another half at random\n * — never zero (avoids a thundering-herd reconnect storm) and monotone-capped at `maxBackoffMs`.\n * Exported so the schedule itself is directly unit-testable without simulating a WebSocket.\n */\nexport function reconnectDelayMs(attempt: number, initialBackoffMs: number, maxBackoffMs: number, rand: () => number = Math.random): number {\n const exp = Math.min(maxBackoffMs, initialBackoffMs * 2 ** attempt);\n const half = exp / 2;\n return half + rand() * half;\n}\n\n/**\n * WebSocket transport over the platform `WebSocket` (browsers, Node 22+, Bun). Reconnects by\n * default on disconnect — exponential backoff + jitter, capped ~30s (`{ reconnect: false }` opts\n * out, preserving the old terminal-on-close contract exactly). `onClose` fires once per disconnect\n * (so the client can run its close disposition — reject inflight, retain unsent, drop layers);\n * `onReopen` fires once per successful RECONNECT, never for the very first connect, so the client\n * knows when to replay `SetAuth`, resubscribe, and flush unsent mutations against the fresh session.\n */\nexport function webSocketTransport(url: string, opts: WebSocketTransportOptions = {}): ClientTransport {\n const reconnect = opts.reconnect ?? true;\n const initialBackoffMs = opts.initialBackoffMs ?? 300;\n const maxBackoffMs = opts.maxBackoffMs ?? 30_000;\n const createWebSocket = opts.createWebSocket ?? ((u: string) => new WebSocket(u));\n\n const listeners = new Set<(msg: ServerMessage) => void>();\n const closeListeners = new Set<() => void>();\n const reopenListeners = new Set<() => void>();\n // Only buffers frames sent before the transport's very FIRST socket has ever opened (normal\n // connection-establishment latency), or — with `{reconnect: false}` — a terminal transport's\n // manual-first-open window. It must NEVER accumulate frames sent during a down period AFTER a\n // socket has already opened once: see the `send()` guard below for why.\n const queue: ClientMessage[] = [];\n\n let ws: WebSocket;\n let open = false;\n let everOpened = false; // true once ANY socket has opened — makes the next open a \"reopen\"\n // A connection ATTEMPT died before ever opening (e.g. the client was constructed offline, or the\n // first connect raced a network blip). Its pre-first-open buffered frames were dropped by\n // `announceClose`, so when a socket finally DOES open, the client must run its full reconnect\n // sequence (SetAuth replay, resubscribe, the outbox `Connect` handshake) to rebuild the session\n // from client state — exactly as it would after a mid-session drop. Without this, a reload client\n // whose initial connect failed would open silently, never re-send its subscriptions or `Connect`,\n // and (for a durable outbox) never arm or drain. So the first open after a failed attempt fires\n // `onReopen` too, even though `everOpened` is still false.\n let hadFailedConnect = false;\n let announced = false; // a close was already told to listeners since the last open\n let terminated = false; // `.close()` was called, or reconnect is disabled and the socket died\n let attempt = 0;\n let reconnectTimer: ReturnType<typeof setTimeout> | undefined;\n\n const clearReconnectTimer = (): void => {\n if (reconnectTimer !== undefined) {\n clearTimeout(reconnectTimer);\n reconnectTimer = undefined;\n }\n };\n\n const announceClose = (): void => {\n if (announced) return;\n announced = true;\n // Belt-and-suspenders: `send()` below already refuses to enqueue once `everOpened && reconnect`,\n // so this is normally a no-op by the time it runs. It stays as the backstop for the one case\n // where the queue can legitimately hold something at this point — the very first connection\n // attempt closing/erroring before ever opening — so nothing stale lingers into a later cycle.\n queue.length = 0;\n for (const l of closeListeners) l();\n };\n\n const handleDisconnect = (): void => {\n open = false;\n // A socket that dies without ever having opened is a failed connection attempt — the next open\n // must be treated as a reopen (see `hadFailedConnect`). Guarded by `!open`-at-this-point isn't\n // enough; key off `everOpened` (no socket has EVER opened yet).\n if (!everOpened) hadFailedConnect = true;\n announceClose();\n if (terminated || !reconnect) {\n terminated = true;\n return;\n }\n const delay = reconnectDelayMs(attempt, initialBackoffMs, maxBackoffMs);\n attempt++;\n clearReconnectTimer();\n reconnectTimer = setTimeout(() => {\n reconnectTimer = undefined;\n if (terminated) return;\n ws = createWebSocket(url);\n wire(ws);\n }, delay);\n (reconnectTimer as { unref?: () => void }).unref?.();\n };\n\n function wire(socket: WebSocket): void {\n socket.addEventListener(\"open\", () => {\n if (terminated) return;\n open = true;\n announced = false;\n attempt = 0;\n for (const m of queue) socket.send(JSON.stringify(m));\n queue.length = 0;\n if (everOpened || hadFailedConnect) {\n for (const l of reopenListeners) l();\n }\n hadFailedConnect = false;\n everOpened = true;\n });\n socket.addEventListener(\"message\", (ev: MessageEvent) => {\n if (typeof ev.data !== \"string\") return;\n const msg = JSON.parse(ev.data) as ServerMessage;\n for (const l of listeners) l(msg);\n });\n socket.addEventListener(\"close\", handleDisconnect);\n socket.addEventListener(\"error\", handleDisconnect);\n }\n\n ws = createWebSocket(url);\n wire(ws);\n\n return {\n send(message) {\n if (terminated) return; // never throw after a terminal close\n if (open) {\n ws.send(JSON.stringify(message));\n return;\n }\n // THE RULE: once a socket has opened at least once and reconnect is enabled, a NEW session is\n // reconstructed ENTIRELY from client state by the reopen sequence (SetAuth replay,\n // resubscribe-from-live-queries, unsent-mutation flush) — see `client.ts#onTransportReopened`.\n // A frame sent here, while down between sessions, is stale by definition: whatever it\n // represents (a subscribe/unsubscribe against the live-query map, a SetAuth token, an\n // ephemeral publish) is either re-derived by that sequence or is correctly lossy (ephemeral).\n // Queueing it would let it land on the fresh session ahead of — and duplicating/pre-empting —\n // the reopen sequence, so it's dropped instead of buffered. Pre-first-open buffering (normal\n // connection latency) and `{reconnect: false}`'s terminal buffering are untouched: this branch\n // only applies once `everOpened && reconnect`.\n if (everOpened && reconnect) return;\n queue.push(message);\n },\n onMessage(listener) {\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n onClose(listener) {\n closeListeners.add(listener);\n return () => closeListeners.delete(listener);\n },\n onReopen(listener) {\n reopenListeners.add(listener);\n return () => reopenListeners.delete(listener);\n },\n close() {\n terminated = true;\n clearReconnectTimer();\n announceClose(); // synchronous, regardless of whether the underlying socket already fired \"close\"\n try {\n ws.close();\n } catch {\n /* already closing */\n }\n },\n };\n}\n","/**\n * `drainOutboxOnce` — the headless one-shot outbox drain (spec Part B, \"The Background Sync seam\").\n * A Service Worker (or any UI-less context) can drain the durable queue: one exported function, no\n * `HelipodClient`, no queries, no optimistic layers. Chromium's one-shot Background Sync then\n * becomes a documented recipe ON TOP of this (`docs/enduser/offline.md`) — a progressive\n * enhancement on the drain TRIGGER, never the durability story: the portable baseline stays the IDB\n * queue + drain-on-next-visit; this function only improves WHEN the drain runs.\n *\n * Composition (one refactor, zero duplication of the state machine):\n * - `webSocketTransport(opts.url)` — already SW-compatible (the global `WebSocket`, no DOM deps).\n * - The SAME `Connect`-handshake helpers `HelipodClient` uses (`./connect-handshake`), fed from\n * the durable STORE instead of a live in-memory log (`outboxHeldFromStore`).\n * - A ~60-line store-only {@link DrainHost}: no rendering, no promises, no optimistic layer —\n * `addHydrated` collects into a local array (not `client.ts`'s reactive `MutationLog`);\n * `settleApplied` just dequeues; `settleTerminal` just marks `\"failed\"`; `whenBaselineAdopted`\n * resolves immediately (there are no live queries to re-baseline — the `expectTransition: false`\n * shape `client.ts#beginBaselineAwait` already has a branch for).\n * - The SAME exported {@link OutboxDrain} (`./outbox-drain`) — identity gate, poison policy,\n * chunking, transient backoff are all reused unchanged, under the SAME deployment-scoped Web\n * Locks name `client.ts` uses (`helipod:outbox:<origin>:<deployment>`) — a live tab already\n * draining makes this call an immediate, cheap no-op (`{drained: 0, failed: 0, remaining}` via a\n * non-blocking `ifAvailable` probe): \"locks are efficiency, not correctness\" holds here too.\n *\n * Unlike `HelipodClient`, this function does NOT implement the `known: true` per-seq classifying\n * settle (`client.ts#settleVerdict`) — it doesn't need to: every held seq gets resent regardless via\n * the normal drain flush, and the server's exact-match receipts dedup a resend of an already-settled\n * seq into a harmless replay-ack (`MutationResponse{replayed:true}` for a prior success, or the SAME\n * recorded `code` for a prior terminal failure) — the whole point of the Receipted Outbox. The ONE\n * thing that genuinely needs store-level handling is `known: false` (a swept/foreign timeline): a\n * blind resend under the SAME (now-disowned) identity risks nothing, but re-presenting an entry\n * whose fate is genuinely UNKNOWN (a persisted `\"parked\"` row — durable + in-flight when some prior\n * live tab's connection dropped) under a FRESH clientId could double-apply if it secretly already\n * committed — so `\"parked\"` rows terminal-fail loudly (`OFFLINE_CLIENT_RESET`) exactly as\n * `client.ts#onClientReset` does for a live park, while `\"unsent\"` rows (never sent, safe either\n * way) simply re-enqueue under the fresh identity, store-level (dequeue the old row, append the new).\n */\nimport type { ServerMessage } from \"@helipod/sync\";\nimport type { PendingMutation } from \"./mutation-log\";\nimport { buildConnectMessage, outboxHeldFromStore } from \"./connect-handshake\";\nimport { OutboxDrain, dropIfNonReplayable, type DrainHost, type OutboxLockManager, type PoisonPolicy } from \"./outbox-drain\";\nimport { type ClientTransport, webSocketTransport } from \"./transport\";\nimport { OUTBOX_VERSION, defaultMintClientId, indexedDBOutbox, type OutboxEntry, type OutboxStorage } from \"./outbox-storage\";\nimport { sha256Hex, sessionFingerprintKey } from \"./identity-fingerprint\";\n\nexport interface HeadlessDrainOptions {\n /** The ws(s) sync endpoint. Ignored when `_transport` is injected (tests). */\n url: string;\n /** Defaults to `indexedDBOutbox()` — IndexedDB exists in a Service Worker just as it does in a\n * tab, so the SAME durable queue a live client wrote is readable here with no extra plumbing. */\n outbox?: OutboxStorage;\n /** Distinguishes the drain's Web Locks name per deployment, matching a live client's own\n * `outboxDeployment` constructor option (`client.ts:320`'s naming) — MUST agree with whatever a\n * live tab configured, or the two will never contend for the same lock. Defaults to `\"default\"`. */\n deployment?: string;\n /** SW-readable auth (the app owns SW-readable token storage — this function only documents the\n * constraint, it does not build one). Replayed as `SetAuth` BEFORE `Connect`, exactly as a\n * reconnecting `HelipodClient` replays its last-set token. */\n getAuthToken?: () => Promise<string | null>;\n /** For a `createAuthClient`-managed session (spec decision 9): resolve the PERSISTED\n * `SessionInfo.sessionId` so this drain computes the outbox `identityFingerprint` the exact same\n * way a live tab's `setSessionFingerprint` does (`sha256Hex(sessionFingerprintKey(sessionId))`,\n * never a token hash) — otherwise every entry a managed session stamped looks \"foreign\" here and\n * terminal-fails with `OFFLINE_IDENTITY_CHANGED` on every headless drain, composition the live\n * client never hits. Read the SAME storage row the auth client persists to — by default\n * `SESSION_STORAGE_KEY` (`\"helipod.session\"`, exported from `auth-client.ts`) in `localStorage`,\n * or wherever a custom `SessionStorage`/`localStorageSession(key)` was configured to write it; a\n * Service Worker reads it via `clients.matchAll()`-relayed message, a mirrored IndexedDB copy, or\n * any other SW-reachable channel the app wires up — this function only documents the contract, it\n * does not build the plumbing. When this resolves to a non-null id, it takes priority over\n * `getAuthToken`'s token-hash fingerprint (which still governs `SetAuth` on the wire); omit it (or\n * resolve `null`) for a client that never adopted a managed session — the token-hash fingerprint\n * (or `\"anon\"`) applies exactly as before this option existed. */\n getSessionId?: () => Promise<string | null>;\n /** How a coded (terminal, server-recorded) failure is handled — `\"skip\"` (default: settle\n * terminally and continue) or `\"pause\"` (halt the whole drain and surface via `onPause`).\n *\n * Prefer the default `\"skip\"` in headless contexts (a Service Worker's `sync` handler, a cron-like\n * invocation, etc.) — there is no live UI here to observe an `onPause` callback and call `resume()`\n * on the drain's behalf, so a paused drain just sits paused until some LATER invocation happens to\n * clear it. Worse, `\"pause\"` combined with a below-floor `STALE_CLIENT` verdict can livelock: the\n * reset path re-queues the stale entry (`revertActive`), the next invocation re-flushes it, the\n * server replies `STALE_CLIENT` again, and the drain re-pauses on the SAME entry every single run\n * — with nothing headless to break the cycle. `\"skip\"` settles it terminally (once) instead and\n * moves on. */\n poisonPolicy?: PoisonPolicy;\n /** The whole-drain wall-clock budget — after this the socket is closed and the current counts are\n * returned, whatever state the drain is in. Default 30 000ms. */\n timeoutMs?: number;\n /** The Web Locks manager — `undefined` probes the ambient `navigator.locks`, `null` forces\n * single-tab (no contention check at all — ALWAYS drains), an object is used directly (tests\n * inject a fake). Mirrors `OutboxDrainOptions.locks`. */\n locks?: OutboxLockManager | null;\n /** @internal test seam — inject a transport instead of opening a real WebSocket. Kept\n * underscore-internal: not part of the documented public surface. */\n _transport?: ClientTransport;\n}\n\n/** The origin component of the drain's Web Locks name — mirrors `client.ts#originTag` verbatim (a\n * Service Worker's global scope has `location` too; Node/tests share one stable fallback). MUST\n * compute the SAME value a live tab's `HelipodClient` would, or the two never contend for the\n * same lock. */\nfunction originTag(): string {\n const loc = (globalThis as { location?: { origin?: string } }).location;\n return loc?.origin ?? \"app\";\n}\n\n/** Probe the ambient `navigator.locks`, wrapped into the seam — a local mirror of\n * `outbox-drain.ts`'s private `probeLockManager` (unexported there, so duplicated here rather than\n * widening that file's public surface for one caller). */\nfunction probeLockManager(): OutboxLockManager | undefined {\n const nav = (globalThis as { navigator?: { locks?: { request?: unknown } } }).navigator;\n const locks = nav?.locks;\n if (locks && typeof locks.request === \"function\") {\n return {\n request: (name, options, callback) => (locks as { request: OutboxLockManager[\"request\"] }).request(name, options, callback),\n };\n }\n return undefined;\n}\n\n/** A non-blocking availability check: `{ifAvailable: true}` per the real Web Locks contract invokes\n * the callback with `null` (never queues) when the lock is currently held elsewhere.\n * `OutboxLockManager.request`'s callback type carries the optional `lock` parameter directly (no\n * cast needed). A callback invoked with no argument at all (a fake that doesn't implement\n * `ifAvailable` semantics) is treated as \"available\" — the permissive default. */\nasync function isLockAvailable(locks: OutboxLockManager, name: string): Promise<boolean> {\n let available = false;\n await locks.request(name, { ifAvailable: true }, async (lock?: unknown) => {\n available = lock !== null;\n });\n return available;\n}\n\nfunction countActive(entries: OutboxEntry[]): number {\n return entries.filter((e) => e.status === \"unsent\" || e.status === \"inflight\" || e.status === \"parked\").length;\n}\n\nfunction toOutboxEntry(entry: PendingMutation): OutboxEntry {\n return {\n clientId: entry.clientId!,\n seq: entry.seq!,\n requestId: entry.requestId,\n udfPath: entry.udfPath,\n args: entry.args,\n seed: entry.seed,\n order: entry.order!,\n status: \"unsent\",\n identityFingerprint: entry.identityFingerprint,\n outboxVersion: OUTBOX_VERSION,\n enqueuedAt: entry.enqueuedAt ?? Date.now(),\n };\n}\n\n/**\n * Drain the durable outbox once: connect, hand `Connect`+`MutationBatch` traffic to the SAME\n * {@link OutboxDrain} state machine a live tab uses, and resolve once the queue is empty/terminal\n * or `timeoutMs` elapses — whichever comes first. Safe to call from a Service Worker's `sync` event\n * handler inside `event.waitUntil(...)`, or from any other script with no live `HelipodClient`.\n */\nexport async function drainOutboxOnce(opts: HeadlessDrainOptions): Promise<{ drained: number; failed: number; remaining: number }> {\n const outbox = opts.outbox ?? indexedDBOutbox();\n const deployment = opts.deployment ?? \"default\";\n const lockName = `helipod:outbox:${originTag()}:${deployment}`;\n const timeoutMs = opts.timeoutMs ?? 30_000;\n\n const initial = await outbox.loadAll();\n if (countActive(initial.entries) === 0) {\n // Nothing to do — mirrors `HelipodClient`'s own \"an EMPTY outbox sends NO first-connect\n // Connect\" byte-identical short-circuit (`outbox-drain.ts#becomeLeader`'s `drainable().length >\n // 0` gate), just one level up: skip the lock probe and the socket entirely.\n return { drained: 0, failed: 0, remaining: 0 };\n }\n\n const locks = opts.locks === undefined ? probeLockManager() : (opts.locks ?? undefined);\n if (locks) {\n const available = await isLockAvailable(locks, lockName);\n if (!available) {\n // A live tab already holds the leader lock — it is already draining this exact queue. Our job\n // is done without opening a socket at all (\"locks are efficiency, not correctness\": the SAME\n // safety would hold even if we raced ahead and drained too — receipts dedup either way).\n return { drained: 0, failed: 0, remaining: countActive(initial.entries) };\n }\n }\n\n // An injected test double (`_transport`) is held onto immediately — it's already a plain object,\n // not a real socket, so grabbing a reference costs nothing and lets a `finally` below close it on\n // EVERY exit, even one that happens before a real transport would ever be built. The REAL\n // `webSocketTransport(opts.url, ...)` is deliberately deferred until AFTER auth resolves: token\n // resolution needs no socket, so a rejecting `getAuthToken` should never cause a real connection to\n // be opened just to immediately throw it away (the socket-leak fix — previously the transport was\n // constructed unconditionally BEFORE `await opts.getAuthToken()`, so a rejection left an opened,\n // never-closed socket behind; probe-proven `closedCount === 0`).\n let transport: ClientTransport | undefined = opts._transport;\n try {\n let fingerprint = \"anon\";\n let authToken: string | null = null;\n if (opts.getAuthToken) {\n // May reject — `transport` (if the caller injected one) is still closed below via `finally`\n // regardless of whether this throws.\n authToken = await opts.getAuthToken();\n if (authToken) fingerprint = await sha256Hex(authToken);\n }\n if (opts.getSessionId) {\n // A managed session's fingerprint (see `HeadlessDrainOptions.getSessionId`'s doc) takes\n // priority over the token-hash computed above whenever it resolves non-null — the SAME\n // `sessionFingerprintKey` format `client.ts#setSessionFingerprint` hashes, through the SAME\n // shared `sha256Hex`, so the two can never disagree on what a managed entry's\n // `identityFingerprint` should be. `getAuthToken`'s token (if any) still governs `SetAuth`\n // below regardless — only the FINGERPRINT is switched.\n const sessionId = await opts.getSessionId();\n if (sessionId) fingerprint = await sha256Hex(sessionFingerprintKey(sessionId));\n }\n\n transport = transport ?? webSocketTransport(opts.url, { reconnect: false });\n // SetAuth BEFORE Connect (mirrors `client.ts#onTransportReopened`'s \"SetAuth replay first\"):\n // `fingerprint` is already correct by the time the flush-time identity gate ever reads it.\n if (authToken) transport.send({ type: \"SetAuth\", token: authToken });\n\n return await runDrain(transport, opts, outbox, initial.entries, deployment, lockName, timeoutMs, fingerprint);\n } finally {\n // Every exit path — normal completion, timeout, or a thrown error anywhere above (including a\n // rejecting `getAuthToken`) — closes whatever transport reference exists exactly once here.\n // `runDrain` itself never calls `transport.close()`. `undefined` only when no `_transport` was\n // injected AND `getAuthToken` rejected before the real transport was ever constructed — nothing\n // to close, nothing leaked.\n transport?.close();\n }\n}\n\nasync function runDrain(\n transport: ClientTransport,\n opts: HeadlessDrainOptions,\n outbox: OutboxStorage,\n initialEntries: OutboxEntry[],\n deployment: string,\n lockName: string,\n timeoutMs: number,\n fingerprint: string,\n): Promise<{ drained: number; failed: number; remaining: number }> {\n let drained = 0;\n let failed = 0;\n let armed = false;\n let closed = false;\n let connectSent = false;\n let orderCounter = Date.now();\n const log = new Map<string, PendingMutation>();\n let nextRequestId = 1;\n\n const heldAtConnect = outboxHeldFromStore(initialEntries);\n\n function addHydrated(e: OutboxEntry): void {\n // Defense-in-depth (see `dropIfNonReplayable`'s doc) — the SAME check `client.ts#addHydratedEntry`\n // and `OutboxDrain#hydrateOnce` apply; this store-only host has its own separate `addHydrated`,\n // so the check is repeated here rather than shared code that doesn't exist between the two.\n if (dropIfNonReplayable(outbox, e)) return;\n for (const existing of log.values()) {\n if (existing.clientId === e.clientId && existing.seq === e.seq) return;\n }\n // A persisted `\"parked\"` row's fate is genuinely unknown (in-flight when some prior live tab's\n // connection dropped) — preserved so a `known: false` reset treats it with the SAME caution\n // `client.ts#onClientReset` gives a live park (reject loudly, never blind-resend under a fresh\n // identity). Every other persisted status (`\"unsent\"`/`\"inflight\"`) normalizes to `\"unsent\"` —\n // safe to (re)send either way, mirroring `client.ts#addHydratedEntry`'s own normalization.\n const status: PendingMutation[\"status\"] = e.status === \"parked\" ? { type: \"parked\" } : { type: \"unsent\" };\n const entry: PendingMutation = {\n requestId: String(nextRequestId++),\n udfPath: e.udfPath,\n args: e.args,\n seed: e.seed,\n touched: new Set(),\n status,\n clientId: e.clientId,\n seq: e.seq,\n order: e.order,\n identityFingerprint: e.identityFingerprint,\n enqueuedAt: e.enqueuedAt,\n durable: true,\n };\n log.set(entry.requestId, entry);\n }\n\n function drainable(): PendingMutation[] {\n // Mirrors `client.ts#drainableEntries` exactly: durable, recorded `(clientId, seq)`, still\n // `unsent`/`parked`, FIFO by persisted `order`.\n return [...log.values()]\n .filter((e) => e.clientId !== undefined && e.seq !== undefined && e.durable === true && (e.status.type === \"unsent\" || e.status.type === \"parked\"))\n .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));\n }\n\n let doneResolve: (() => void) | undefined;\n function checkDone(): void {\n // `closed` (the transport dropped, e.g. mid-drain) is included: with `reconnect: false` a closed\n // transport can never make further progress, so waiting out the rest of `timeoutMs` would just\n // hold the caller (a Service Worker's `event.waitUntil`) for no reason — exit now with whatever\n // counts the drain reached.\n if (log.size === 0 || drain.isPaused || closed) doneResolve?.();\n }\n\n const host: DrainHost = {\n outbox,\n // No live identity is ever minted for NEW mutations here (this function never calls\n // `mutation()`) — nothing of this session's own needs protecting from `pruneDeadMeta`.\n currentClientId: () => undefined,\n currentFingerprint: () => fingerprint,\n transportOpen: () => !closed,\n isArmed: () => armed,\n drainable,\n addHydrated,\n ensureInitialHandshake: () => {\n if (connectSent) return;\n connectSent = true;\n transport.send(buildConnectMessage(`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`, undefined, heldAtConnect));\n },\n setStatus: (entry, status) => {\n entry.status = { type: status };\n if (entry.clientId !== undefined && entry.seq !== undefined) {\n void outbox.updateStatus(entry.clientId, entry.seq, status).catch(() => {});\n }\n },\n batchEntry: (entry) => ({ requestId: entry.requestId, udfPath: entry.udfPath, args: entry.args, clientId: entry.clientId, seq: entry.seq }),\n sendBatch: (entries) => transport.send({ type: \"MutationBatch\", entries }),\n settleApplied: (requestId) => {\n const entry = log.get(requestId);\n log.delete(requestId);\n if (entry?.clientId !== undefined && entry.seq !== undefined) void outbox.dequeue(entry.clientId, entry.seq).catch(() => {});\n drained++;\n checkDone();\n },\n settleTerminal: (requestId, code, message) => {\n const entry = log.get(requestId);\n log.delete(requestId);\n if (entry?.clientId !== undefined && entry.seq !== undefined) {\n void outbox.updateStatus(entry.clientId, entry.seq, \"failed\", { message, code }).catch(() => {});\n }\n failed++;\n checkDone();\n },\n // No live queries — nothing to re-baseline (matches `client.ts#beginBaselineAwait`'s\n // `expectTransition: false` branch, which adopts immediately with nothing to wait for).\n whenBaselineAdopted: () => Promise.resolve(),\n };\n\n const drain = new OutboxDrain(host, {\n lockName,\n locks: opts.locks,\n poisonPolicy: opts.poisonPolicy ?? \"skip\",\n intervalMs: 0, // one-shot: progress is driven by responses/backoff timers, not a periodic nudge\n onPause: () => checkDone(),\n });\n\n /** `known: false` (verdict §(d) Retention, store-level): re-mint a fresh clientId; re-enqueue\n * every `\"unsent\"` entry under it with a NEW seq (dequeue the old durable row, append the new);\n * terminal-fail every `\"parked\"` entry LOUDLY (`OFFLINE_CLIENT_RESET`) instead — see the file\n * doc's \"genuinely unknown fate\" note. Mirrors `client.ts#onClientReset`, store-level only (no\n * promises to reject/resolve, no layers to drop). */\n async function handleClientReset(): Promise<void> {\n const fresh = defaultMintClientId();\n let freshSeq = 0;\n for (const entry of [...log.values()]) {\n if (entry.status.type === \"parked\") {\n log.delete(entry.requestId);\n failed++;\n if (entry.clientId !== undefined && entry.seq !== undefined) {\n void outbox\n .updateStatus(entry.clientId, entry.seq, \"failed\", {\n message: \"the server disowned this client's mutation history (swept/foreign timeline)\",\n code: \"OFFLINE_CLIENT_RESET\",\n })\n .catch(() => {});\n }\n } else if (entry.status.type === \"unsent\") {\n const oldClientId = entry.clientId;\n const oldSeq = entry.seq;\n entry.clientId = fresh;\n entry.seq = freshSeq++;\n entry.order = ++orderCounter;\n if (oldClientId !== undefined && oldSeq !== undefined) void outbox.dequeue(oldClientId, oldSeq).catch(() => {});\n void outbox.append(toOutboxEntry(entry)).catch(() => {});\n }\n }\n await outbox.setMeta(fresh, { nextSeq: freshSeq, deployment });\n }\n\n const disposeMessage = transport.onMessage((msg: ServerMessage) => {\n if (msg.type === \"MutationResponse\") {\n if (drain.handles(msg.requestId)) drain.onResponse(msg);\n return;\n }\n if (msg.type === \"ConnectAck\") {\n armed = true;\n if (msg.known) {\n drain.nudge();\n } else {\n void handleClientReset()\n .then(() => {\n drain.nudge();\n checkDone();\n })\n .catch((err) => {\n // `setMeta`/`dequeue`/`append` failures inside `handleClientReset` must not become an\n // unhandled rejection (there is no promise chain here for a caller to attach a `.catch`\n // to) — floor it to console and still let the drain settle with whatever counts it has.\n console.error(\"[helipod] outbox: handleClientReset failed\", err);\n checkDone();\n });\n }\n }\n });\n const disposeClose = transport.onClose(() => {\n closed = true;\n drain.onTransportClosed();\n // A closed transport (reconnect:false) can never progress further — resolve `donePromise`\n // promptly instead of waiting out the rest of `timeoutMs`.\n checkDone();\n });\n\n const donePromise = new Promise<void>((resolve) => {\n doneResolve = resolve;\n });\n drain.start();\n let timeoutTimer: ReturnType<typeof setTimeout> | undefined;\n const timeoutPromise = new Promise<void>((resolve) => {\n timeoutTimer = setTimeout(resolve, timeoutMs);\n (timeoutTimer as { unref?: () => void }).unref?.();\n });\n await Promise.race([donePromise, timeoutPromise]);\n // Clear the race's loser timer either way: a drain that quiesces in milliseconds must not hold a\n // Node/SW process alive for the rest of `timeoutMs` (a real 30s default) just because this timer\n // is still pending — `.unref?.()` above is belt-and-suspenders for the same reason.\n if (timeoutTimer !== undefined) clearTimeout(timeoutTimer);\n\n drain.stop();\n disposeMessage();\n disposeClose();\n\n return { drained, failed, remaining: log.size };\n}\n","/**\n * `createAuthClient` — a thin token-lifecycle manager over a `HelipodClient` (auth slice A1).\n * Sign-in flows stay ordinary app mutations; the app hands the mint result to `setSession`. From\n * there this manages: persistence (default `localStorage`, memory fallback), applying the access\n * token via `client.setAuth` + re-applying on reconnect (SetAuth replay handles the wire side),\n * refresh scheduling at ~80% of the access TTL, a Web-Locks single-refresher, a BroadcastChannel\n * pair broadcast to sibling tabs, `REFRESH_STALE` wait-for-broadcast, and terminal-clear +\n * `onSignedOut` on `REFRESH_EXPIRED`/`REFRESH_REUSED`. The outbox fingerprint is switched to the\n * stable `sessionId` while a session is managed (spec decision 9).\n *\n * Non-browser hosts fall back to in-process serialization (no Web Locks) and a no-op broadcast; two\n * independent PROCESSES sharing one refresh token is documented as unsupported (spec decision 5).\n */\n\n/** The persisted mint result — the raw pair + the stable ids the manager needs across a reload. */\nexport interface SessionInfo {\n token: string;\n refreshToken: string;\n sessionId: string;\n userId: string;\n /** Absolute wall-clock ms when the ACCESS token expires (mint `expiresAt`). Drives the 80% schedule. */\n expiresAt: number;\n}\n\n/** The minimal `HelipodClient` surface `createAuthClient` needs (kept structural for testability).\n * `mutation` deliberately returns `Promise<unknown>` (not a generic) so `HelipodClient`'s\n * overloaded `mutation(...): Promise<Value>` is structurally assignable; call sites cast. */\nexport interface AuthManagedClient {\n setAuth(token: string | null): void;\n setSessionFingerprint(sessionId: string | null): void;\n /** `opts.transient` is threaded straight to `HelipodClient.mutation`'s own escape hatch — the\n * refresh call below always passes `{ transient: true }` so a refresh mutation never durably\n * enqueues, even when the app configured an `outbox` (see the file doc's outbox-fingerprint\n * paragraph and `client.ts#mutation`'s doc for why replaying a refresh is never safe). */\n mutation(ref: string, args?: Record<string, unknown>, opts?: { transient?: boolean }): Promise<unknown>;\n}\n\n/** Pluggable synchronous session store (same shape idea as the outbox storage seam). */\nexport interface SessionStorage {\n load(): SessionInfo | null;\n save(info: SessionInfo): void;\n clear(): void;\n}\n\n/** A minimal single-refresher lock seam (a subset of the Web Locks API). Non-browser → in-process. */\nexport interface RefreshLock {\n run<T>(fn: () => Promise<T>): Promise<T>;\n}\n\n/** A minimal cross-tab broadcast seam over the new pair. */\nexport interface PairBroadcast {\n post(info: SessionInfo): void;\n onMessage(cb: (info: SessionInfo) => void): void;\n close(): void;\n}\n\nexport interface CreateAuthClientOptions {\n storage?: SessionStorage;\n lock?: RefreshLock;\n broadcast?: PairBroadcast;\n /** Injectable clock (tests). Defaults to `Date.now`. */\n now?: () => number;\n /** Refresh at this fraction of the access TTL (default 0.8). */\n refreshAtFraction?: number;\n /** Called when the session terminally ends (REFRESH_EXPIRED / REFRESH_REUSED, or clearSession). */\n onSignedOut?: () => void;\n /** Fixed function path for the refresh mutation (default \"auth:refresh\"). */\n refreshPath?: string;\n}\n\nexport interface AuthClient {\n setSession(info: SessionInfo): void;\n clearSession(): void;\n getSessionInfo(): SessionInfo | null;\n close(): void;\n}\n\nconst KEY = \"helipod.session\";\n\n/** The default `localStorage` key `localStorageSession()` persists under — a `SessionInfo` JSON\n * blob, `.sessionId` being the field a headless host needs for `headless-drain.ts`'s `getSessionId`\n * option (mirroring the SAME managed-session fingerprint a live tab's `setSessionFingerprint`\n * computes). Exported so a Service Worker (which shares `localStorage`'s *origin* but typically not\n * a synchronous API to it) or any other headless host can name the same storage row without\n * hand-copying the string literal. A custom `storage`/key (via `localStorageSession(key)` or a\n * fully custom `SessionStorage`) is, of course, this app's own convention to document instead. */\nexport const SESSION_STORAGE_KEY = KEY;\n\n/** localStorage-backed store with an in-memory fallback wherever localStorage is unavailable/throws. */\nexport function localStorageSession(key = KEY): SessionStorage {\n let ls: Storage | undefined;\n try {\n ls = typeof localStorage !== \"undefined\" ? localStorage : undefined;\n if (ls) { ls.setItem(`${key}.probe`, \"1\"); ls.removeItem(`${key}.probe`); }\n } catch {\n ls = undefined;\n }\n if (!ls) return memorySession();\n return {\n load() {\n try { const raw = ls!.getItem(key); return raw ? (JSON.parse(raw) as SessionInfo) : null; } catch { return null; }\n },\n save(info) { try { ls!.setItem(key, JSON.stringify(info)); } catch { /* quota/private-mode: best-effort */ } },\n clear() { try { ls!.removeItem(key); } catch { /* best-effort */ } },\n };\n}\n\n/** In-memory store — nothing survives a reload; the default fallback and a test seam. */\nexport function memorySession(): SessionStorage {\n let cur: SessionInfo | null = null;\n return { load: () => cur, save: (i) => { cur = i; }, clear: () => { cur = null; } };\n}\n\n/** Web-Locks single-refresher when available (browser); otherwise a promise-chain in-process serializer. */\nfunction defaultLock(): RefreshLock {\n const locks = typeof navigator !== \"undefined\" ? (navigator as unknown as { locks?: { request: (name: string, cb: () => Promise<unknown>) => Promise<unknown> } }).locks : undefined;\n if (locks) {\n return { run: <T>(fn: () => Promise<T>) => locks.request(\"helipod:auth:refresh\", fn as () => Promise<unknown>) as Promise<T> };\n }\n let tail: Promise<unknown> = Promise.resolve();\n return {\n run<T>(fn: () => Promise<T>): Promise<T> {\n const next = tail.then(fn, fn);\n tail = next.catch(() => {});\n return next as Promise<T>;\n },\n };\n}\n\n/** BroadcastChannel when available; a no-op otherwise. */\nfunction defaultBroadcast(): PairBroadcast {\n const BC = typeof BroadcastChannel !== \"undefined\" ? BroadcastChannel : undefined;\n if (!BC) return { post: () => {}, onMessage: () => {}, close: () => {} };\n const ch = new BC(\"helipod:auth:pair\");\n return {\n post: (info) => ch.postMessage(info),\n onMessage: (cb) => { ch.onmessage = (e: MessageEvent) => cb(e.data as SessionInfo); },\n close: () => ch.close(),\n };\n}\n\n/** Extract the auth error code from a rejected mutation (spec: `err.code ?? err.message`). */\nfunction codeOf(err: unknown): string {\n if (err && typeof err === \"object\") {\n const c = (err as { code?: unknown }).code;\n if (typeof c === \"string\") return c;\n const m = (err as { message?: unknown }).message;\n if (typeof m === \"string\") return m;\n }\n return String(err);\n}\n\nexport function createAuthClient(client: AuthManagedClient, opts: CreateAuthClientOptions = {}): AuthClient {\n const storage = opts.storage ?? localStorageSession();\n const lock = opts.lock ?? defaultLock();\n const broadcast = opts.broadcast ?? defaultBroadcast();\n const now = opts.now ?? (() => Date.now());\n const fraction = opts.refreshAtFraction ?? 0.8;\n const refreshPath = opts.refreshPath ?? \"auth:refresh\";\n\n let info: SessionInfo | null = storage.load();\n let timer: ReturnType<typeof setTimeout> | undefined;\n let closed = false;\n /** Consecutive generic (non-STALE, non-terminal) refresh failures — drives `scheduleBackoff()`'s\n * exponential delay. Reset by `apply()` on any successful refresh or adopted pair. */\n let refreshFailureCount = 0;\n\n function apply(next: SessionInfo | null): void {\n refreshFailureCount = 0;\n info = next;\n if (next) {\n storage.save(next);\n client.setSessionFingerprint(next.sessionId);\n client.setAuth(next.token);\n schedule();\n } else {\n storage.clear();\n client.setSessionFingerprint(null);\n client.setAuth(null);\n if (timer) clearTimeout(timer);\n }\n }\n\n function schedule(): void {\n if (timer) clearTimeout(timer);\n if (!info || closed) return;\n // The access token was minted for `accessTtl = expiresAt - mintTime`; we don't store mintTime, so\n // approximate the remaining budget as `expiresAt - now` and fire at 80% of it (min 0). This is\n // exact right after a mint/rotation (the common case) and conservative afterward.\n const remaining = info.expiresAt - now();\n const delay = Math.max(0, remaining * fraction);\n timer = setTimeout(() => { void doRefresh(); }, delay);\n }\n\n const backoffBaseMs = 1000;\n const backoffCapMs = 60_000;\n\n /** Exponential backoff for a transient (non-STALE, non-terminal) refresh failure: 1s, 2s, 4s, … up\n * to `backoffCapMs`. This is distinct from `schedule()`'s TTL-based delay, which is derived from\n * `expiresAt - now()` and collapses to ~0 once `now() >= expiresAt` — using `schedule()` here would\n * spin in a tight zero-delay retry loop for the rest of a server outage. `refreshFailureCount` is\n * reset by `apply()` on the next successful refresh or adopted pair. */\n function scheduleBackoff(): void {\n if (timer) clearTimeout(timer);\n if (!info || closed) return;\n refreshFailureCount++;\n const delay = Math.min(backoffCapMs, backoffBaseMs * 2 ** (refreshFailureCount - 1));\n timer = setTimeout(() => { void doRefresh(); }, delay);\n }\n\n /** `expiresAt` moves strictly forward on every rotation (it's the new access token's absolute\n * expiry), so it doubles as a monotonic generation marker. A foreign pair (re-read under the lock,\n * or delivered via broadcast) must only be adopted when it is STRICTLY NEWER than our current one —\n * adopting an older/foreign pair regresses our state and shared storage, which can cascade into a\n * spurious `REFRESH_REUSED` forced sign-out (using a since-superseded refresh token) or a zero-delay\n * retry loop (a stale `expiresAt` already in the past). */\n function isNewer(candidate: SessionInfo, currentInfo: SessionInfo): boolean {\n return candidate.refreshToken !== currentInfo.refreshToken && candidate.expiresAt > currentInfo.expiresAt;\n }\n\n async function doRefresh(): Promise<void> {\n if (!info || closed) return;\n const before = info;\n try {\n const result = await lock.run(async () => {\n // Another tab may have rotated (and broadcast) while we queued for the lock: re-read storage\n // and, if a newer pair is present, adopt it instead of refreshing again. See `isNewer` — a\n // naive refreshToken-diff check would also match an OLDER foreign pair.\n const latest = storage.load();\n if (latest && isNewer(latest, before)) return { adopted: latest };\n // `{ transient: true }` — never durably enqueue a refresh call (see `AuthManagedClient.mutation`'s\n // doc): a drain-replayed stale refresh after a reload has no live awaiter for the mint result\n // and risks rotating the session blind, tripping reuse-detection into a force sign-out.\n const next = (await client.mutation(refreshPath, { refreshToken: before.refreshToken }, { transient: true })) as SessionInfo;\n return { minted: next };\n });\n if (closed) return;\n if (\"adopted\" in result && result.adopted) { apply(result.adopted); return; }\n if (\"minted\" in result && result.minted) {\n apply(result.minted);\n broadcast.post(result.minted); // tell sibling tabs about the winning pair\n }\n } catch (err) {\n if (closed) return;\n const code = codeOf(err);\n if (code === \"REFRESH_STALE\") {\n // Honest race: the winner's broadcast should arrive shortly. Wait briefly, then re-read\n // storage; if a newer pair landed, adopt it (see `isNewer`) — otherwise reschedule and try\n // again.\n setTimeout(() => {\n if (closed) return;\n const latest = storage.load();\n if (latest && isNewer(latest, before)) apply(latest);\n else schedule();\n }, 250);\n return;\n }\n if (code === \"REFRESH_EXPIRED\" || code === \"REFRESH_REUSED\") {\n apply(null);\n opts.onSignedOut?.();\n return;\n }\n // Transient/unknown (e.g. a network blip or server outage): back off exponentially rather than\n // `schedule()`'s TTL-based delay (see `scheduleBackoff`'s doc comment).\n scheduleBackoff();\n }\n }\n\n // Adopt a pair broadcast by another tab (it already committed the rotation server-side). Guarded by\n // `isNewer` (see its doc comment) so a stale/out-of-order/foreign broadcast can never regress us.\n broadcast.onMessage((incoming) => {\n if (closed || !info) return;\n if (isNewer(incoming, info)) apply(incoming);\n });\n\n // Re-apply a persisted session on construction (reload continuity).\n if (info) apply(info);\n\n return {\n setSession(next) { apply(next); },\n clearSession() { apply(null); opts.onSignedOut?.(); },\n getSessionInfo() { return info ? { ...info } : null; }, // shallow copy — never hand out the live internal object\n close() {\n closed = true;\n if (timer) clearTimeout(timer);\n broadcast.close();\n },\n };\n}\n","/**\n * `@helipod/client` — the framework-agnostic reactive client: the `api` proxy, transports\n * (loopback + WebSocket), and `HelipodClient`. React bindings are at `@helipod/client/react`.\n */\nexport type { FunctionReference, AnyFunctionRef } from \"./api\";\nexport { anyApi, getFunctionPath } from \"./api\";\n\nexport type { AnyFunctionReference, FunctionArgs, FunctionReturnType } from \"./function-types\";\n\nexport type { ClientTransport, LoopbackLike, WebSocketTransportOptions } from \"./transport\";\nexport { loopbackTransport, webSocketTransport, reconnectDelayMs } from \"./transport\";\n\nexport type { QueryListener, QueryErrorListener } from \"./client\";\nexport { HelipodClient } from \"./client\";\n\n// T5 — the durable-outbox registry, R9 observability (verdict §(d) \"Observability\").\nexport type { MutationFailedInfo, OutboxBroadcastLike, OutboxBroadcastMessage, PendingMutationEntry, PendingSummary } from \"./client\";\n\n// The Gated Ledger (optimistic updates): the writeable store-view contract T5's typed\n// `OptimisticLocalStore` extends, the update-closure type, and the typed undelivered rejection.\nexport type { OptimisticStoreView, OptimisticUpdate } from \"./layered-store\";\nexport { MutationUndeliveredError } from \"./delivery-policy\";\n\n// T5 — the public, typed optimistic-update store (verdict §(b)'s v1 API surface), plus the\n// durable-outbox `optimisticUpdates` registry's updater shape (spec §(k)6).\nexport type { OptimisticLocalStore, OptimisticUpdateFn, RefArgs, RefReturn } from \"./optimistic-store\";\nexport { createOptimisticLocalStore } from \"./optimistic-store\";\n\n// The durable-offline `OutboxStorage` seam (verdict §(d)): the interface, the in-memory default,\n// and the probe-and-fallback IndexedDB adapter. `mintIdentity` is exported mainly for direct\n// testing of the identity model — app code configures `outbox` at client construction and never\n// calls it itself.\nexport type { HydrateResult, IndexedDBOutboxOptions, OutboxEntry, OutboxEntryError, OutboxEntryStatus, OutboxMeta, OutboxStorage } from \"./outbox-storage\";\nexport { DEFAULT_OUTBOX_MAX_QUEUE_SIZE, OUTBOX_VERSION, OfflineClientResetError, OutboxOverflowError, defaultMintClientId, indexedDBOutbox, memoryOutbox, mintIdentity } from \"./outbox-storage\";\nexport type { ClientResetInfo } from \"./client\";\n\n// Task 4 — the drain: the poison policy, the Web Locks seam (fake-able), and the backoff mirror.\nexport type { DrainHost, OutboxDrainOptions, OutboxLockManager, PoisonPolicy } from \"./outbox-drain\";\nexport {\n AUTH_REFRESH_UDF_PATH,\n DEFAULT_DRAIN_CHUNK_SIZE,\n DEFAULT_DRAIN_INTERVAL_MS,\n NON_REPLAYABLE_MUTATION_DROPPED,\n OFFLINE_IDENTITY_CHANGED,\n OutboxDrain,\n computeDrainBackoff,\n} from \"./outbox-drain\";\n\n// Shared identity-fingerprint primitives (`client.ts` and `headless-drain.ts` both route through\n// these — see the file doc for why a single copy matters).\nexport { sha256Hex, sessionFingerprintKey } from \"./identity-fingerprint\";\n\n// The Connect-handshake helpers shared between `HelipodClient` and the headless drain below.\nexport { buildConnectMessage, outboxAckedThrough, outboxHeldFromLog, outboxHeldFromStore } from \"./connect-handshake\";\n\n// The browser-ux pair, Part B — the headless one-shot outbox drain (the Background Sync seam): a\n// Service Worker (or any UI-less context) can drain the durable queue with no `HelipodClient`.\nexport type { HeadlessDrainOptions } from \"./headless-drain\";\nexport { drainOutboxOnce } from \"./headless-drain\";\n\n// Auth slice A1 — the token-lifecycle manager over a `HelipodClient` (rotation, refresh\n// scheduling, single-refresher, cross-tab pair broadcast, sessionId-based outbox fingerprint).\nexport type {\n AuthClient,\n AuthManagedClient,\n CreateAuthClientOptions,\n PairBroadcast,\n RefreshLock,\n SessionInfo,\n SessionStorage,\n} from \"./auth-client\";\nexport { createAuthClient, localStorageSession, memorySession, SESSION_STORAGE_KEY } from \"./auth-client\";\n\n/** Untyped core of client-side id minting — prefer the codegen-typed `mintId` from your app's\n * `_generated/ids`. Exists for hosts without codegen output at hand. */\nexport { mintEncodedDocumentId as mintDocumentId } from \"@helipod/id-codec\";\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { a as OutboxStorage, O as OutboxEntry, d as OutboxEntryStatus, c as OutboxEntryError, H as HydrateResult, e as OutboxMeta } from './outbox-storage-C6VHkXs9.js';
|
|
2
|
+
import '@helipod/values';
|
|
3
|
+
|
|
4
|
+
interface FsOutboxOptions {
|
|
5
|
+
/** Queue directory (created recursively). One durable queue per dir. */
|
|
6
|
+
dir: string;
|
|
7
|
+
/** fsync appends before resolving append()'s promise. Default true. */
|
|
8
|
+
fsync?: boolean;
|
|
9
|
+
/** Fired once if the adapter degrades to memoryOutbox() (lock held by another process, or an
|
|
10
|
+
* open/hydrate failure). */
|
|
11
|
+
onFallback?: (reason: unknown) => void;
|
|
12
|
+
}
|
|
13
|
+
/** Thrown (rejecting the operation's promise) when a mutating call's flush runs — or would run —
|
|
14
|
+
* after `close()` has completed. Before this existed, a flush that observed `closed` mid-batch
|
|
15
|
+
* simply `return`ed, letting every op in that batch resolve normally despite never being written
|
|
16
|
+
* — a silent, undetectable data-loss window. Now the batch's shared promise rejects instead, so a
|
|
17
|
+
* caller racing `close()` learns its write was dropped rather than believing it durable. */
|
|
18
|
+
declare class OutboxClosedError extends Error {
|
|
19
|
+
readonly code = "OUTBOX_CLOSED";
|
|
20
|
+
constructor(message?: string);
|
|
21
|
+
}
|
|
22
|
+
/** The direct (lock-free) open — `fsOutbox()` below wraps this with lock + probe-and-fallback. */
|
|
23
|
+
declare class FsOutboxStorage implements OutboxStorage {
|
|
24
|
+
private readonly dir;
|
|
25
|
+
private readonly fsync;
|
|
26
|
+
/** Accepts an injectable stats object (the `fsOutbox()` lock wrapper passes its own so the object
|
|
27
|
+
* it returns from `fsOutbox()` stays live even across a probe-and-fallback swap) — defaults to a
|
|
28
|
+
* fresh one for direct construction (tests, direct use of this class). */
|
|
29
|
+
readonly stats: {
|
|
30
|
+
flushes: number;
|
|
31
|
+
fsyncs: number;
|
|
32
|
+
};
|
|
33
|
+
private state;
|
|
34
|
+
private opCount;
|
|
35
|
+
private handle;
|
|
36
|
+
private pending;
|
|
37
|
+
private flushScheduled;
|
|
38
|
+
/** Serializes flushes, compactions, and close() — every disk mutation chains on this, INCLUDING
|
|
39
|
+
* close() itself (see close() below), so nothing can run concurrently with or after a completed
|
|
40
|
+
* close(). Fail-stop by construction: once any chained link throws (disk-full, a corrupt-open
|
|
41
|
+
* failure, `OutboxClosedError`), `this.tail` becomes a permanently-rejected promise and every
|
|
42
|
+
* later `.then(...)` chained onto it (the normal way every method here extends the chain) short-
|
|
43
|
+
* circuits straight to that SAME rejection without ever running its own body — callers see the
|
|
44
|
+
* original failure's error, not a fresh one. This is intentional, not a bug to route around: a
|
|
45
|
+
* fsOutbox instance whose disk state may be compromised should not keep pretending to work.
|
|
46
|
+
* Recovery (probe-and-fallback to memoryOutbox, lock steal) is `fsOutbox()`'s seam, not this class's. */
|
|
47
|
+
private tail;
|
|
48
|
+
private opened;
|
|
49
|
+
private closed;
|
|
50
|
+
constructor(dir: string, fsync: boolean, stats?: {
|
|
51
|
+
flushes: number;
|
|
52
|
+
fsyncs: number;
|
|
53
|
+
});
|
|
54
|
+
private get journalPath();
|
|
55
|
+
/** Replay the journal into memory; truncate a torn tail; quarantine corrupt middles; compact. */
|
|
56
|
+
private openOnce;
|
|
57
|
+
private ready;
|
|
58
|
+
/** Eagerly hydrate the journal. `fsOutbox()`'s probe awaits this BEFORE treating the
|
|
59
|
+
* instance as live, so an open/hydrate failure (corrupt journal, failed truncate, disk error)
|
|
60
|
+
* surfaces to the probe — which can then release the lock and degrade to `memoryOutbox()` — in
|
|
61
|
+
* place of the old lazy-on-first-method-call shape, where the failure only surfaced after the
|
|
62
|
+
* probe had already resolved to this fs store, fail-stopping the instance instead of falling
|
|
63
|
+
* back per the spec's error-handling table. */
|
|
64
|
+
open(): Promise<void>;
|
|
65
|
+
private write;
|
|
66
|
+
/** Rewrite live state tmp → fsync → rename → dir fsync. Failure leaves the old journal intact. */
|
|
67
|
+
private compact;
|
|
68
|
+
append(entry: OutboxEntry): Promise<void>;
|
|
69
|
+
updateStatus(clientId: string, seq: number, status: OutboxEntryStatus, error?: OutboxEntryError): Promise<void>;
|
|
70
|
+
dequeue(clientId: string, seq: number): Promise<void>;
|
|
71
|
+
loadAll(): Promise<HydrateResult>;
|
|
72
|
+
getMeta(clientId: string): Promise<OutboxMeta | undefined>;
|
|
73
|
+
setMeta(clientId: string, meta: OutboxMeta): Promise<void>;
|
|
74
|
+
listMetaClientIds(): Promise<string[]>;
|
|
75
|
+
deleteMeta(clientId: string): Promise<void>;
|
|
76
|
+
persist(): void;
|
|
77
|
+
/** NOTE for `finally`-block callers: under the fail-stop tail, `close()` PROPAGATES a prior
|
|
78
|
+
* tail rejection (a racing op's flush error) rather than masking it — `await storage.close?.()`
|
|
79
|
+
* in cleanup paths should tolerate a rejecting close (`.catch` it if the original error is
|
|
80
|
+
* already being handled elsewhere). */
|
|
81
|
+
close(): Promise<void>;
|
|
82
|
+
}
|
|
83
|
+
declare class OutboxLockHeldError extends Error {
|
|
84
|
+
readonly code = "OUTBOX_LOCK_HELD";
|
|
85
|
+
constructor(holder: number);
|
|
86
|
+
}
|
|
87
|
+
/** The final shape: an async probe (lock the dir, hydrate the journal) wrapped in the same
|
|
88
|
+
* resolved/ready delegation `indexedDBOutbox()` uses (`outbox-storage.ts`) — every call made
|
|
89
|
+
* before the probe settles queues behind it; every call after routes directly. A same-process
|
|
90
|
+
* double-open on the same dir, a live foreign-pid lock, or any `openOnce()` failure (a truncate
|
|
91
|
+
* error, disk-full, etc.) all degrade to a fresh `memoryOutbox()` rather than throwing — a durable
|
|
92
|
+
* outbox that can't get its lock still has to behave like *an* outbox (verdict-consistent
|
|
93
|
+
* probe-and-fallback), just without cross-reload durability. `onFallback` fires exactly once for
|
|
94
|
+
* whichever reason won. */
|
|
95
|
+
declare function fsOutbox(opts: FsOutboxOptions): OutboxStorage & {
|
|
96
|
+
stats: {
|
|
97
|
+
flushes: number;
|
|
98
|
+
fsyncs: number;
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export { type FsOutboxOptions, FsOutboxStorage, OutboxClosedError, OutboxLockHeldError, fsOutbox };
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import {
|
|
2
|
+
dropStaleVersion,
|
|
3
|
+
memoryOutbox
|
|
4
|
+
} from "./chunk-DW55SNHW.js";
|
|
5
|
+
|
|
6
|
+
// src/outbox-fs.ts
|
|
7
|
+
import { appendFileSync, unlinkSync } from "node:fs";
|
|
8
|
+
import { mkdir, open, readFile, rename, rm, truncate, unlink, writeFile } from "node:fs/promises";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
var OutboxClosedError = class extends Error {
|
|
11
|
+
code = "OUTBOX_CLOSED";
|
|
12
|
+
constructor(message = "fsOutbox is closed; this operation's outcome is UNKNOWN \u2014 it may or may not have been durably persisted (close()'s own final compaction can snapshot state an op mutated before its flush rejected). Treat like any in-flight-at-shutdown write: verify on next hydrate.") {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "OutboxClosedError";
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
var COMPACT_THRESHOLD = 4096;
|
|
18
|
+
var key = (clientId, seq) => `${clientId}\0${seq}`;
|
|
19
|
+
function applyOp(state, op) {
|
|
20
|
+
switch (op.op) {
|
|
21
|
+
case "append":
|
|
22
|
+
state.entries.set(key(op.entry.clientId, op.entry.seq), op.entry);
|
|
23
|
+
break;
|
|
24
|
+
case "status": {
|
|
25
|
+
const existing = state.entries.get(key(op.clientId, op.seq));
|
|
26
|
+
if (existing)
|
|
27
|
+
state.entries.set(key(op.clientId, op.seq), {
|
|
28
|
+
...existing,
|
|
29
|
+
status: op.status,
|
|
30
|
+
...op.error !== void 0 ? { error: op.error } : {}
|
|
31
|
+
});
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
case "dequeue":
|
|
35
|
+
state.entries.delete(key(op.clientId, op.seq));
|
|
36
|
+
break;
|
|
37
|
+
case "meta":
|
|
38
|
+
state.meta.set(op.clientId, op.meta);
|
|
39
|
+
break;
|
|
40
|
+
case "metaDelete":
|
|
41
|
+
state.meta.delete(op.clientId);
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
var FsOutboxStorage = class {
|
|
46
|
+
constructor(dir, fsync, stats = { flushes: 0, fsyncs: 0 }) {
|
|
47
|
+
this.dir = dir;
|
|
48
|
+
this.fsync = fsync;
|
|
49
|
+
this.stats = stats;
|
|
50
|
+
}
|
|
51
|
+
dir;
|
|
52
|
+
fsync;
|
|
53
|
+
/** Accepts an injectable stats object (the `fsOutbox()` lock wrapper passes its own so the object
|
|
54
|
+
* it returns from `fsOutbox()` stays live even across a probe-and-fallback swap) — defaults to a
|
|
55
|
+
* fresh one for direct construction (tests, direct use of this class). */
|
|
56
|
+
stats;
|
|
57
|
+
state = { entries: /* @__PURE__ */ new Map(), meta: /* @__PURE__ */ new Map() };
|
|
58
|
+
opCount = 0;
|
|
59
|
+
handle;
|
|
60
|
+
pending = [];
|
|
61
|
+
flushScheduled = false;
|
|
62
|
+
/** Serializes flushes, compactions, and close() — every disk mutation chains on this, INCLUDING
|
|
63
|
+
* close() itself (see close() below), so nothing can run concurrently with or after a completed
|
|
64
|
+
* close(). Fail-stop by construction: once any chained link throws (disk-full, a corrupt-open
|
|
65
|
+
* failure, `OutboxClosedError`), `this.tail` becomes a permanently-rejected promise and every
|
|
66
|
+
* later `.then(...)` chained onto it (the normal way every method here extends the chain) short-
|
|
67
|
+
* circuits straight to that SAME rejection without ever running its own body — callers see the
|
|
68
|
+
* original failure's error, not a fresh one. This is intentional, not a bug to route around: a
|
|
69
|
+
* fsOutbox instance whose disk state may be compromised should not keep pretending to work.
|
|
70
|
+
* Recovery (probe-and-fallback to memoryOutbox, lock steal) is `fsOutbox()`'s seam, not this class's. */
|
|
71
|
+
tail = Promise.resolve();
|
|
72
|
+
opened = false;
|
|
73
|
+
closed = false;
|
|
74
|
+
get journalPath() {
|
|
75
|
+
return join(this.dir, "journal.jsonl");
|
|
76
|
+
}
|
|
77
|
+
/** Replay the journal into memory; truncate a torn tail; quarantine corrupt middles; compact. */
|
|
78
|
+
async openOnce() {
|
|
79
|
+
if (this.opened) return;
|
|
80
|
+
this.opened = true;
|
|
81
|
+
await mkdir(this.dir, { recursive: true });
|
|
82
|
+
let raw = "";
|
|
83
|
+
try {
|
|
84
|
+
raw = await readFile(this.journalPath, "utf8");
|
|
85
|
+
} catch {
|
|
86
|
+
}
|
|
87
|
+
let validBytes = 0;
|
|
88
|
+
const hasTrailingNewline = raw.endsWith("\n");
|
|
89
|
+
const lines = raw.length === 0 ? [] : raw.split("\n");
|
|
90
|
+
for (let i = 0; i < lines.length; i++) {
|
|
91
|
+
const line = lines[i];
|
|
92
|
+
if (line === "") {
|
|
93
|
+
if (i < lines.length - 1) validBytes += 1;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const isLast = i === lines.length - 1;
|
|
97
|
+
if (isLast && !hasTrailingNewline) {
|
|
98
|
+
await truncate(this.journalPath, validBytes);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const op = JSON.parse(line);
|
|
103
|
+
applyOp(this.state, op);
|
|
104
|
+
this.opCount++;
|
|
105
|
+
validBytes += Buffer.byteLength(line, "utf8") + 1;
|
|
106
|
+
} catch {
|
|
107
|
+
appendFileSync(join(this.dir, "journal.quarantine"), line + "\n");
|
|
108
|
+
validBytes += Buffer.byteLength(line, "utf8") + 1;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
this.handle = await open(this.journalPath, "a");
|
|
112
|
+
if (this.opCount > this.state.entries.size + this.state.meta.size) await this.compact();
|
|
113
|
+
}
|
|
114
|
+
ready() {
|
|
115
|
+
this.tail = this.tail.then(() => this.openOnce());
|
|
116
|
+
return this.tail;
|
|
117
|
+
}
|
|
118
|
+
/** Eagerly hydrate the journal. `fsOutbox()`'s probe awaits this BEFORE treating the
|
|
119
|
+
* instance as live, so an open/hydrate failure (corrupt journal, failed truncate, disk error)
|
|
120
|
+
* surfaces to the probe — which can then release the lock and degrade to `memoryOutbox()` — in
|
|
121
|
+
* place of the old lazy-on-first-method-call shape, where the failure only surfaced after the
|
|
122
|
+
* probe had already resolved to this fs store, fail-stopping the instance instead of falling
|
|
123
|
+
* back per the spec's error-handling table. */
|
|
124
|
+
async open() {
|
|
125
|
+
await this.ready();
|
|
126
|
+
}
|
|
127
|
+
write(op) {
|
|
128
|
+
this.pending.push(JSON.stringify(op) + "\n");
|
|
129
|
+
this.opCount++;
|
|
130
|
+
if (!this.flushScheduled) {
|
|
131
|
+
this.flushScheduled = true;
|
|
132
|
+
this.tail = this.tail.then(async () => {
|
|
133
|
+
await this.openOnce();
|
|
134
|
+
this.flushScheduled = false;
|
|
135
|
+
const batch = this.pending.splice(0);
|
|
136
|
+
if (batch.length === 0) return;
|
|
137
|
+
if (this.closed) throw new OutboxClosedError();
|
|
138
|
+
await this.handle.write(batch.join(""));
|
|
139
|
+
this.stats.flushes++;
|
|
140
|
+
if (this.fsync) {
|
|
141
|
+
await this.handle.sync();
|
|
142
|
+
this.stats.fsyncs++;
|
|
143
|
+
}
|
|
144
|
+
if (this.opCount > COMPACT_THRESHOLD) await this.compact();
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return this.tail;
|
|
148
|
+
}
|
|
149
|
+
/** Rewrite live state tmp → fsync → rename → dir fsync. Failure leaves the old journal intact. */
|
|
150
|
+
async compact() {
|
|
151
|
+
const tmpPath = join(this.dir, "journal.tmp");
|
|
152
|
+
try {
|
|
153
|
+
const lines = [];
|
|
154
|
+
for (const [clientId, meta] of this.state.meta) lines.push(JSON.stringify({ op: "meta", clientId, meta }) + "\n");
|
|
155
|
+
for (const entry of [...this.state.entries.values()].sort((a, b) => a.order - b.order))
|
|
156
|
+
lines.push(JSON.stringify({ op: "append", entry }) + "\n");
|
|
157
|
+
const tmp = await open(tmpPath, "w");
|
|
158
|
+
await tmp.write(lines.join(""));
|
|
159
|
+
await tmp.sync();
|
|
160
|
+
await tmp.close();
|
|
161
|
+
await this.handle?.close();
|
|
162
|
+
await rename(tmpPath, this.journalPath);
|
|
163
|
+
const dirHandle = await open(this.dir, "r").catch(() => void 0);
|
|
164
|
+
if (dirHandle) {
|
|
165
|
+
await dirHandle.sync().catch(() => {
|
|
166
|
+
});
|
|
167
|
+
await dirHandle.close();
|
|
168
|
+
}
|
|
169
|
+
this.handle = await open(this.journalPath, "a");
|
|
170
|
+
this.opCount = this.state.entries.size + this.state.meta.size;
|
|
171
|
+
} catch {
|
|
172
|
+
await rm(tmpPath, { force: true }).catch(() => {
|
|
173
|
+
});
|
|
174
|
+
await this.handle?.close().catch(() => {
|
|
175
|
+
});
|
|
176
|
+
this.handle = await open(this.journalPath, "a");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async append(entry) {
|
|
180
|
+
if (!this.opened) await this.ready();
|
|
181
|
+
applyOp(this.state, { op: "append", entry: structuredClone(entry) });
|
|
182
|
+
await this.write({ op: "append", entry });
|
|
183
|
+
}
|
|
184
|
+
async updateStatus(clientId, seq, status, error) {
|
|
185
|
+
if (!this.opened) await this.ready();
|
|
186
|
+
if (!this.state.entries.has(key(clientId, seq))) return;
|
|
187
|
+
applyOp(this.state, { op: "status", clientId, seq, status, error });
|
|
188
|
+
await this.write({ op: "status", clientId, seq, status, ...error !== void 0 ? { error } : {} });
|
|
189
|
+
}
|
|
190
|
+
async dequeue(clientId, seq) {
|
|
191
|
+
if (!this.opened) await this.ready();
|
|
192
|
+
if (!this.state.entries.has(key(clientId, seq))) return;
|
|
193
|
+
applyOp(this.state, { op: "dequeue", clientId, seq });
|
|
194
|
+
await this.write({ op: "dequeue", clientId, seq });
|
|
195
|
+
}
|
|
196
|
+
async loadAll() {
|
|
197
|
+
await this.ready();
|
|
198
|
+
const all = [...this.state.entries.values()].sort((a, b) => a.order - b.order);
|
|
199
|
+
const { entries, dropped } = dropStaleVersion(all);
|
|
200
|
+
for (const e of dropped) {
|
|
201
|
+
applyOp(this.state, { op: "dequeue", clientId: e.clientId, seq: e.seq });
|
|
202
|
+
void this.write({ op: "dequeue", clientId: e.clientId, seq: e.seq }).catch(() => {
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return { entries: entries.map((e) => structuredClone(e)), dropped };
|
|
206
|
+
}
|
|
207
|
+
async getMeta(clientId) {
|
|
208
|
+
await this.ready();
|
|
209
|
+
const m = this.state.meta.get(clientId);
|
|
210
|
+
return m ? structuredClone(m) : void 0;
|
|
211
|
+
}
|
|
212
|
+
async setMeta(clientId, meta) {
|
|
213
|
+
if (!this.opened) await this.ready();
|
|
214
|
+
applyOp(this.state, { op: "meta", clientId, meta: structuredClone(meta) });
|
|
215
|
+
await this.write({ op: "meta", clientId, meta });
|
|
216
|
+
}
|
|
217
|
+
async listMetaClientIds() {
|
|
218
|
+
await this.ready();
|
|
219
|
+
return [...this.state.meta.keys()];
|
|
220
|
+
}
|
|
221
|
+
async deleteMeta(clientId) {
|
|
222
|
+
if (!this.opened) await this.ready();
|
|
223
|
+
applyOp(this.state, { op: "metaDelete", clientId });
|
|
224
|
+
await this.write({ op: "metaDelete", clientId });
|
|
225
|
+
}
|
|
226
|
+
persist() {
|
|
227
|
+
}
|
|
228
|
+
/** NOTE for `finally`-block callers: under the fail-stop tail, `close()` PROPAGATES a prior
|
|
229
|
+
* tail rejection (a racing op's flush error) rather than masking it — `await storage.close?.()`
|
|
230
|
+
* in cleanup paths should tolerate a rejecting close (`.catch` it if the original error is
|
|
231
|
+
* already being handled elsewhere). */
|
|
232
|
+
async close() {
|
|
233
|
+
this.tail = this.tail.then(async () => {
|
|
234
|
+
await this.openOnce();
|
|
235
|
+
if (this.opCount > this.state.entries.size + this.state.meta.size) await this.compact();
|
|
236
|
+
this.closed = true;
|
|
237
|
+
await this.handle?.close();
|
|
238
|
+
this.handle = void 0;
|
|
239
|
+
});
|
|
240
|
+
return this.tail;
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
var openDirs = /* @__PURE__ */ new Set();
|
|
244
|
+
var liveLocks = /* @__PURE__ */ new Set();
|
|
245
|
+
var exitHookRegistered = false;
|
|
246
|
+
var OutboxLockHeldError = class extends Error {
|
|
247
|
+
code = "OUTBOX_LOCK_HELD";
|
|
248
|
+
constructor(holder) {
|
|
249
|
+
super(`outbox dir is locked by live pid ${holder} \u2014 one writer per dir; falling back to memory`);
|
|
250
|
+
this.name = "OutboxLockHeldError";
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
async function acquireLock(dir) {
|
|
254
|
+
const lockPath = join(dir, "lock");
|
|
255
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
256
|
+
try {
|
|
257
|
+
await writeFile(lockPath, JSON.stringify({ pid: process.pid, createdAt: Date.now() }), { flag: "wx" });
|
|
258
|
+
liveLocks.add(lockPath);
|
|
259
|
+
if (!exitHookRegistered) {
|
|
260
|
+
exitHookRegistered = true;
|
|
261
|
+
process.once("exit", () => {
|
|
262
|
+
for (const p of liveLocks) {
|
|
263
|
+
try {
|
|
264
|
+
unlinkSync(p);
|
|
265
|
+
} catch {
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return lockPath;
|
|
271
|
+
} catch (e) {
|
|
272
|
+
if (e.code !== "EEXIST") throw e;
|
|
273
|
+
let stale = false;
|
|
274
|
+
try {
|
|
275
|
+
const { pid } = JSON.parse(await readFile(lockPath, "utf8"));
|
|
276
|
+
if (typeof pid !== "number") stale = true;
|
|
277
|
+
else if (pid === process.pid) stale = !liveLocks.has(lockPath);
|
|
278
|
+
else {
|
|
279
|
+
try {
|
|
280
|
+
process.kill(pid, 0);
|
|
281
|
+
} catch (killErr) {
|
|
282
|
+
if (killErr.code === "ESRCH") stale = true;
|
|
283
|
+
else throw new OutboxLockHeldError(pid);
|
|
284
|
+
}
|
|
285
|
+
if (!stale) throw new OutboxLockHeldError(pid);
|
|
286
|
+
}
|
|
287
|
+
} catch (parseOrHeld) {
|
|
288
|
+
if (parseOrHeld instanceof OutboxLockHeldError) throw parseOrHeld;
|
|
289
|
+
stale = true;
|
|
290
|
+
}
|
|
291
|
+
if (!stale) throw new OutboxLockHeldError(-1);
|
|
292
|
+
await rm(lockPath, { force: true });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
throw new OutboxLockHeldError(-1);
|
|
296
|
+
}
|
|
297
|
+
function fsOutbox(opts) {
|
|
298
|
+
const dir = resolve(opts.dir);
|
|
299
|
+
const stats = { flushes: 0, fsyncs: 0 };
|
|
300
|
+
let resolved;
|
|
301
|
+
const ready = (async () => {
|
|
302
|
+
if (openDirs.has(dir)) throw new OutboxLockHeldError(process.pid);
|
|
303
|
+
openDirs.add(dir);
|
|
304
|
+
let lockPath;
|
|
305
|
+
try {
|
|
306
|
+
await mkdir(dir, { recursive: true });
|
|
307
|
+
lockPath = await acquireLock(dir);
|
|
308
|
+
const store = new FsOutboxStorage(dir, opts.fsync !== false, stats);
|
|
309
|
+
await store.open();
|
|
310
|
+
const innerClose = store.close.bind(store);
|
|
311
|
+
store.close = async () => {
|
|
312
|
+
try {
|
|
313
|
+
await innerClose();
|
|
314
|
+
} finally {
|
|
315
|
+
openDirs.delete(dir);
|
|
316
|
+
liveLocks.delete(lockPath);
|
|
317
|
+
await unlink(lockPath).catch(() => {
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
return store;
|
|
322
|
+
} catch (err) {
|
|
323
|
+
openDirs.delete(dir);
|
|
324
|
+
if (lockPath) {
|
|
325
|
+
liveLocks.delete(lockPath);
|
|
326
|
+
await unlink(lockPath).catch(() => {
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
})().catch((err) => {
|
|
332
|
+
opts.onFallback?.(err);
|
|
333
|
+
return memoryOutbox();
|
|
334
|
+
});
|
|
335
|
+
void ready.then((impl2) => {
|
|
336
|
+
resolved = impl2;
|
|
337
|
+
});
|
|
338
|
+
const impl = async () => resolved ?? ready;
|
|
339
|
+
return {
|
|
340
|
+
stats,
|
|
341
|
+
append: async (entry) => (await impl()).append(entry),
|
|
342
|
+
updateStatus: async (clientId, seq, status, error) => (await impl()).updateStatus(clientId, seq, status, error),
|
|
343
|
+
dequeue: async (clientId, seq) => (await impl()).dequeue(clientId, seq),
|
|
344
|
+
loadAll: async () => (await impl()).loadAll(),
|
|
345
|
+
getMeta: async (clientId) => (await impl()).getMeta(clientId),
|
|
346
|
+
setMeta: async (clientId, meta) => (await impl()).setMeta(clientId, meta),
|
|
347
|
+
listMetaClientIds: async () => (await impl()).listMetaClientIds?.() ?? [],
|
|
348
|
+
deleteMeta: async (clientId) => (await impl()).deleteMeta?.(clientId),
|
|
349
|
+
persist: () => {
|
|
350
|
+
void impl().then((i) => i.persist());
|
|
351
|
+
},
|
|
352
|
+
close: async () => (await impl()).close?.()
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
export {
|
|
356
|
+
FsOutboxStorage,
|
|
357
|
+
OutboxClosedError,
|
|
358
|
+
OutboxLockHeldError,
|
|
359
|
+
fsOutbox
|
|
360
|
+
};
|
|
361
|
+
//# sourceMappingURL=outbox-fs.js.map
|