@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.
@@ -0,0 +1,268 @@
1
+ import { P as PendingMutation, a as PoisonPolicy, O as OutboxLockManager, C as ClientTransport } from './client-mJZjEkhK.js';
2
+ export { A as AUTH_REFRESH_UDF_PATH, b as AnyFunctionRef, c as AnyFunctionReference, d as ClientResetInfo, D as DEFAULT_DRAIN_CHUNK_SIZE, e as DEFAULT_DRAIN_INTERVAL_MS, f as DrainHost, F as FunctionArgs, g as FunctionReference, h as FunctionReturnType, H as HelipodClient, L as LoopbackLike, M as MutationFailedInfo, N as NON_REPLAYABLE_MUTATION_DROPPED, i as OFFLINE_IDENTITY_CHANGED, j as OptimisticLocalStore, k as OptimisticStoreView, l as OptimisticUpdate, m as OptimisticUpdateFn, n as OutboxBroadcastLike, o as OutboxBroadcastMessage, p as OutboxDrain, q as OutboxDrainOptions, r as PendingMutationEntry, s as PendingSummary, Q as QueryErrorListener, t as QueryListener, R as RefArgs, u as RefReturn, W as WebSocketTransportOptions, v as anyApi, w as computeDrainBackoff, x as createOptimisticLocalStore, y as getFunctionPath, z as loopbackTransport, B as reconnectDelayMs, E as webSocketTransport } from './client-mJZjEkhK.js';
3
+ import { O as OutboxEntry, a as OutboxStorage } from './outbox-storage-C6VHkXs9.js';
4
+ export { D as DEFAULT_OUTBOX_MAX_QUEUE_SIZE, H as HydrateResult, I as IndexedDBOutboxOptions, b as OfflineClientResetError, c as OutboxEntryError, d as OutboxEntryStatus, e as OutboxMeta, f as OutboxOverflowError, g as defaultMintClientId, i as indexedDBOutbox, m as memoryOutbox, h as mintIdentity } from './outbox-storage-C6VHkXs9.js';
5
+ import { ClientMutationRef, ClientMessage } from '@helipod/sync';
6
+ export { mintEncodedDocumentId as mintDocumentId } from '@helipod/id-codec';
7
+ import '@helipod/values';
8
+
9
+ /**
10
+ * The IndexedDB-backed `OutboxStorage` (verdict §(d) decision 8, `docs/dev/research/offline-outbox/
11
+ * verdict.md`). ONE database, `helipod-outbox` — the whole seam (the shared mutation queue AND
12
+ * per-clientId identity) lives together so an origin eviction takes BOTH atomically. That is
13
+ * verdict §(g) hazard 1 ("whole-origin eviction... co-evict — one database") pinned structurally:
14
+ * there is no separate identity store to fall out of sync with the queue, because there is only
15
+ * one store to begin with.
16
+ *
17
+ * Schema (v1):
18
+ * - `entries` — keyPath `["clientId", "seq"]` (the durable identity pair, verdict §(b)'s governing
19
+ * invariant: `(clientId, seq) -> payload` written exactly once); index `order`
20
+ * (drain FIFO across the WHOLE shared queue, every clientId); index `status`
21
+ * (candidate scans — e.g. everything still `unsent`/`inflight` — consumed from a
22
+ * later task onward).
23
+ * - `meta` — keyPath `"clientId"`; one row per clientId, `{clientId, nextSeq, deployment}`.
24
+ *
25
+ * Write-behind: every mutating call (`append`/`updateStatus`/`dequeue`/`setMeta`) enqueues a
26
+ * pending op and schedules (once) a microtask flush that opens ONE `readwrite` transaction across
27
+ * both stores and applies every op queued since the last flush, in call order. Callers that fire
28
+ * several appends synchronously in the same microtask turn get ONE transaction, not N.
29
+ */
30
+
31
+ /** Bump when the persisted entry shape changes incompatibly. A hydrate that finds an entry
32
+ * stamped with a different version DROPS it (verdict §(g) hazard 10) — never runs it, never
33
+ * guesses a migration. */
34
+ declare const OUTBOX_VERSION = 1;
35
+
36
+ /**
37
+ * S4 — `DeliveryPolicy`. Routes transport lifecycle events (v1: only *close*) into log
38
+ * transitions. The v1 policy is verdict §(c) event 6 verbatim: at close, NO optimistic layer of
39
+ * any kind survives into a new session (the ts-gate is only sound over a feed whose ts is
40
+ * monotone for THIS client, and a reconnect's resubscribe baseline arrives with the fresh
41
+ * session's ts — carrying a completed layer across would replay it on top of its own echo).
42
+ *
43
+ * - `unsent` → **retained** (never hit the wire; safe to (re)send on reconnect — T6).
44
+ * - `inflight` → the promise **rejects** with `MutationUndeliveredError` and its layer **drops**
45
+ * (outcome genuinely unknowable — no server dedup exists; a blind resend would
46
+ * double-apply) — UNLESS the S4 swap is `armed` AND this entry's durable append
47
+ * has already committed, in which case it **parks** instead (Task 2): the promise
48
+ * stays PENDING (a future drain resolves it under its recorded `(clientId, seq)`),
49
+ * while its layer still drops (the no-layer-crosses-a-session rule is unchanged).
50
+ * - `completed`→ already resolved at `MutationResponse` (D3); its layer **drops** too.
51
+ * - `parked` → already parked by an earlier close, with no drain yet built to resend it
52
+ * (T2's honest boundary — T4 owns the drain): left exactly as is.
53
+ */
54
+
55
+ /**
56
+ * Rejection for a mutation whose outcome is unknowable because the transport dropped before its
57
+ * `MutationResponse` arrived. Typed so apps can distinguish "the server rejected this" (a plain
58
+ * `Error` from the handler) from "we never learned what happened" (retry is unsafe — there is no
59
+ * server-side dedup yet). The message deliberately contains "connection closed".
60
+ */
61
+ declare class MutationUndeliveredError extends Error {
62
+ constructor(message?: string);
63
+ }
64
+
65
+ /**
66
+ * Shared identity-fingerprint primitives for the durable outbox (verdict §(d) hazard 9 / spec
67
+ * §(k)7). `client.ts`'s live `setAuth`/`setSessionFingerprint` and `headless-drain.ts`'s one-shot
68
+ * `drainOutboxOnce` must compute the EXACT SAME `identityFingerprint` for the same underlying
69
+ * identity, or the two never agree — a durable entry stamped by a live tab under a managed
70
+ * session's `sessionFingerprintKey` hash would otherwise look "foreign" to the headless drain's own
71
+ * differently-formatted hash and terminal-fail with `OFFLINE_IDENTITY_CHANGED` even though nothing
72
+ * about the identity actually changed. One shared module, not two hand-synced copies, is how that's
73
+ * kept impossible by construction.
74
+ */
75
+ /** SHA-256 hex digest of `input`. */
76
+ declare function sha256Hex(input: string): Promise<string>;
77
+ /** The string a managed `createAuthClient` session's outbox fingerprint hashes (never the raw
78
+ * `sessionId` alone) — `client.ts#setSessionFingerprint` and `headless-drain.ts`'s `getSessionId`
79
+ * option both route through this ONE function so the "session:" prefix convention can never drift
80
+ * between the two call sites (spec decision 9). */
81
+ declare function sessionFingerprintKey(sessionId: string): string;
82
+
83
+ /**
84
+ * The `Connect` resume handshake's pure computation (verdict §(c) event 6, §(e)) — `held` (every
85
+ * durable `(clientId, seq)` still awaiting a verdict) and `ackedThrough` (the contiguous
86
+ * settled-prefix per clientId, for server-side retention pruning), plus the wire message itself.
87
+ *
88
+ * Extracted from `client.ts` (T3) so a SECOND caller with no `HelipodClient` can send the SAME
89
+ * handshake shape — the headless drain (`headless-drain.ts`, the Background Sync seam). A Service
90
+ * Worker has no live in-memory `MutationLog`, only the durable `OutboxStorage` rows, so
91
+ * {@link outboxHeldFromStore} computes `held` straight from those instead of a live
92
+ * `PendingMutation` log ({@link outboxHeldFromLog}, `HelipodClient`'s own source). Both feed the
93
+ * SAME {@link outboxAckedThrough}/{@link buildConnectMessage} — the wire shape is identical either
94
+ * way, only the source of `held` differs.
95
+ */
96
+
97
+ /** `held` from a LIVE `HelipodClient`'s in-memory log — every entry with a recorded `(clientId,
98
+ * seq)` whose status is still unsettled. */
99
+ declare function outboxHeldFromLog(entries: Iterable<PendingMutation>): ClientMutationRef[];
100
+ /** `held` from a store-only host (the headless drain — no live log, only persisted rows) — the same
101
+ * three statuses, read straight off the persisted `OutboxEntry.status` (identical strings, identical
102
+ * meaning, to the live `PendingMutation.status.type` above). */
103
+ declare function outboxHeldFromStore(entries: Iterable<OutboxEntry>): ClientMutationRef[];
104
+ /** The highest CONTIGUOUS settled-prefix seq per clientId (verdict §(c) Retention / spec decision
105
+ * 3). Under the FIFO one-unacked-chunk drain a seq can never settle past an unsettled earlier one,
106
+ * so for each clientId the settled prefix is exactly `(lowest still-held seq) - 1`; a clientId
107
+ * whose lowest held seq is 0 has acked nothing and is omitted. */
108
+ declare function outboxAckedThrough(held: ClientMutationRef[]): ClientMutationRef[];
109
+ /** Build the `Connect` resume handshake wire message. `sessionId` is a fresh per-connect id (the
110
+ * server routes the handshake by the transport-level session, so this field is only
111
+ * informational); `clientId` is likewise informational server-side — `handleConnect` classifies
112
+ * purely off each `held`/`ackedThrough` entry's OWN `clientId`/`seq` pair, never the top-level
113
+ * field — so a caller whose held set spans several prior tab-sessions' clientIds (the headless
114
+ * drain) can safely omit it. */
115
+ declare function buildConnectMessage(sessionId: string, clientId: string | undefined, held: ClientMutationRef[]): Extract<ClientMessage, {
116
+ type: "Connect";
117
+ }>;
118
+
119
+ interface HeadlessDrainOptions {
120
+ /** The ws(s) sync endpoint. Ignored when `_transport` is injected (tests). */
121
+ url: string;
122
+ /** Defaults to `indexedDBOutbox()` — IndexedDB exists in a Service Worker just as it does in a
123
+ * tab, so the SAME durable queue a live client wrote is readable here with no extra plumbing. */
124
+ outbox?: OutboxStorage;
125
+ /** Distinguishes the drain's Web Locks name per deployment, matching a live client's own
126
+ * `outboxDeployment` constructor option (`client.ts:320`'s naming) — MUST agree with whatever a
127
+ * live tab configured, or the two will never contend for the same lock. Defaults to `"default"`. */
128
+ deployment?: string;
129
+ /** SW-readable auth (the app owns SW-readable token storage — this function only documents the
130
+ * constraint, it does not build one). Replayed as `SetAuth` BEFORE `Connect`, exactly as a
131
+ * reconnecting `HelipodClient` replays its last-set token. */
132
+ getAuthToken?: () => Promise<string | null>;
133
+ /** For a `createAuthClient`-managed session (spec decision 9): resolve the PERSISTED
134
+ * `SessionInfo.sessionId` so this drain computes the outbox `identityFingerprint` the exact same
135
+ * way a live tab's `setSessionFingerprint` does (`sha256Hex(sessionFingerprintKey(sessionId))`,
136
+ * never a token hash) — otherwise every entry a managed session stamped looks "foreign" here and
137
+ * terminal-fails with `OFFLINE_IDENTITY_CHANGED` on every headless drain, composition the live
138
+ * client never hits. Read the SAME storage row the auth client persists to — by default
139
+ * `SESSION_STORAGE_KEY` (`"helipod.session"`, exported from `auth-client.ts`) in `localStorage`,
140
+ * or wherever a custom `SessionStorage`/`localStorageSession(key)` was configured to write it; a
141
+ * Service Worker reads it via `clients.matchAll()`-relayed message, a mirrored IndexedDB copy, or
142
+ * any other SW-reachable channel the app wires up — this function only documents the contract, it
143
+ * does not build the plumbing. When this resolves to a non-null id, it takes priority over
144
+ * `getAuthToken`'s token-hash fingerprint (which still governs `SetAuth` on the wire); omit it (or
145
+ * resolve `null`) for a client that never adopted a managed session — the token-hash fingerprint
146
+ * (or `"anon"`) applies exactly as before this option existed. */
147
+ getSessionId?: () => Promise<string | null>;
148
+ /** How a coded (terminal, server-recorded) failure is handled — `"skip"` (default: settle
149
+ * terminally and continue) or `"pause"` (halt the whole drain and surface via `onPause`).
150
+ *
151
+ * Prefer the default `"skip"` in headless contexts (a Service Worker's `sync` handler, a cron-like
152
+ * invocation, etc.) — there is no live UI here to observe an `onPause` callback and call `resume()`
153
+ * on the drain's behalf, so a paused drain just sits paused until some LATER invocation happens to
154
+ * clear it. Worse, `"pause"` combined with a below-floor `STALE_CLIENT` verdict can livelock: the
155
+ * reset path re-queues the stale entry (`revertActive`), the next invocation re-flushes it, the
156
+ * server replies `STALE_CLIENT` again, and the drain re-pauses on the SAME entry every single run
157
+ * — with nothing headless to break the cycle. `"skip"` settles it terminally (once) instead and
158
+ * moves on. */
159
+ poisonPolicy?: PoisonPolicy;
160
+ /** The whole-drain wall-clock budget — after this the socket is closed and the current counts are
161
+ * returned, whatever state the drain is in. Default 30 000ms. */
162
+ timeoutMs?: number;
163
+ /** The Web Locks manager — `undefined` probes the ambient `navigator.locks`, `null` forces
164
+ * single-tab (no contention check at all — ALWAYS drains), an object is used directly (tests
165
+ * inject a fake). Mirrors `OutboxDrainOptions.locks`. */
166
+ locks?: OutboxLockManager | null;
167
+ /** @internal test seam — inject a transport instead of opening a real WebSocket. Kept
168
+ * underscore-internal: not part of the documented public surface. */
169
+ _transport?: ClientTransport;
170
+ }
171
+ /**
172
+ * Drain the durable outbox once: connect, hand `Connect`+`MutationBatch` traffic to the SAME
173
+ * {@link OutboxDrain} state machine a live tab uses, and resolve once the queue is empty/terminal
174
+ * or `timeoutMs` elapses — whichever comes first. Safe to call from a Service Worker's `sync` event
175
+ * handler inside `event.waitUntil(...)`, or from any other script with no live `HelipodClient`.
176
+ */
177
+ declare function drainOutboxOnce(opts: HeadlessDrainOptions): Promise<{
178
+ drained: number;
179
+ failed: number;
180
+ remaining: number;
181
+ }>;
182
+
183
+ /**
184
+ * `createAuthClient` — a thin token-lifecycle manager over a `HelipodClient` (auth slice A1).
185
+ * Sign-in flows stay ordinary app mutations; the app hands the mint result to `setSession`. From
186
+ * there this manages: persistence (default `localStorage`, memory fallback), applying the access
187
+ * token via `client.setAuth` + re-applying on reconnect (SetAuth replay handles the wire side),
188
+ * refresh scheduling at ~80% of the access TTL, a Web-Locks single-refresher, a BroadcastChannel
189
+ * pair broadcast to sibling tabs, `REFRESH_STALE` wait-for-broadcast, and terminal-clear +
190
+ * `onSignedOut` on `REFRESH_EXPIRED`/`REFRESH_REUSED`. The outbox fingerprint is switched to the
191
+ * stable `sessionId` while a session is managed (spec decision 9).
192
+ *
193
+ * Non-browser hosts fall back to in-process serialization (no Web Locks) and a no-op broadcast; two
194
+ * independent PROCESSES sharing one refresh token is documented as unsupported (spec decision 5).
195
+ */
196
+ /** The persisted mint result — the raw pair + the stable ids the manager needs across a reload. */
197
+ interface SessionInfo {
198
+ token: string;
199
+ refreshToken: string;
200
+ sessionId: string;
201
+ userId: string;
202
+ /** Absolute wall-clock ms when the ACCESS token expires (mint `expiresAt`). Drives the 80% schedule. */
203
+ expiresAt: number;
204
+ }
205
+ /** The minimal `HelipodClient` surface `createAuthClient` needs (kept structural for testability).
206
+ * `mutation` deliberately returns `Promise<unknown>` (not a generic) so `HelipodClient`'s
207
+ * overloaded `mutation(...): Promise<Value>` is structurally assignable; call sites cast. */
208
+ interface AuthManagedClient {
209
+ setAuth(token: string | null): void;
210
+ setSessionFingerprint(sessionId: string | null): void;
211
+ /** `opts.transient` is threaded straight to `HelipodClient.mutation`'s own escape hatch — the
212
+ * refresh call below always passes `{ transient: true }` so a refresh mutation never durably
213
+ * enqueues, even when the app configured an `outbox` (see the file doc's outbox-fingerprint
214
+ * paragraph and `client.ts#mutation`'s doc for why replaying a refresh is never safe). */
215
+ mutation(ref: string, args?: Record<string, unknown>, opts?: {
216
+ transient?: boolean;
217
+ }): Promise<unknown>;
218
+ }
219
+ /** Pluggable synchronous session store (same shape idea as the outbox storage seam). */
220
+ interface SessionStorage {
221
+ load(): SessionInfo | null;
222
+ save(info: SessionInfo): void;
223
+ clear(): void;
224
+ }
225
+ /** A minimal single-refresher lock seam (a subset of the Web Locks API). Non-browser → in-process. */
226
+ interface RefreshLock {
227
+ run<T>(fn: () => Promise<T>): Promise<T>;
228
+ }
229
+ /** A minimal cross-tab broadcast seam over the new pair. */
230
+ interface PairBroadcast {
231
+ post(info: SessionInfo): void;
232
+ onMessage(cb: (info: SessionInfo) => void): void;
233
+ close(): void;
234
+ }
235
+ interface CreateAuthClientOptions {
236
+ storage?: SessionStorage;
237
+ lock?: RefreshLock;
238
+ broadcast?: PairBroadcast;
239
+ /** Injectable clock (tests). Defaults to `Date.now`. */
240
+ now?: () => number;
241
+ /** Refresh at this fraction of the access TTL (default 0.8). */
242
+ refreshAtFraction?: number;
243
+ /** Called when the session terminally ends (REFRESH_EXPIRED / REFRESH_REUSED, or clearSession). */
244
+ onSignedOut?: () => void;
245
+ /** Fixed function path for the refresh mutation (default "auth:refresh"). */
246
+ refreshPath?: string;
247
+ }
248
+ interface AuthClient {
249
+ setSession(info: SessionInfo): void;
250
+ clearSession(): void;
251
+ getSessionInfo(): SessionInfo | null;
252
+ close(): void;
253
+ }
254
+ /** The default `localStorage` key `localStorageSession()` persists under — a `SessionInfo` JSON
255
+ * blob, `.sessionId` being the field a headless host needs for `headless-drain.ts`'s `getSessionId`
256
+ * option (mirroring the SAME managed-session fingerprint a live tab's `setSessionFingerprint`
257
+ * computes). Exported so a Service Worker (which shares `localStorage`'s *origin* but typically not
258
+ * a synchronous API to it) or any other headless host can name the same storage row without
259
+ * hand-copying the string literal. A custom `storage`/key (via `localStorageSession(key)` or a
260
+ * fully custom `SessionStorage`) is, of course, this app's own convention to document instead. */
261
+ declare const SESSION_STORAGE_KEY = "helipod.session";
262
+ /** localStorage-backed store with an in-memory fallback wherever localStorage is unavailable/throws. */
263
+ declare function localStorageSession(key?: string): SessionStorage;
264
+ /** In-memory store — nothing survives a reload; the default fallback and a test seam. */
265
+ declare function memorySession(): SessionStorage;
266
+ declare function createAuthClient(client: AuthManagedClient, opts?: CreateAuthClientOptions): AuthClient;
267
+
268
+ export { type AuthClient, type AuthManagedClient, ClientTransport, type CreateAuthClientOptions, type HeadlessDrainOptions, MutationUndeliveredError, OUTBOX_VERSION, OutboxEntry, OutboxLockManager, OutboxStorage, type PairBroadcast, PoisonPolicy, type RefreshLock, SESSION_STORAGE_KEY, type SessionInfo, type SessionStorage, buildConnectMessage, createAuthClient, drainOutboxOnce, localStorageSession, memorySession, outboxAckedThrough, outboxHeldFromLog, outboxHeldFromStore, sessionFingerprintKey, sha256Hex };