@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,1144 @@
1
+ import { JSONValue, Value } from '@helipod/values';
2
+ import { ClientMessage, ServerMessage, MutationBatchEntry } from '@helipod/sync';
3
+ import { a as OutboxStorage, O as OutboxEntry, c as OutboxEntryError, d as OutboxEntryStatus } from './outbox-storage-C6VHkXs9.js';
4
+
5
+ /**
6
+ * `FunctionArgs`/`FunctionReturnType` — extract the typed args/return generics from a
7
+ * codegen-generated function reference (see `@helipod/codegen`'s `FUNCTION_REFERENCE_TYPE`,
8
+ * emitted verbatim into every app's `_generated/api.d.ts` as `FunctionReference<Type, Vis, Args,
9
+ * Returns>`). The `__args`/`__returns` fields are phantom — they exist purely at the type level
10
+ * (the actual runtime value behind e.g. `api.messages.send` is the untyped `anyApi` proxy from
11
+ * `./api.ts`) — so these generics operate structurally on any type carrying them, without this
12
+ * package importing the app's generated types.
13
+ *
14
+ * This is D10 (docs/superpowers/specs/2025-10-16-optimistic-updates-design.md): `returns`
15
+ * validators are now threaded through codegen to a real `Returns` type (`functions.ts`'s
16
+ * `returnsJson` -> `validatorToTsType` -> the `generate.ts` `returnsType` slot); a function
17
+ * without a `returns` declaration stays `any` (the documented gap — see the executor's
18
+ * `returnsJson` doc for the enforcement follow-on). Consumed by the typed optimistic-updates
19
+ * store and `useQuery`/`useMutation`/`useAction`.
20
+ */
21
+ interface AnyFunctionReference<Args = any, Returns = any> {
22
+ readonly __args: Args;
23
+ readonly __returns: Returns;
24
+ }
25
+ /** The argument type a generated function reference expects (`any` if the function declares no `args`). */
26
+ type FunctionArgs<FuncRef extends AnyFunctionReference> = FuncRef["__args"];
27
+ /**
28
+ * The value a generated function reference resolves with (`any` if the function declares no
29
+ * `returns` — D10's documented gap; migrants add `returns` incrementally to narrow it).
30
+ */
31
+ type FunctionReturnType<FuncRef extends AnyFunctionReference> = FuncRef["__returns"];
32
+
33
+ /**
34
+ * The runtime `api` — a proxy that turns `api.messages.list` into a function reference whose
35
+ * `__path` is `"messages:list"` (module path + function name). Typed as the generated `Api`
36
+ * type, this is what `useQuery(api.messages.list, …)` passes. Nested modules join with `/`
37
+ * (e.g. `api.admin.users.list` → `"admin/users:list"`).
38
+ */
39
+ interface FunctionReference {
40
+ __path: string;
41
+ }
42
+ /**
43
+ * T5's type reconciliation (verdict §(b), flagged latent by T3's report): this package's own
44
+ * `FunctionReference` (`{ __path }`, above) and codegen's generated `Api` type (`FunctionReference<
45
+ * Type, Vis, Args, Returns>` — `__type`/`__visibility`/`__args`/`__returns`, NO `__path` in its
46
+ * TYPE) are structurally incompatible AT THE TYPE LEVEL ONLY. At runtime they are the exact same
47
+ * object — every app's `_generated/server.ts` does `export const api = anyApi as Api`, and
48
+ * `anyApi`'s `Proxy` answers any string property access, `__path` included. This union is the
49
+ * bridge: every public entry point that accepts a function reference (`client.query`/`mutation`/
50
+ * `subscribe`/`action`, `useQuery`/`useMutation`/`useAction`, `OptimisticLocalStore`) is typed
51
+ * against `AnyFunctionRef` (or overloaded across its two members) so a generated typed `api` value
52
+ * compiles wherever the client's own untyped `anyApi` value already did.
53
+ */
54
+ type AnyFunctionRef = FunctionReference | AnyFunctionReference<any, any> | string;
55
+ declare function getFunctionPath(ref: AnyFunctionRef): string;
56
+ /** The untyped api proxy; cast to your generated `Api` type at the import site. */
57
+ declare const anyApi: unknown;
58
+
59
+ /**
60
+ * Transports carry the sync protocol between the client and the engine. The client logic is
61
+ * transport-agnostic; pick `loopbackTransport` (in-process / embedded) or `webSocketTransport`
62
+ * (over the network) — the same `HelipodClient` runs on either.
63
+ */
64
+
65
+ interface ClientTransport {
66
+ send(message: ClientMessage): void;
67
+ onMessage(listener: (msg: ServerMessage) => void): () => void;
68
+ /** Fires when the transport closes or errors (so the client can fail pending work / resync). */
69
+ onClose(listener: () => void): () => void;
70
+ /**
71
+ * Optional (T6): fires once per successful RECONNECT — never for the initial connect. A
72
+ * transport that never reconnects (e.g. `loopbackTransport`) simply doesn't implement this; the
73
+ * client treats it as absent (`transport.onReopen?.(...)`). Implemented by `webSocketTransport`
74
+ * so the client can replay `SetAuth`, resubscribe every live query, and flush unsent mutations
75
+ * against the fresh session.
76
+ */
77
+ onReopen?(listener: () => void): () => void;
78
+ close(): void;
79
+ }
80
+ /** Anything shaped like an embedded loopback connection. */
81
+ interface LoopbackLike {
82
+ send(message: ClientMessage): unknown;
83
+ onMessage(listener: (msg: ServerMessage) => void): () => void;
84
+ close(): void;
85
+ }
86
+ declare function loopbackTransport(connection: LoopbackLike): ClientTransport;
87
+ interface WebSocketTransportOptions {
88
+ /** Reconnect automatically after a disconnect, with exponential backoff + jitter. Default `true`
89
+ * — `{ reconnect: false }` restores the old terminal-on-close behavior verbatim. */
90
+ reconnect?: boolean;
91
+ /** Base delay before the first reconnect attempt; doubles each subsequent attempt. Default `300`. */
92
+ initialBackoffMs?: number;
93
+ /** Reconnect backoff cap. Default `30_000` (~30s). */
94
+ maxBackoffMs?: number;
95
+ /** @internal test seam — how to construct the underlying `WebSocket`. Defaults to `new WebSocket(url)`. */
96
+ createWebSocket?: (url: string) => WebSocket;
97
+ }
98
+ /**
99
+ * Equal-jitter exponential backoff: half the exponential delay, plus up to another half at random
100
+ * — never zero (avoids a thundering-herd reconnect storm) and monotone-capped at `maxBackoffMs`.
101
+ * Exported so the schedule itself is directly unit-testable without simulating a WebSocket.
102
+ */
103
+ declare function reconnectDelayMs(attempt: number, initialBackoffMs: number, maxBackoffMs: number, rand?: () => number): number;
104
+ /**
105
+ * WebSocket transport over the platform `WebSocket` (browsers, Node 22+, Bun). Reconnects by
106
+ * default on disconnect — exponential backoff + jitter, capped ~30s (`{ reconnect: false }` opts
107
+ * out, preserving the old terminal-on-close contract exactly). `onClose` fires once per disconnect
108
+ * (so the client can run its close disposition — reject inflight, retain unsent, drop layers);
109
+ * `onReopen` fires once per successful RECONNECT, never for the very first connect, so the client
110
+ * knows when to replay `SetAuth`, resubscribe, and flush unsent mutations against the fresh session.
111
+ */
112
+ declare function webSocketTransport(url: string, opts?: WebSocketTransportOptions): ClientTransport;
113
+
114
+ /**
115
+ * S1 — the `MutationLog`. One entry per unconfirmed mutation, held in a Map whose iteration order
116
+ * is insertion order — which, because `requestId`s are assigned by a monotonically-incrementing
117
+ * counter (`client.ts`), IS the "requestId order" the reconciler replays surviving updates in
118
+ * (verdict §(c) event 2d). The entry carries the **serializable triple** `(requestId, udfPath,
119
+ * args)` from day one (verdict §(b)'s A1 amendment) so "the log is what a durable outbox later
120
+ * persists" is true instead of aspirational — the durable-offline slice backs S1 with IndexedDB
121
+ * without reshaping this record.
122
+ */
123
+
124
+ /** A single unconfirmed mutation and its optimistic effect. Verbatim from verdict §(b). */
125
+ interface PendingMutation {
126
+ /** Client-local id; rides the existing wire `requestId` field. Opaque string (kept opaque so a
127
+ * future durable outbox can choose uuid vs monotone clientSeq without reshaping this record). */
128
+ requestId: string;
129
+ udfPath: string;
130
+ /** `(requestId, udfPath, args)` = the serializable triple. */
131
+ args: JSONValue;
132
+ /** The optimistic update closure. Looked up at replay, never serialized. Absent for a plain
133
+ * (non-optimistic) mutation — such an entry holds no layer and only tracks in-flight status. */
134
+ update?: OptimisticUpdate;
135
+ /** Fixed at creation — feeds the deterministic `placeholderId()`/`now()` an updater calls (D2);
136
+ * the SAME seed is reused on every replay so minted ids/timestamps are stable. (T5 consumes it.) */
137
+ seed: {
138
+ entropy: string;
139
+ now: number;
140
+ };
141
+ /** Query hashes the updater wrote to on its most recent run — which subscriptions its layer covers. */
142
+ touched: Set<string>;
143
+ status: {
144
+ type: "unsent";
145
+ } | {
146
+ type: "inflight";
147
+ } | {
148
+ type: "completed";
149
+ commitTs: number;
150
+ completedAt: number;
151
+ } | {
152
+ type: "parked";
153
+ };
154
+ /**
155
+ * Durable-outbox identity (verdict §(d), Task 2) — present ONLY when this `HelipodClient` was
156
+ * constructed with a durable `outbox`; a client without one never sets any of the fields below,
157
+ * so `entriesInOrder()` and the wire `Mutation` shape stay byte-identical to before this task for
158
+ * that path. `clientId`/`seq` ride the wire `Mutation`/`MutationBatchEntry` (`clientId`/`seq`,
159
+ * `@helipod/sync`'s `protocol.ts`) whenever an outbox is configured — carried for park-safety
160
+ * on every send, not just once the S4 swap is armed (see `client.ts#mutationMessage`).
161
+ */
162
+ clientId?: string;
163
+ seq?: number;
164
+ /** Global position across the WHOLE shared durable queue (every clientId sharing one outbox) —
165
+ * the drain's FIFO key (T4 consumes it; T2 only assigns it). */
166
+ order?: number;
167
+ /** SHA-256 of the last `SetAuth` token ("anon" for none/empty) — stamped synchronously from a
168
+ * cache `client.ts#setAuth` computes asynchronously (spec §(k)7: SubtleCrypto is async, enqueue
169
+ * is sync, so the digest is computed ahead of time and merely read here). */
170
+ identityFingerprint?: string;
171
+ enqueuedAt?: number;
172
+ /** Flips `true` once this entry's `OutboxStorage.append()` has resolved. "Park eligibility
173
+ * requires durability" (verdict §(d)): `delivery-policy.ts#closeDisposition` only parks an
174
+ * `inflight` entry when this is `true` — a still-in-flight (unconfirmed) append rejects with
175
+ * `MutationUndeliveredError` exactly as before this task existed. Always left falsy when no
176
+ * outbox is configured. */
177
+ durable?: boolean;
178
+ }
179
+
180
+ /**
181
+ * S2 — the `LayeredQueryStore`. It splits what was a single `value` slot per subscription into a
182
+ * **`serverValue`** (written only by server ingest) and a **`composedValue`** (= ordered replay of
183
+ * the surviving optimistic updates over that base — what listeners and the cached first delivery
184
+ * see). Change detection is by **reference inequality**, exactly as convex-js does.
185
+ *
186
+ * The **byte-identity invariant** (the no-re-render binding): when NO surviving update touches a
187
+ * query, its `composedValue` must be the *same reference* as its `serverValue`, not merely
188
+ * deep-equal — so a client with zero optimistic updates behaves reference-for-reference like the
189
+ * pre-slice client and triggers no extra renders.
190
+ *
191
+ * `recompose` is the sole place composed values are (re)built. It is transactional per-updater:
192
+ * an updater that throws mid-run contributes NOTHING (its buffered writes are discarded) and is
193
+ * reported for the caller to drop — a mid-rebuild throw never leaves the composed store half-built.
194
+ */
195
+
196
+ /**
197
+ * The writeable view of composed state an optimistic updater receives. Reads see the composed
198
+ * state as built so far (prior layers + this updater's own writes); writes stack on top.
199
+ *
200
+ * T5 layers the *public typed* `OptimisticLocalStore` (with `placeholderId(table)`/`now()` derived
201
+ * from the entry `seed`, and dev-mode `Object.freeze` on `getQuery` results) on top of this
202
+ * internal contract — this slice implements the read/write view; those three are additive.
203
+ */
204
+ interface OptimisticStoreView {
205
+ getQuery(ref: FunctionReference | string, args?: Record<string, Value>): Value | undefined;
206
+ setQuery(ref: FunctionReference | string, args: Record<string, Value>, value: Value | undefined): void;
207
+ getAllQueries(ref: FunctionReference | string): Array<{
208
+ args: Record<string, Value>;
209
+ value: Value | undefined;
210
+ }>;
211
+ }
212
+ /** The optimistic update closure. `store` is a writeable composed view; `args` is the mutation's args. */
213
+ type OptimisticUpdate = (store: OptimisticStoreView, args: Value) => void;
214
+ type QueryListener = (value: Value) => void;
215
+ /** Fires when a subscribed query throws server-side (its handler errored). */
216
+ type QueryErrorListener = (error: string) => void;
217
+
218
+ /**
219
+ * T5 — the public, typed `OptimisticLocalStore` (verdict §(b)). Wraps T4's internal, untyped
220
+ * `OptimisticStoreView` (the raw read/write composed-state view `LayeredQueryStore.recompose`
221
+ * threads through an updater) with the v1 API surface a `withOptimisticUpdate` closure actually
222
+ * sees:
223
+ *
224
+ * - **Typed `getQuery`/`setQuery`/`getAllQueries`** — `Q`'s declared `__args`/`__returns` (a
225
+ * codegen-generated `FunctionReference`) drive `args`/`value`'s types; the client's own untyped
226
+ * `{ __path }` ref or a raw string path fall back to `Record<string, Value>`/`Value` (see
227
+ * `RefArgs`/`RefReturn` below — the same fallback shape `api.ts`'s `AnyFunctionRef` bridges).
228
+ * - **`placeholderId(table)`** — deterministic per (entry, table, call-ordinal): replaying the
229
+ * SAME pending mutation (same `entry.seed`, verdict §(c) event 2d's rebuild) mints the SAME id
230
+ * sequence, while two calls to `placeholderId("messages")` within ONE updater invocation are
231
+ * ordinal-distinct, and two DIFFERENT entries (distinct `seed.entropy`) never collide. Built from
232
+ * `entry.seed.entropy` — NOT `crypto.randomUUID()` (verdict D11's replay-purity rule; Convex's
233
+ * own docs example is the footgun this API shape is designed to make structurally impossible to
234
+ * reach for).
235
+ * - **`now()`** — `entry.seed.now`, fixed at mutation creation, stable across every replay. NOT
236
+ * `Date.now()` (same D11 rule).
237
+ * - **dev-mode `Object.freeze`** on every value `getQuery`/`getAllQueries` hands back — Convex's
238
+ * documented "mutating the returned value in place will corrupt the client's internal state"
239
+ * footgun becomes an immediate `TypeError` in development (strict-mode ESM) instead of silent
240
+ * corruption. Gated on `process.env.NODE_ENV !== "production"` (no existing dev/prod convention
241
+ * was found in this package at T1 — this is the convention going forward) so a production build
242
+ * pays no freeze cost.
243
+ */
244
+
245
+ /** `Q`'s declared args if `Q` carries codegen's `__args`/`__returns`; else the untyped default —
246
+ * covers the client's own `{ __path }` ref and a raw string path (mirrors `api.ts`'s `AnyFunctionRef`). */
247
+ type RefArgs<Q> = Q extends AnyFunctionReference<infer A, any> ? A : Record<string, Value>;
248
+ /** `Q`'s declared return type — same fallback rule as `RefArgs`. */
249
+ type RefReturn<Q> = Q extends AnyFunctionReference<any, infer R> ? R : Value;
250
+ /** The typed store an optimistic updater receives (verdict §(b)'s v1 API surface, verbatim). */
251
+ interface OptimisticLocalStore {
252
+ getQuery<Q extends AnyFunctionRef>(ref: Q, args?: RefArgs<Q>): RefReturn<Q> | undefined;
253
+ setQuery<Q extends AnyFunctionRef>(ref: Q, args: RefArgs<Q>, value: RefReturn<Q> | undefined): void;
254
+ getAllQueries<Q extends AnyFunctionRef>(ref: Q): Array<{
255
+ args: RefArgs<Q>;
256
+ value: RefReturn<Q> | undefined;
257
+ }>;
258
+ /** Deterministic per (entry, table, call-ordinal) — see file doc. NOT `crypto.randomUUID()`. */
259
+ placeholderId(table: string): string;
260
+ /** Entry-creation time, stable across every replay. NOT `Date.now()`. */
261
+ now(): number;
262
+ }
263
+ /**
264
+ * T5 — the `optimisticUpdates` registry's updater shape (verdict §(d) "Reload and rendering",
265
+ * spec §(k)6). The SAME `(store, args) => void` contract `useMutation(...).withOptimisticUpdate`
266
+ * accepts, but with `args` untyped (`Value`): the registry maps ONE function type across every
267
+ * udfPath key (`Partial<Record<UdfPathOf<Api>, OptimisticUpdateFn>>` — codegen emits the `UdfPathOf`
268
+ * union of KEYS; there is no per-key Args inference threaded through a `Record` at this level, so
269
+ * the VALUE type stays single and generic). Consulted ONLY when a durable entry is hydrated after a
270
+ * reload (never for a live call — "call-site closure wins for the live call, the registry is
271
+ * consulted only at hydrate"). An updater here should tolerate `store.getQuery(...)` returning
272
+ * `undefined` (no persisted query baseline exists pre-reconnect — the documented
273
+ * `if (list === undefined) return` recipe, `docs/enduser/offline.md`).
274
+ */
275
+ type OptimisticUpdateFn = (store: OptimisticLocalStore, args: Value) => void;
276
+ /**
277
+ * Builds the typed store for ONE updater invocation. The per-table ordinal counter is created
278
+ * fresh here (a local `Map`, not carried on `seed`) — every call to this factory starts every
279
+ * table's ordinal back at 0, which is exactly what makes replaying the same entry (same `seed`)
280
+ * deterministic: the Nth `placeholderId("messages")` call within a run always gets the same id.
281
+ */
282
+ declare function createOptimisticLocalStore(view: OptimisticStoreView, seed: {
283
+ entropy: string;
284
+ now: number;
285
+ }): OptimisticLocalStore;
286
+
287
+ /**
288
+ * Task 4 — the drain (verdict §(d) "Drain", `docs/dev/research/offline-outbox/verdict.md`). The
289
+ * state machine that turns the durable queue (`OutboxStorage`) into exactly-once server effects.
290
+ *
291
+ * Shape (verdict §(d)):
292
+ * - **Web Locks leader** (`helipod:outbox:<origin>:<deployment>`) — probe `navigator.locks`,
293
+ * single-tab fallback when it's absent. Locks are *efficiency*, not correctness: two drainers
294
+ * double-sending is harmless because Plan A's exact-match receipts replay-ack the loser (verdict
295
+ * §(d): "locks are efficiency; correctness is the records"). Mid-drain lock loss (the tab killed,
296
+ * or `stop()` on close) → stop cleanly; the durable records make the successor safe (hazard 7).
297
+ * - **Hydrate** the durable queue into the in-memory log under each entry's RECORDED `(clientId,
298
+ * seq)` (cross-session entries a prior page-load left behind), then prune dead-clientId meta rows.
299
+ * - **FIFO by persisted `order`**, sent as `MutationBatch` chunks (default 50 — repair 4, the
300
+ * 500-drain arithmetic in verdict §(a)/(h)); ONE unacked chunk in flight at a time.
301
+ * - **Per-unit resolution** of each chunk's per-entry `MutationResponse`:
302
+ * - `applied` → settle (resolve + dequeue) and advance; the layer drop is routed by `replayed` —
303
+ * a replay (historical, predates this session's baseline) uses T3's unconditional
304
+ * baseline-gated drop, a fresh first-ever apply (this session) uses the same-session
305
+ * `onMutationSuccess` gate a direct-send gets, to stay flicker-free (review-caught, T4).
306
+ * - coded (terminal) failure → default **skip-and-record**: the SERVER recorded the terminal
307
+ * verdict, so the client settles the promise terminally and CONTINUES past it; the
308
+ * `poisonPolicy: "pause"` option instead HALTS the drain and surfaces (verdict §(c) R5).
309
+ * - codeless (transient/infra) failure → back off (a local mirror of the scheduler's
310
+ * `computeBackoff`) and re-send FROM the failed unit.
311
+ * - the **transient-stop chunk contract** (Plan A's server semantics, its E2E is the
312
+ * reference): a transient failure STOPS the server mid-batch, so every unit AFTER it gets NO
313
+ * response — those remain queued and re-send on the next chunk.
314
+ * - **Identity gate per entry at flush**: an entry whose stored `identityFingerprint` no longer
315
+ * matches the session's current one terminal-fails LOUDLY with `OFFLINE_IDENTITY_CHANGED`
316
+ * (hazard 9) — a mutation queued as user A must never flush as user B.
317
+ * - **Wakes**: on enqueue, on reconnect-after-baseline, and on an interval nudge — NEVER
318
+ * `navigator.onLine` (hazard 13: it lies).
319
+ *
320
+ * The drain owns no promise callbacks and no wire types beyond `MutationBatch`/`MutationResponse`;
321
+ * every settlement routes back through `client.ts` via {@link DrainHost} so the T3 seam
322
+ * (`settleVerdict`'s primitives — resolve/reject, dequeue, the drop rule) is reused, not forked.
323
+ */
324
+
325
+ type MutationResponse = Extract<ServerMessage, {
326
+ type: "MutationResponse";
327
+ }>;
328
+ /** How the drain treats a coded (terminal, server-recorded) mutation failure (verdict §(c) R5). */
329
+ type PoisonPolicy =
330
+ /** DEFAULT — skip-and-record: settle the promise terminally and CONTINUE draining. The server
331
+ * already recorded the verdict, so a restart can never un-skip it. */
332
+ "skip"
333
+ /** Halt the whole drain on the first coded failure and surface it (A's argument, honored as an
334
+ * option, not the default). */
335
+ | "pause";
336
+ declare function computeDrainBackoff(attempts: number, rng?: () => number): number;
337
+ /** The default `MutationBatch` chunk size (verdict §(a)/(h) repair 4). */
338
+ declare const DEFAULT_DRAIN_CHUNK_SIZE = 50;
339
+ /** The default interval-nudge period — the backstop wake (verdict §(d): never `navigator.onLine`). */
340
+ declare const DEFAULT_DRAIN_INTERVAL_MS = 5000;
341
+ /** The one method the drain needs from `navigator.locks` — a minimal, structurally-fakeable seam.
342
+ * `request(name, options, cb)` holds the named lock for the lifetime of `cb`'s returned promise
343
+ * (exactly the real `LockManager.request` 3-arg contract). The callback's `lock` parameter mirrors
344
+ * the real `LockGrantedCallback` shape (`null` under `{ifAvailable: true}` when the lock could not
345
+ * be granted immediately) — declared optional so every existing zero-arg callback (`async () =>
346
+ * {...}`) stays assignable unchanged; callers that DO need the availability signal (e.g.
347
+ * `headless-drain.ts#isLockAvailable`) can read it with no cast. */
348
+ interface OutboxLockManager {
349
+ request(name: string, options: {
350
+ signal?: AbortSignal;
351
+ mode?: "exclusive" | "shared";
352
+ ifAvailable?: boolean;
353
+ }, callback: (lock?: unknown) => Promise<unknown> | unknown): Promise<unknown>;
354
+ }
355
+ interface DrainHost {
356
+ readonly outbox: OutboxStorage;
357
+ /** This tab-session's current clientId (re-minted on `onClientReset`). */
358
+ currentClientId(): string | undefined;
359
+ /** The session's current identity fingerprint — the flush-time identity gate compares against it. */
360
+ currentFingerprint(): string;
361
+ /** The transport is up (not closed) — the drain never sends while down. */
362
+ transportOpen(): boolean;
363
+ /** A `ConnectAck` has proven server-side receipt dedup for this session (S4 armed) — the drain
364
+ * never flushes before this, so it can never re-execute against an old/undedup'd server. */
365
+ isArmed(): boolean;
366
+ /** Drain-eligible entries (`unsent`/`parked`, durable, `clientId`+`seq` set), FIFO by `order`. */
367
+ drainable(): PendingMutation[];
368
+ /** Add a hydrated durable entry to the log under a FRESH requestId (idempotent by `(clientId,
369
+ * seq)`). The persisted requestId was session-correlation only, so a fresh one avoids colliding
370
+ * with this session's own requestId counter. */
371
+ addHydrated(entry: OutboxEntry): void;
372
+ /** Send the first-connect `Connect` handshake if one hasn't gone out on this connection yet
373
+ * (idempotent) — the reload analog of T3's reopen handshake (verdict §(d): "the drain starts
374
+ * only after the reconnect baseline Transition has been adopted"). */
375
+ ensureInitialHandshake(): void;
376
+ /** Flip an in-log entry's status (drain-owned transitions: `inflight` on flush, back to `unsent`
377
+ * on a transient revert). */
378
+ setStatus(entry: PendingMutation, status: "inflight" | "unsent"): void;
379
+ /** Build the wire `MutationBatchEntry` for an in-log entry. */
380
+ batchEntry(entry: PendingMutation): MutationBatchEntry;
381
+ /** Send one `MutationBatch` chunk. */
382
+ sendBatch(entries: MutationBatchEntry[]): void;
383
+ /** applied/replayed settlement (verdict §(d) drop rule, T3's `settleVerdict` primitives): resolve
384
+ * the awaiting promise (if any), dequeue the durable record, then drop the layer — routed by
385
+ * `replayed`. A REPLAY (`replayed: true`, a resend whose commit predates this session's `Connect`)
386
+ * drops unconditionally once the baseline is adopted (T3's rule — sound because the baseline
387
+ * snapshot already renders it). A FRESH first-ever apply (`replayed` absent/false — the server
388
+ * executed it for the first time, THIS session, `ts` genuinely after the baseline) must NOT drop
389
+ * early: it needs the same same-session gated hold `onMutationSuccess` gives a direct-send, or the
390
+ * layer disappears a frame before the row it represents actually renders (a flicker). */
391
+ settleApplied(requestId: string, value: Value | null, replayed: boolean, ts: number | undefined): void;
392
+ /** Terminal settlement: reject the awaiting promise (coded), dequeue, drop the layer. */
393
+ settleTerminal(requestId: string, code: string | undefined, message: string): void;
394
+ /** Resolves once the first post-`Connect` baseline Transition has been adopted (verdict §(d) —
395
+ * the drain's send gate). Resolves immediately when no handshake is in flight. */
396
+ whenBaselineAdopted(): Promise<void>;
397
+ }
398
+ /** Terminal code for the flush-time identity gate (hazard 9). */
399
+ declare const OFFLINE_IDENTITY_CHANGED = "OFFLINE_IDENTITY_CHANGED";
400
+ /** The auth-refresh mutation's canonical udf path — mirrors `auth-client.ts`'s default `refreshPath`
401
+ * convention (`"auth:refresh"`). A durable entry hydrated under this path is DROPPED at hydrate
402
+ * time (see `hydrateOnce` below) rather than resent — defense-in-depth for a row a PRE-FIX client
403
+ * version already durably enqueued, before `client.ts#mutation`'s `{transient: true}` escape hatch
404
+ * existed. Replaying a refresh mutation is never safe: there is no live promise awaiter for the
405
+ * mint result across a reload, and blindly resending a stale refresh token risks tripping the
406
+ * session's reuse-detection and force-signing-out an honest user (`auth-client.ts`'s file doc,
407
+ * `REFRESH_REUSED`). A fixed client never durably enqueues this path in the first place, so this
408
+ * should only ever match a row a stale/pre-fix build left behind. */
409
+ declare const AUTH_REFRESH_UDF_PATH = "auth:refresh";
410
+ /** Terminal code stamped on a hydrate-time-dropped refresh entry — see {@link AUTH_REFRESH_UDF_PATH}. */
411
+ declare const NON_REPLAYABLE_MUTATION_DROPPED = "NON_REPLAYABLE_MUTATION_DROPPED";
412
+ interface OutboxDrainOptions {
413
+ /** The Web Locks lock name — `helipod:outbox:<origin>:<deployment>`. */
414
+ lockName: string;
415
+ /** `undefined` → probe `navigator.locks`; `null` → force single-tab; an object → use it (tests). */
416
+ locks?: OutboxLockManager | null;
417
+ poisonPolicy?: PoisonPolicy;
418
+ chunkSize?: number;
419
+ intervalMs?: number;
420
+ /** Injectable backoff (tests drive it deterministically); defaults to {@link computeDrainBackoff}. */
421
+ backoffMs?: (attempts: number) => number;
422
+ /** Fired once when `poisonPolicy: "pause"` halts the drain on a coded failure (surfacing). */
423
+ onPause?: (info: {
424
+ requestId: string;
425
+ udfPath: string;
426
+ code: string;
427
+ }) => void;
428
+ }
429
+ declare class OutboxDrain {
430
+ private readonly host;
431
+ private readonly lockName;
432
+ private readonly locksOption;
433
+ private readonly poisonPolicy;
434
+ private readonly chunkSize;
435
+ private readonly intervalMs;
436
+ private readonly backoffMs;
437
+ private readonly onPause?;
438
+ private started;
439
+ private leader;
440
+ private stopped;
441
+ private paused;
442
+ private hydrated;
443
+ /** The in-flight chunk: requestId → in-log entry. Non-null iff one unacked chunk is outstanding. */
444
+ private active;
445
+ /** Re-entrancy guard around `maybeDrainNext`'s `await whenBaselineAdopted()` (one flush at a time). */
446
+ private flushScheduling;
447
+ /** Consecutive transient-stop count — the backoff attempt number (reset on any forward progress). */
448
+ private transientAttempts;
449
+ private readonly abort;
450
+ private releaseLeadership?;
451
+ private intervalTimer?;
452
+ private backoffTimer?;
453
+ constructor(host: DrainHost, opts: OutboxDrainOptions);
454
+ /** Acquire leadership (Web Locks, or single-tab fallback) and, once leader, hydrate + drain. Safe
455
+ * to call once; a no-op afterward. */
456
+ start(): void;
457
+ /** Stop cleanly (client close, or mid-drain lock loss). Releases leadership, cancels a pending
458
+ * lock request, and clears every timer — the durable records make a successor leader safe. */
459
+ stop(): void;
460
+ /** Wake the drain (enqueue / reconnect-after-baseline / an explicit nudge). Deferred to a
461
+ * microtask so a synchronous settling frame (e.g. a `ConnectAck` emitted right after reopen)
462
+ * always settles its entries BEFORE the drain re-reads the queue. */
463
+ nudge(): void;
464
+ /** True iff `requestId` belongs to the drain's in-flight chunk — `client.ts` routes that unit's
465
+ * `MutationResponse` here instead of down the direct-send path. */
466
+ handles(requestId: string): boolean;
467
+ /** The transport dropped (a reconnect-class close, NOT the client's `close()` — leadership and
468
+ * the interval survive). The in-flight chunk's unresponded units will never get a response on
469
+ * the new server session, so revert them to re-sendable and clear the chunk NOW — otherwise
470
+ * `canDrain()`'s one-unacked invariant (`active === null`) would wedge the drain for the rest of
471
+ * this tab session (no chunk would ever flush again, and every new mutation would queue behind
472
+ * the stuck backlog until overflow). Called by `client.ts#onTransportClosed` BEFORE the
473
+ * reconciler's S4 close rules run, so the reverted (`unsent`) units are simply retained by the
474
+ * close disposition, promises pending, ready for the reconnect handshake + re-drain. A pending
475
+ * transient-backoff timer is also cleared — the reconnect handshake re-drives the drain, and
476
+ * `canDrain()` would no-op a stale timer against a closed transport anyway. */
477
+ onTransportClosed(): void;
478
+ /** True while an unacked chunk is in flight — `client.ts#hasOutboxBacklog` counts it so a new
479
+ * `mutation()` enqueues BEHIND the chunk instead of direct-sending ahead of it (the FIFO rule:
480
+ * "while the queue is non-empty, new mutations enqueue behind it"). A chunk's units are
481
+ * `inflight`, which the unsent/parked backlog scan alone would miss when the chunk consumed the
482
+ * entire backlog. */
483
+ get hasActiveChunk(): boolean;
484
+ /** @internal test/debug — the drain halted on a coded failure under `poisonPolicy: "pause"`. */
485
+ get isPaused(): boolean;
486
+ /** @internal test/debug — the drain currently holds leadership. */
487
+ get isLeader(): boolean;
488
+ /** Resume a `pause`d drain (T5 owns the app-facing retry surface; this is the mechanism). */
489
+ resume(): void;
490
+ private becomeLeader;
491
+ private startInterval;
492
+ /** Load the durable queue into the log (once), under recorded ids, then prune dead meta rows.
493
+ * Only STILL-ACTIVE entries (`unsent`/`inflight`/`parked`) are hydrated into the log — a `failed`
494
+ * entry left behind by a prior session is a terminal, accessor-only record (surfaced via
495
+ * `pendingMutations()`/the constructor's `refireDurableFailures` R9 scan) and must never be
496
+ * resurrected here as a fresh `unsent` log entry: `host.addHydrated` unconditionally stamps
497
+ * `status: "unsent"` regardless of the persisted status, so hydrating a `failed` row verbatim
498
+ * would render a phantom optimistic row for an already-dead mutation AND make it drainable again
499
+ * (a resend of something the server/R9 already settled), on top of double-firing R9 alongside the
500
+ * constructor's unconditional resume scan. Mirrors the same filter `mirrorFromStore`'s cross-tab
501
+ * backstop applies (`client.ts`). */
502
+ private hydrateOnce;
503
+ /** Delete meta rows for clientIds with no live entries and that aren't the current session's — the
504
+ * T1-flagged unbounded-tiny-rows gap (one dead row accrues per prior tab-session + every reset).
505
+ * Capability-gated: a minimal `OutboxStorage` (a bare test double) may omit the two optional
506
+ * methods, in which case pruning is simply skipped. */
507
+ private pruneDeadMeta;
508
+ private canDrain;
509
+ /** Flush the next chunk when eligible. The `flushScheduling` guard + the synchronous `this.active`
510
+ * set (in `flushChunk`) keep exactly one chunk in flight across the `await` yield. */
511
+ private maybeDrainNext;
512
+ private flushChunk;
513
+ /** Route one unit's `MutationResponse` (only ever called for a requestId in the active chunk). */
514
+ onResponse(msg: MutationResponse): void;
515
+ private resolveResponseValue;
516
+ /** A unit settled (applied or coded-terminal): if the chunk is now empty, advance to the next. */
517
+ private onForwardProgress;
518
+ /** Revert every still-in-flight entry of the active chunk to `unsent` (re-sendable) and clear the
519
+ * chunk — used by the transient-stop and pause paths. The units that got no response are exactly
520
+ * those still in `active`. */
521
+ private revertActive;
522
+ }
523
+
524
+ /** Passed to the `onClientReset` callback (verdict §(d) Retention) when the server disowns this
525
+ * client's mutation history on `ConnectAck{known: false}`. `unsentReEnqueued` counts the `unsent`
526
+ * entries carried forward under the fresh clientId + NEW seqs; `parkedRejected` counts the
527
+ * in-flight-at-disconnect entries rejected loudly with `OfflineClientResetError`. */
528
+ interface ClientResetInfo {
529
+ oldClientId: string | undefined;
530
+ newClientId: string;
531
+ unsentReEnqueued: number;
532
+ parkedRejected: number;
533
+ }
534
+ /** T5 (R9): the `onMutationFailed` callback's payload — a terminal, server-recorded verdict for a
535
+ * durable outbox entry the CURRENT session may have no live promise awaiter for (a hydrated
536
+ * cross-reload entry, a retried one, or one discovered already-failed at construction — "resume"). */
537
+ interface MutationFailedInfo {
538
+ clientId: string;
539
+ seq: number;
540
+ udfPath: string;
541
+ error: OutboxEntryError;
542
+ }
543
+ /** T5 (R9): one row of `client.pendingMutations()`/`usePendingMutations()` — a snapshot from the
544
+ * DURABLE store (verdict §(d) "Observability"), not the in-memory reconciler log; `retry()`/
545
+ * `dismiss()` are meaningful only when `status === "failed"` (a terminal, server-recorded verdict —
546
+ * every other status is still in flight and simply isn't a `retry()`/`dismiss()` candidate) and are
547
+ * harmless no-ops otherwise. */
548
+ interface PendingMutationEntry {
549
+ readonly clientId: string;
550
+ readonly seq: number;
551
+ readonly udfPath: string;
552
+ readonly status: OutboxEntryStatus;
553
+ readonly enqueuedAt: number;
554
+ readonly error?: OutboxEntryError;
555
+ /** Re-enqueue this FAILED entry under a fresh `(clientId, seq)` — "never reuse a seq for a new
556
+ * attempt" (verdict §(b)): the old seq's durable record IS its terminal verdict. No-op unless
557
+ * `status === "failed"`. */
558
+ retry(): Promise<void>;
559
+ /** Permanently remove this FAILED entry from the durable store without retrying. No-op unless
560
+ * `status === "failed"`. */
561
+ dismiss(): Promise<void>;
562
+ }
563
+ /** T5 (R9, hazard 2's client half): the queue-age/size advisory — cheap enough to poll before
564
+ * surfacing a "you have offline changes that may be lost soon" banner ahead of Safari's 7-day
565
+ * eviction cliff. `oldestEnqueuedAt`/`oldestAgeMs` are `undefined` for an empty (or unconfigured)
566
+ * outbox. */
567
+ interface PendingSummary {
568
+ count: number;
569
+ oldestEnqueuedAt: number | undefined;
570
+ oldestAgeMs: number | undefined;
571
+ }
572
+ /** T5 (R9): the one method `usePendingMutations()`'s cross-tab nudge needs from `BroadcastChannel` —
573
+ * a minimal, structurally-fakeable seam (the same probe-and-fallback discipline as
574
+ * `OutboxLockManager`, `./outbox-drain`). Real `BroadcastChannel`s satisfy this structurally. */
575
+ interface OutboxBroadcastLike {
576
+ postMessage(message: unknown): void;
577
+ onmessage: ((event: {
578
+ data: unknown;
579
+ }) => void) | null;
580
+ close(): void;
581
+ }
582
+ /** T-crosstab (browser-ux spec Part A): the broadcast channel's payload becomes ADDITIVELY typed.
583
+ * Today's bare `1` ("the message IS the nudge", see `notifyOutboxChange` below) stays a valid,
584
+ * forward-compatible message forever — every listener still fires its `outboxChangeListeners` fan-out
585
+ * on ANY payload shape first (`onmessage` below), unconditionally. These three shapes let a receiver
586
+ * additionally MIRROR another tab's durable entries live, instead of merely re-reading on next poll:
587
+ * - `enqueued` — posted after any durable-outbox-mutating write (append/dequeue/status change);
588
+ * the receiver re-reads `loadAll()` and reconciles its mirrored set against it (the backstop).
589
+ * - `settled` — posted by the drain leader right after an `applied` verdict; a mirroring tab holds
590
+ * its layer `completed` and drops it only once ITS OWN feed observes `commitTs` (flicker-free).
591
+ * - `failed` — posted by the drain leader right after a terminal verdict; a mirroring tab drops the
592
+ * layer and fires its own R9 `onMutationFailed`/dev-loud default (no promise exists to reject).
593
+ * A payload that isn't one of these three (including the legacy bare `1`, or anything malformed) is
594
+ * simply not recognized by `isOutboxBroadcastMessage` below — nudge-only, mirrors nothing, throws
595
+ * nothing. */
596
+ type OutboxBroadcastMessage = {
597
+ kind: "enqueued";
598
+ } | {
599
+ kind: "settled";
600
+ clientId: string;
601
+ seq: number;
602
+ commitTs: number;
603
+ } | {
604
+ kind: "failed";
605
+ clientId: string;
606
+ seq: number;
607
+ code?: string;
608
+ message: string;
609
+ };
610
+ declare class HelipodClient {
611
+ private readonly transport;
612
+ private version;
613
+ private resyncing;
614
+ private closed;
615
+ private resumeSinceTs;
616
+ private readonly store;
617
+ private readonly reconciler;
618
+ /** Mutation promise callbacks, keyed by requestId — resolved/rejected here; layers live in the log. */
619
+ private readonly pendingMutationCallbacks;
620
+ private readonly pendingActions;
621
+ private readonly broadcastListeners;
622
+ private readonly disposeTransport;
623
+ private readonly disposeClose;
624
+ private readonly disposeReopen?;
625
+ private nextQueryId;
626
+ private nextRequestId;
627
+ /** The last token passed to `setAuth` (T6: replayed on reconnect). Unset until `setAuth` is
628
+ * first called — a transport that never had auth set never sends a spurious `SetAuth` on reopen. */
629
+ private hasSetAuth;
630
+ private lastAuthToken;
631
+ /** Absent unless `opts.outbox` is configured — a client constructed without it behaves exactly
632
+ * as before this seam existed (`outbox-storage.ts`'s file doc: "never touches this file's
633
+ * runtime branches that matter"). */
634
+ private readonly outbox?;
635
+ /** Resolves once this tab-session's clientId is durably persisted (`mintIdentity`,
636
+ * `outbox-storage.ts`) — ALWAYS a fresh clientId, never one reused from a prior session. Public
637
+ * contract for tests/direct inspection; `mutation()` itself never awaits this (see
638
+ * `outboxClientId`/`outboxNextSeq` below — the synchronous counterparts it actually reads). */
639
+ private readonly outboxIdentity?;
640
+ /** This tab-session's clientId, minted SYNCHRONOUSLY at construction (Task 2) — `mutation()` must
641
+ * stay fully synchronous (T1's open concern), so it cannot await `outboxIdentity`'s async
642
+ * `getMeta`/`setMeta` round-trip. Fed into `mintIdentity` via `opts.mintClientId` below so the
643
+ * durable meta row names this SAME id. Set once, iff `opts.outbox` is configured; never reused
644
+ * across a reload (a fresh `HelipodClient` always mints again). */
645
+ private outboxClientId?;
646
+ /** In-memory serial `seq` counter for `outboxClientId` (verdict §(d): "seqs minted serially
647
+ * in-memory per tab"). Starts at 0 synchronously; `outboxIdentity`'s resolution only ever
648
+ * reconciles it UPWARD (never re-hands-out a seq already allocated locally) for the
649
+ * astronomically-unlikely colliding-clientId case `mintIdentity` itself guards against. */
650
+ private outboxNextSeq;
651
+ /** Monotonic per-tab counter for `OutboxEntry.order` — the drain's (T4) FIFO key across the
652
+ * WHOLE shared queue (every clientId/tab). Seeded from wall-clock time so multiple tabs sharing
653
+ * one outbox interleave in roughly chronological order; strictly increasing per call within
654
+ * this tab regardless of clock resolution. Cross-tab total ordering is a best-effort aid to the
655
+ * drain's efficiency, NOT a correctness requirement — "locks are efficiency; correctness is the
656
+ * records" (verdict §(d) "Drain"). */
657
+ private outboxOrderCounter;
658
+ /** Cache of `identityFingerprint` (SHA-256 hex of the last `SetAuth` token, or `"anon"` for
659
+ * none/empty) — see `setAuth()` below and spec §(k)7. Stamped synchronously onto every entry;
660
+ * computed asynchronously (SubtleCrypto) whenever `setAuth` is called with a real token. */
661
+ private outboxFingerprint;
662
+ /** When true, a managed `createAuthClient` owns the outbox fingerprint (derived from the stable
663
+ * `sessionId`, not the rotating token) — `setAuth`'s token-hash recompute is suppressed so
664
+ * rotation never orphans queued offline mutations mid-drain (spec decision 9). The raw
665
+ * `setAuth(token)` path (no `createAuthClient`) leaves this false and keeps token-hash
666
+ * fingerprinting byte-for-byte unchanged. */
667
+ private sessionFingerprintActive;
668
+ /** The `session:<sessionId>` key whose SHA-256 is the active managed fingerprint — guards a stale
669
+ * async digest from overwriting a newer session's fingerprint. */
670
+ private sessionFingerprintKey;
671
+ /** The S4 swap's capability flag (verdict §(d) "S4 swap, feature-detected") — flipped by
672
+ * `setOutboxArmed()`, which T3's Connect handshake calls once a `ConnectAck` proves server-side
673
+ * receipt dedup exists for this session. Defaults `false`: today's fail-fast, byte-for-byte,
674
+ * whether or not an outbox is configured. */
675
+ private outboxArmed;
676
+ private readonly outboxMaxQueueSize;
677
+ /** The last `ConnectAck.deploymentId` seen — the same-timeline proof stamp (verdict §(g) hazard
678
+ * 15's client half). Surfaced via `getOutboxDeploymentId()`; also written into the durable meta
679
+ * row so a future reload can compare timelines. Undefined until the first `ConnectAck`. */
680
+ private outboxDeploymentId?;
681
+ /** App callback fired once whenever a `ConnectAck{known: false}` resets this client's identity
682
+ * (verdict §(d) Retention). Optional constructor config. */
683
+ private readonly onClientResetCallback?;
684
+ /** True while the Connect handshake is waiting for the first post-`Connect` baseline Transition
685
+ * to be ADOPTED through S3 (verdict §(d) / spec decision 5 — "a NEW await"). While true, the
686
+ * drop rule for `applied` cross-session entries is DEFERRED (queued in `outboxPendingDrops`) and
687
+ * `whenBaselineAdopted()` (T4's drain gate) stays pending. */
688
+ private outboxAwaitingBaseline;
689
+ /** requestIds whose `applied`-verdict layer drop is deferred until the baseline is adopted (so the
690
+ * drop is flicker-free — the baseline already renders the effect). Drained by `markBaselineAdopted`. */
691
+ private outboxPendingDrops;
692
+ /** Resolvers for in-flight `whenBaselineAdopted()` promises — settled together when the baseline
693
+ * Transition adopts (or immediately, when a reopen had no live subscriptions to re-baseline). */
694
+ private outboxBaselineResolvers;
695
+ /** Whether a `Connect` handshake has already gone out on the CURRENT connection (reset at close).
696
+ * Guards against a double-handshake when both the reopen path and the drain's first-connect path
697
+ * could fire — the drain's `ensureInitialHandshake()` is a no-op once a reopen already sent one. */
698
+ private outboxConnectSent;
699
+ /** DLR Stage 2a — whether this connection has advertised `supportsQueryDiff` (reset at close). A
700
+ * non-outbox client has no resume `Connect` to piggyback the capability on (an outbox client's
701
+ * `buildConnectMessage` carries it), so it sends a minimal capability-only `Connect` before its
702
+ * first subscribe. Without this the server never records the capability and every by-id sub falls
703
+ * back to RERUN — correct, but the diff path never engages. */
704
+ private diffCapabilitySent;
705
+ /** The drain (Task 4) — the Web Locks leader that turns the durable queue into exactly-once server
706
+ * effects. Present iff `opts.outbox` is configured; started at construction. */
707
+ private readonly outboxDrain?;
708
+ /** T5: the registry `mutation()` NEVER consults — only `addHydratedEntry` does, at hydrate time
709
+ * (verdict §(d): "call-site closure wins for the live call; the registry is consulted only at
710
+ * hydrate"). Plain string-keyed: a generated `UdfPathOf<Api>` union (`@helipod/codegen`) narrows
711
+ * the caller's OWN object-literal keys; this package never imports that generated type. */
712
+ private readonly optimisticUpdates;
713
+ /** udfPaths already warned for a registry miss at hydrate — "one warn per udfPath" (spec §(k)6),
714
+ * not once per missed ENTRY (a stale backlog of the same unregistered udfPath warns exactly once). */
715
+ private readonly optimisticUpdateMissWarned;
716
+ /** T5 (R9): fired for a terminal durable failure with no live promise awaiter THIS session (a
717
+ * hydrated/retried entry, or one discovered already-failed at construction — "resume"). Never
718
+ * fired for a failure a live `mutation()` caller's own rejected promise already delivered
719
+ * (Lunora's `hadAwaiter` — no double notification for one failure). */
720
+ private readonly onMutationFailedCallback?;
721
+ /** T5 (R9): same-instance listeners for "the durable outbox changed" — `usePendingMutations()`'s
722
+ * re-read trigger. Fired locally on every outbox-mutating op AND on an incoming cross-tab
723
+ * `outboxBroadcast` message (unified into one path — a listener never needs to know which). */
724
+ private readonly outboxChangeListeners;
725
+ /** T5 (R9): the cross-tab nudge — `undefined` when no outbox is configured or the probe/injected
726
+ * option resolved to nothing (single-tab observability still works via `outboxChangeListeners`). */
727
+ private readonly outboxBroadcast?;
728
+ /** T-crosstab: serializes `mirrorFromStore()` — a second call arriving while one is already
729
+ * in-flight (a rapid burst of `enqueued` broadcasts) sets this bit instead of racing a second
730
+ * `loadAll()`; the in-flight run loops once more on completion so the caller's freshest read is
731
+ * never dropped. */
732
+ private mirrorInFlight;
733
+ private mirrorRerun;
734
+ constructor(transport: ClientTransport, opts?: {
735
+ gateTimeoutMs?: number;
736
+ outbox?: OutboxStorage;
737
+ outboxMaxQueueSize?: number;
738
+ onClientReset?: (info: ClientResetInfo) => void;
739
+ /** How a coded (terminal, server-recorded) mutation failure is handled during the drain
740
+ * (verdict §(c) R5) — `"skip"` (default: skip-and-record + continue) or `"pause"` (halt). */
741
+ poisonPolicy?: PoisonPolicy;
742
+ /** The Web Locks manager for the drain leader — `undefined` probes `navigator.locks`, `null`
743
+ * forces single-tab, an object is used directly (tests inject a fake). */
744
+ outboxLocks?: OutboxLockManager | null;
745
+ /** Distinguishes the drain's lock name per deployment (`helipod:outbox:<origin>:<deployment>`);
746
+ * defaults to `"default"`. */
747
+ outboxDeployment?: string;
748
+ /** The drain's interval-nudge period (verdict §(d): never `navigator.onLine`). */
749
+ outboxDrainIntervalMs?: number;
750
+ /** The drain's `MutationBatch` chunk size (default 50). */
751
+ outboxChunkSize?: number;
752
+ /** Injectable backoff for the drain's codeless-retry path (tests drive it deterministically). */
753
+ outboxBackoffMs?: (attempts: number) => number;
754
+ /** Fired once when `poisonPolicy: "pause"` halts the drain (surfacing). */
755
+ onOutboxPause?: (info: {
756
+ requestId: string;
757
+ udfPath: string;
758
+ code: string;
759
+ }) => void;
760
+ /** T5: the durable-outbox registry — consulted ONLY when a durable entry is hydrated after a
761
+ * reload (never for a live call). Plain string-keyed here; a generated `UdfPathOf<Api>`
762
+ * (`@helipod/codegen`) narrows an app's own object literal at the call site. */
763
+ optimisticUpdates?: Partial<Record<string, OptimisticUpdateFn>>;
764
+ /** T5 (R9): fired for a terminal durable failure with no live promise awaiter this session. */
765
+ onMutationFailed?: (info: MutationFailedInfo) => void;
766
+ /** T5 (R9): the cross-tab nudge for `usePendingMutations()` — `undefined` probes the ambient
767
+ * `BroadcastChannel`, `null` disables it (single-tab observability only), an object is used
768
+ * directly (tests inject a fake). */
769
+ outboxBroadcast?: OutboxBroadcastLike | null;
770
+ });
771
+ /** The origin component of the drain's Web Locks name — `location.origin` in a browser, a stable
772
+ * fallback elsewhere (Node/SSR share one origin; correctness is the records, not the lock). */
773
+ private originTag;
774
+ /** @internal This tab-session's durable outbox identity, or `undefined` when no `outbox` was
775
+ * configured. Exposed for direct testing of the identity-mint behavior; `mutation()` itself
776
+ * reads the synchronous `outboxClientId`/`outboxNextSeq` counterparts, never this promise
777
+ * (see the field doc above `outboxClientId`). */
778
+ getOutboxIdentity(): Promise<{
779
+ clientId: string;
780
+ nextSeq: number;
781
+ }> | undefined;
782
+ /** @internal T3's Connect handshake calls this once a `ConnectAck` proves server-side receipt
783
+ * dedup exists for this session — see verdict §(d) "S4 swap, feature-detected". Before that (no
784
+ * outbox configured, a fresh/pre-handshake session, or an old server that never sends
785
+ * `ConnectAck`), `close()` behaves exactly as it always has: today's fail-fast, byte-for-byte. */
786
+ setOutboxArmed(armed: boolean): void;
787
+ /**
788
+ * Subscribe to a reactive query. `onUpdate` fires with the latest **composed** value (immediately
789
+ * if cached). `onError` (optional) fires if the query's handler throws server-side — otherwise a
790
+ * failing query is logged and leaves the last known value in place.
791
+ *
792
+ * Two overloads bridge T3/T5's type reconciliation (`api.ts`'s `AnyFunctionRef` doc): a
793
+ * codegen-generated ref types `args`/`onUpdate`'s value from its declared `__args`/`__returns`;
794
+ * this package's own untyped `{ __path }` ref or a raw string path fall back to the pre-existing
795
+ * `Record<string, Value>`/`Value` shape (an explicit `T` still overrides, as before).
796
+ */
797
+ subscribe<Q extends AnyFunctionReference<any, any>>(ref: Q, args: FunctionArgs<Q>, onUpdate: (value: FunctionReturnType<Q>) => void, onError?: QueryErrorListener): () => void;
798
+ subscribe(ref: FunctionReference | string, args: Record<string, Value> | undefined, onUpdate: QueryListener, onError?: QueryErrorListener): () => void;
799
+ /** One-shot read: resolves with the first **composed** value (D15) — a one-shot read can return
800
+ * speculative data — or rejects if the query throws; then unsubscribes. */
801
+ query<Q extends AnyFunctionReference<any, any>>(ref: Q, args?: FunctionArgs<Q>): Promise<FunctionReturnType<Q>>;
802
+ query(ref: FunctionReference | string, args?: Record<string, Value>): Promise<Value>;
803
+ /**
804
+ * Run a mutation; resolves with its return value at `MutationResponse` (D3), or rejects with its
805
+ * error. With `{ optimisticUpdate }`, the closure runs synchronously against a writeable composed
806
+ * view before the mutation is sent (instant UI); if it throws, `mutation` throws **synchronously**
807
+ * and nothing is sent. The optimistic layer is dropped on observed inclusion, never on the ack.
808
+ *
809
+ * The typed overload's `optimisticUpdate` is typed against the public `OptimisticLocalStore`
810
+ * (`Q`'s declared `__args`) — sound because `Reconciler.invokeUpdate` (`reconcile.ts`) ALWAYS
811
+ * enriches the raw internal view into an `OptimisticLocalStore` before calling `entry.update`,
812
+ * regardless of entry point; the cast to the internal `OptimisticUpdate` shape below is safe for
813
+ * exactly that reason.
814
+ *
815
+ * `{ transient: true }` (internal escape hatch, e.g. `auth-client.ts`'s refresh call) skips the
816
+ * durable outbox entirely for THIS call — wire-send-only, normal promise semantics — even when an
817
+ * `outbox` is configured. It exists for mutations that must never be durably replayed after a
818
+ * reload (an auth-refresh call: replaying a stale refresh token blind, with no live awaiter for
819
+ * the mint result, trips reuse-detection and force-signs-out an honest user). A non-outbox client
820
+ * is unaffected either way (`useOutbox` is already false whenever `this.outbox` is unset).
821
+ */
822
+ mutation<Q extends AnyFunctionReference<any, any>>(ref: Q, args?: FunctionArgs<Q>, opts?: {
823
+ optimisticUpdate?: (store: OptimisticLocalStore, args: FunctionArgs<Q>) => void;
824
+ transient?: boolean;
825
+ }): Promise<FunctionReturnType<Q>>;
826
+ mutation(ref: FunctionReference | string, args?: Record<string, Value>, opts?: {
827
+ optimisticUpdate?: OptimisticUpdate;
828
+ transient?: boolean;
829
+ }): Promise<Value>;
830
+ /** The wire `Mutation` message for `entry` — carries `(clientId, seq)` whenever an outbox is
831
+ * configured (park-safety, verdict §(d)), and OMITS the fields entirely (not merely `undefined`)
832
+ * when it isn't, so a client with no `outbox` sends exactly today's shape, byte-for-byte. */
833
+ private mutationMessage;
834
+ /** The persisted `OutboxStorage` twin of `entry` — only ever called when `this.outbox` (and thus
835
+ * `entry.clientId`/`seq`/`order`/`enqueuedAt`) is set. */
836
+ private toOutboxEntry;
837
+ /** True while any OTHER entry is `unsent` (queued for a flush) or `parked` (queued for a future
838
+ * drain) — the FIFO-preserving gate a new mutation enqueues behind (verdict §(d) "Enqueue").
839
+ * A drain chunk in flight also counts (its units are `inflight`, which the scan alone would miss
840
+ * when the chunk consumed the whole backlog) — otherwise a mutation issued mid-chunk would
841
+ * direct-send AHEAD of a still-unsettled older unit, breaking the FIFO promise if the chunk
842
+ * transient-stops and re-sends. Plain live in-flight direct-sends deliberately do NOT count:
843
+ * "when empty, live sends go direct and concurrent" (T2's scoping, unchanged). */
844
+ private hasOutboxBacklog;
845
+ /** Count of outbox-tracked entries not yet fully settled (excludes `completed` — already acked,
846
+ * held only for the ts-gate) — the overflow cap's occupancy (verdict §(d) "Enqueue": "bounded,
847
+ * default 1000"). */
848
+ private outboxQueueDepth;
849
+ /** Monotonic `OutboxEntry.order` allocator — see the `outboxOrderCounter` field doc. */
850
+ private nextOutboxOrder;
851
+ /** Run an action; resolves with its return value (or rejects with its error). Not reactive — an action has no subscription. */
852
+ action<Q extends AnyFunctionReference<any, any>>(ref: Q, args?: FunctionArgs<Q>): Promise<FunctionReturnType<Q>>;
853
+ action(ref: FunctionReference | string, args?: Record<string, Value>): Promise<Value>;
854
+ /** Set (or clear) the session identity for this connection; the server re-runs subscriptions under it. */
855
+ setAuth(token: string | null): void;
856
+ /** Managed-session fingerprinting (spec decision 9): a `createAuthClient` calls this so the durable
857
+ * outbox's `identityFingerprint` derives from the STABLE `sessionId`, not the rotating access
858
+ * token — otherwise a rotation mid-drain would orphan queued offline mutations under a new
859
+ * fingerprint. Pass `null` to hand the fingerprint back to the raw `setAuth` token-hash path
860
+ * (e.g. on sign-out). No-op when no outbox is configured. */
861
+ setSessionFingerprint(sessionId: string | null): void;
862
+ /** Publish an ephemeral event (presence/typing) — bypasses the engine. */
863
+ publishEphemeral(topic: string, event: Value): void;
864
+ /** Listen for ephemeral broadcasts (presence/typing) from other clients. */
865
+ onBroadcast(listener: (topic: string, event: Value) => void): () => void;
866
+ close(): void;
867
+ /** @internal test/debug only — the observed-inclusion frontier (resets to 0 at close). */
868
+ get __maxObservedTs(): number;
869
+ /** @internal test/debug only — the live pending-mutation log, in requestId order. */
870
+ get __pending(): readonly PendingMutation[];
871
+ /** @internal test/debug only — the current `identityFingerprint` cache (see `setAuth`); polling
872
+ * this (rather than calling `mutation()` repeatedly, which consumes seqs) is how a test waits
873
+ * out the async SHA-256 digest without depending on a fixed tick count. */
874
+ get __outboxFingerprint(): string;
875
+ private onServerMessage;
876
+ /** A frame was missed: reset and re-subscribe all live queries; adopt the server's next state. */
877
+ private resync;
878
+ private onTransportClosed;
879
+ /**
880
+ * T6: the transport reconnected (a fresh session — the server has no state for it). Order is
881
+ * load-bearing (verdict §(c) event 6): `SetAuth` replay first (the server re-runs subscriptions
882
+ * under the right identity), THEN resubscribe every live query (the existing resync path — it
883
+ * adopts the reply as a fresh baseline regardless of its start version), THEN flush every
884
+ * `unsent` mutation FIFO — each transitions `unsent` -> `inflight` reusing its ORIGINAL
885
+ * `requestId` (never re-minted), so the promise created at `mutation()` call time stays the one
886
+ * that resolves when the new session's `MutationResponse` arrives.
887
+ */
888
+ private onTransportReopened;
889
+ /** Send the `Connect` resume handshake once per connection (idempotent via `outboxConnectSent`),
890
+ * arming the baseline await. Shared by the reopen path and the drain's first-connect path (Task 4
891
+ * / T3 handoff #1: a fresh-client-first-connect after reload has no reopen event, so the drain
892
+ * triggers the same handshake on becoming leader with a durable backlog). */
893
+ private initiateHandshake;
894
+ /** True iff at least one live subscription has NOT yet received its first server reply
895
+ * (`!sub.answered` — set by `ingestTransition` on EITHER outcome, `QueryUpdated` or `QueryFailed`).
896
+ * The drain's first-connect `ensureInitialHandshake` gate (T4 bug fix, later widened to cover the
897
+ * failed-query shape too — re-review FIX 2): a subscription created (and answered) BEFORE the
898
+ * drain's async hydrate finishes has already consumed its one-shot Transition by the time the
899
+ * handshake arms — waiting for ANOTHER one that will never come on a quiet deployment would starve
900
+ * `whenBaselineAdopted()` (and so the drain) forever. Only a subscription still awaiting its first
901
+ * reply guarantees a future Transition is actually coming — that's the one worth waiting for.
902
+ * (Deliberately NOT `sub.serverValue === undefined`: a `QueryFailed` reply never sets `serverValue`
903
+ * — there's no base to render — so that check misclassified an already-answered failed query as
904
+ * still-undelivered and reproduced the same deadlock via the failed-query path.) */
905
+ private hasUndeliveredSubscription;
906
+ /** @internal T4's drain gate. Resolves once the first post-`Connect` baseline Transition has been
907
+ * adopted through S3 (verdict §(d) / spec decision 5). Resolves immediately when no handshake is
908
+ * in flight (nothing to await) or when a reopen had no live subscriptions to re-baseline. */
909
+ whenBaselineAdopted(): Promise<void>;
910
+ /** @internal test/debug — the last `ConnectAck.deploymentId` (the same-timeline proof stamp), or
911
+ * `undefined` before any handshake completed. */
912
+ getOutboxDeploymentId(): string | undefined;
913
+ /** @internal test/debug — whether the S4 park swap is armed (a `ConnectAck` has proven dedup). */
914
+ get __outboxArmed(): boolean;
915
+ /** Begin awaiting the post-`Connect` baseline. `expectTransition` is true iff a baseline Transition
916
+ * is actually coming — for a reopen, `this.resyncing` (set iff `resync()` re-subscribed live
917
+ * queries); for a first connect, whether any live subscription is still awaiting its first
918
+ * delivery (`hasUndeliveredSubscription()` — NOT merely whether one exists: a subscription
919
+ * created and already answered before the handshake armed has nothing left to wait for). With
920
+ * nothing pending, there is no baseline frame coming and adoption is immediate. */
921
+ private beginBaselineAwait;
922
+ /** The baseline Transition adopted (or there was none to await): fire every deferred `applied`
923
+ * layer drop (each flicker-free now — the baseline renders the effect), release the drain gate,
924
+ * and wake the drain (reconnect-after-baseline). */
925
+ private markBaselineAdopted;
926
+ /** Send the `Connect` resume handshake: this tab-session's clientId, the `held` durable entries
927
+ * (every not-yet-settled `(clientId, seq)` in the log — the server classifies each into
928
+ * `ConnectAck.results`), and `ackedThrough` (the contiguous settled-prefix per clientId, for
929
+ * server-side retention pruning). Delegates the pure computation to `./connect-handshake` — the
930
+ * SAME shared module the headless drain (`headless-drain.ts`) builds its own `Connect` from. */
931
+ private sendConnect;
932
+ /** DLR Stage 2a — advertise by-id diff support once per connection for a NON-outbox client (an
933
+ * outbox client's resume `Connect` already carries the flag). A capability-only `Connect` (no
934
+ * `clientId`/`held`/`ackedThrough`) is the reserved server no-op path — it records the capability
935
+ * and sends no `ConnectAck` — so it never interferes with the outbox handshake or backpressure.
936
+ * Sent BEFORE the first `ModifyQuerySet` (fresh connect) and before `resync()` (reopen) so the
937
+ * server has the capability recorded when it decides a by-id sub's initial answer; the ordered
938
+ * transport guarantees delivery order. Even if it somehow arrived late the diff path self-heals
939
+ * (a pre-capability RERUN answer, then diffs resume), but sending it first keeps the common path
940
+ * on the diff answer from the very first subscribe. */
941
+ private maybeSendDiffCapability;
942
+ /** Process a `ConnectAck` (verdict §(e)): the capability proof arms the S4 park swap; the
943
+ * deploymentId is surfaced + persisted; `known: false` triggers `onClientReset`; otherwise each
944
+ * classified `held` seq is settled (`applied`/`failed`/`stale` terminal; `unknown` left for the
945
+ * drain). */
946
+ private handleConnectAck;
947
+ /** Settle one classified `held` seq from a `ConnectAck` (or, later, a drain replay-ack). */
948
+ private settleVerdict;
949
+ /** `known: false` — the server disowned this client's history (verdict §(d) Retention). Re-mint a
950
+ * fresh clientId + meta; re-enqueue every `unsent` entry under the new clientId + NEW seqs (never
951
+ * applied, so safe); reject every `parked` entry LOUDLY (in-flight-at-disconnect, no server dedup
952
+ * → a blind resend could double-apply); fire the `onClientReset` callback. */
953
+ private onClientReset;
954
+ /** The in-memory log entry with this recorded `(clientId, seq)`, or `undefined`. */
955
+ private findOutboxEntry;
956
+ /** Resolve a pending mutation promise by requestId (no-op if it already settled / has no awaiter). */
957
+ private resolvePending;
958
+ /** Reject a pending mutation promise by requestId (no-op if it already settled / has no awaiter). */
959
+ private rejectPending;
960
+ /** Drop an `applied` cross-session entry's layer — deferred until the baseline is adopted (so the
961
+ * drop is flicker-free), or immediately if it already has. */
962
+ private dropAfterBaseline;
963
+ /** Dequeue a settled durable entry from the outbox store (no-op without an outbox / clientId). */
964
+ private dequeueOutboxEntry;
965
+ /** An `Error` carrying the server's terminal verdict `code` (STALE_CLIENT, an app error code) so
966
+ * the drain's coded-vs-codeless retry policy (T4) and apps can key off it. */
967
+ private mutationError;
968
+ /** @internal test/debug — the live drain (Task 4), or `undefined` without an outbox. */
969
+ get __outboxDrain(): OutboxDrain | undefined;
970
+ private makeDrainHost;
971
+ /** Drain-eligible entries: durable, recorded `(clientId, seq)`, still `unsent`/`parked`, FIFO by
972
+ * the persisted `order`. Excludes `inflight` (a live direct-send or an in-flight chunk unit) and
973
+ * `completed`. */
974
+ private drainableEntries;
975
+ /** Add a hydrated durable entry into the log under a FRESH requestId (the persisted requestId was
976
+ * session-correlation only; a fresh one avoids colliding with this session's requestId counter).
977
+ * Idempotent by `(clientId, seq)` — a direct-send this session already tracks is not re-added.
978
+ * T5: `entry.update` is populated from the `optimisticUpdates` registry (hydrate-only lookup — a
979
+ * live call-site closure is never in play here, there IS no live call site for a cross-reload
980
+ * entry); a registry miss is layerless (no `update` at all), a clean drop under the baseline-gated
981
+ * drop rule exactly as T4 shipped it. */
982
+ private addHydratedEntry;
983
+ /** T5: the registry lookup `addHydratedEntry` makes — hydrate-time ONLY (verdict §(d): "the
984
+ * registry is consulted at hydrate only"). A miss warns ONCE per udfPath (not per entry — a
985
+ * backlog of many unregistered entries for the same udfPath warns once) and returns `undefined`:
986
+ * the entry still drains fine, only its optimistic rendering is skipped (spec §(k)6). */
987
+ private lookupHydratedUpdate;
988
+ /** The one predicate distinguishing a MIRRORED entry (another tab-session's durable append, or
989
+ * this tab's own past-session hydrate — either way, no live promise) from an entry THIS instance
990
+ * itself initiated live (hazard (a), "own-tab discrimination"): durable AND no
991
+ * `pendingMutationCallbacks` registered for its `requestId`. A live `mutation()` caller has a
992
+ * callback registered only UNTIL its own wire response settles it — resolved/rejected callbacks
993
+ * are deleted immediately (`resolvePending`/`rejectPending`), well before a `completed` layer's
994
+ * gate gets a chance to drop it. So a live caller's entry looks exactly like a true mirror
995
+ * (`isMirroredEntry` returns `true`) for the whole post-ack, still-gated window — this predicate
996
+ * alone does NOT distinguish "own tab, post-ack" from "another tab's mirror"; it is the
997
+ * `status.type === "completed"` skip in `mirrorFromStore`'s backstop pass that closes that gap
998
+ * (a `completed` layer is owned by its gate, never force-settled by this predicate or the store).
999
+ * Used both by the dispatch methods below and the doc comment on `mirrorFromStore`'s backstop pass
1000
+ * — a single documented helper, never inlined twice (brief hazard (a)). */
1001
+ private isMirroredEntry;
1002
+ /** Handle an incoming (already-fan-out'd) broadcast payload — the typed half of `onmessage`. A
1003
+ * payload that doesn't match `OutboxBroadcastMessage` (the legacy bare `1`, or anything else) is
1004
+ * simply not recognized: the accessor nudge already fired in the caller, nothing else happens. */
1005
+ private handleOutboxBroadcastMessage;
1006
+ /** Re-read the durable store and reconcile this tab's mirrored set against it — the `enqueued`
1007
+ * broadcast's handler, and the missed-message backstop (spec Part A "Rules"): a mirrored entry
1008
+ * absent from the fresh snapshot (settled/failed elsewhere, whose OWN targeted broadcast this tab
1009
+ * never received) drops via the same `dropAfterBaseline` one-pass rule the verdict-after-baseline
1010
+ * path uses. Serialized on `mirrorInFlight` — a second call arriving mid-read sets `mirrorRerun`
1011
+ * and is folded into one more pass after the current read finishes, rather than racing a second
1012
+ * `loadAll()` against it.
1013
+ *
1014
+ * Only STILL-ACTIVE entries (`unsent`/`inflight`/`parked`) are (re-)hydrated — a `failed` (or a
1015
+ * stray `completed`) entry is a terminal, accessor-only record (`pendingMutations()` already
1016
+ * surfaces it) and must never be resurrected as a fresh `unsent` optimistic layer. Without this
1017
+ * guard, a tab that itself once owned a now-terminally-failed entry would keep reviving its OWN
1018
+ * dead record on every subsequent `enqueued` broadcast (this store never dequeues a `failed`
1019
+ * entry — R9 "persists until dismissed/retried") — and, being `durable` with no live callback,
1020
+ * that revived entry would then match `isMirroredEntry`, making the tab react to an UNRELATED
1021
+ * later `settled`/`failed` broadcast that merely happens to name the same `(clientId, seq)`.
1022
+ *
1023
+ * Two review-fixed hazards in the reconcile loop below (both stem from the SAME root cause: the
1024
+ * store's presence/absence is not always the authority for a mirrored layer's fate):
1025
+ * - a `completed` mirrored layer (this tab already got a targeted `settled` broadcast and is
1026
+ * holding it gated until ITS OWN feed observes `commitTs`, per `onCrossTabSettle`) is SKIPPED
1027
+ * entirely here, never force-dropped merely because the store record is absent. The leader's
1028
+ * own `drainSettleApplied` dequeues the record right after posting `settled`, and THAT
1029
+ * dequeue's `{kind:"enqueued"}` follow-up broadcast is exactly what drives this backstop pass —
1030
+ * so a `completed` entry being store-absent is the ordinary, expected case, not a missed
1031
+ * message. Force-dropping it here would race ahead of this tab's own gate (a flicker the gate
1032
+ * exists to prevent) — CRITICAL. The same skip also protects THIS tab's own just-acked live
1033
+ * mutation: its `pendingMutationCallbacks` entry is deleted the moment its wire response
1034
+ * settles it (see `isMirroredEntry`'s doc — the callback does NOT survive the whole gated
1035
+ * window, only until the response arrives), so during that gated window it is
1036
+ * indistinguishable from a true mirror to `isMirroredEntry` and would otherwise be dropped by a
1037
+ * totally unrelated tab's `enqueued` broadcast.
1038
+ * - the "is this mirror still active" check now reads from ACTIVE-status (`unsent`/`inflight`/
1039
+ * `parked`) entries only, not "any entry present in the store" — a mirror whose backing record
1040
+ * flipped to `failed` (R9 never dequeues a failure) is present-in-store but no longer active;
1041
+ * treating presence alone as "still live" would leave a permanent phantom optimistic row behind
1042
+ * a missed `failed` broadcast. Such an entry is instead settled failed right here (same effect
1043
+ * as `onCrossTabSettle`'s `failed` branch — mark failed + fire R9), using the terminal verdict
1044
+ * already recorded on the store row itself. */
1045
+ private mirrorFromStore;
1046
+ /** Handle a targeted `settled`/`failed` broadcast — the leader's flicker-free fast path (Part A's
1047
+ * normal route; `mirrorFromStore`'s backstop above is the fallback for a missed message). Ignored
1048
+ * entirely for an entry this tab doesn't know about, or one it initiated live itself (hazard (a)).
1049
+ * Never touches the durable store — the leader (whichever tab settled it) already wrote that;
1050
+ * this only updates THIS tab's in-memory reconciler layer + R9 observability. */
1051
+ private onCrossTabSettle;
1052
+ private drainBatchEntry;
1053
+ /** applied settlement for a drained unit — resolve the awaiting promise (if any) and dequeue the
1054
+ * durable record ALWAYS; then route the layer drop by `replayed` (T4 review fix — the ungated
1055
+ * fresh-apply drop):
1056
+ * - `replayed: true` — a resend whose commit predates this session's `Connect`, reusing the same
1057
+ * primitive `settleVerdict`'s `applied` case uses (T3's unconditional baseline-gated drop).
1058
+ * Drop-soundness (T3 watch item, scoped to replays ONLY): the drop is gated on baseline
1059
+ * adoption, not on the replay's carried commitTs — the entry's commit necessarily predates this
1060
+ * session's `Connect`, so it predates the baseline's read snapshot and the baseline already
1061
+ * renders the effect. Historical-ts-vs-current-base is therefore still covered; the drop is
1062
+ * flicker-free by the same one-pass rule as T3's handshake.
1063
+ * - a FRESH apply (`replayed` absent/false — this session's OWN first execution, a genuinely new
1064
+ * `ts`) — the argument above does NOT apply: nothing proves this commit predates the baseline,
1065
+ * so an unconditional drop here would remove a still-rendered layer before its authoritative row
1066
+ * ever appears (a flicker). Instead this routes through the normal same-session gate,
1067
+ * `onMutationSuccess` (the response `ts`) — the exact same shipped no-flicker discipline the
1068
+ * direct-send path uses at `MutationResponse` (see the `case "MutationResponse"` handler above):
1069
+ * hold the layer `completed` until this client's own reactive feed observes `ts`.
1070
+ *
1071
+ * T-crosstab: AFTER the local settle above, the leader (this tab, if it holds the drain lock)
1072
+ * also posts a targeted `settled` broadcast so a tab MIRRORING this same `(clientId, seq)` gets
1073
+ * the flicker-free fast path instead of waiting for its own next `enqueued`-triggered backstop
1074
+ * read. Posted only when the entry carries a durable `(clientId, seq)` — a plain non-outbox
1075
+ * mutation has nothing for another tab to have mirrored in the first place.
1076
+ */
1077
+ private drainSettleApplied;
1078
+ /** Terminal settlement for a drained unit (a coded server verdict, or the identity gate) — reject
1079
+ * the awaiting promise (coded), MARK the durable record `"failed"` (R9: never dequeue a terminal
1080
+ * failure — it persists until dismissed/retried), drop the layer, and (T5) refire `onMutationFailed`
1081
+ * / the dev-loud default when nothing awaited this failure this session. T-crosstab: AFTER all of
1082
+ * the above, also posts a targeted `failed` broadcast (same durability gate as `drainSettleApplied`
1083
+ * above) — a mirroring tab's own `onCrossTabSettle` fires ITS OWN `onMutationFailed`/dev-loud
1084
+ * default; it never double-delivers THIS tab's own notification above. */
1085
+ private drainSettleTerminal;
1086
+ /** A snapshot of the durable outbox — `usePendingMutations()`'s underlying read. `[]` without an
1087
+ * outbox configured (verdict §(d) R9). Each row's `retry()`/`dismiss()` close over the entry as
1088
+ * read HERE (no extra storage round-trip — `retry()` needs `args`/`seed`/`identityFingerprint`,
1089
+ * all captured already). */
1090
+ pendingMutations(): Promise<PendingMutationEntry[]>;
1091
+ /** T5 (R9, hazard 2's client half): count + oldest-age advisory over the durable queue — cheap
1092
+ * enough to poll for a "your offline changes may be lost soon" banner ahead of a storage cliff
1093
+ * (Safari's 7-day eviction). `{count: 0, oldestEnqueuedAt: undefined, oldestAgeMs: undefined}`
1094
+ * without an outbox configured, or with an empty one. */
1095
+ pendingSummary(): Promise<PendingSummary>;
1096
+ private toPendingMutationEntry;
1097
+ /** `entry.retry()` (R9): a FAILED entry only — everything else is a harmless no-op (verdict §(b):
1098
+ * "never reuse a seq for a new attempt"). Dequeues the OLD (failed-verdict) durable record and
1099
+ * builds a brand-new `PendingMutation` — fresh requestId/seq/order, the CURRENT session's identity
1100
+ * fingerprint (a fair shot even if identity rotated since the original failure), reconstructed
1101
+ * exactly like a hydrated entry (same udfPath/args/seed; the registry is consulted — there is no
1102
+ * live call-site closure for a retry either). No live promise is registered: like a hydrated
1103
+ * entry, its eventual outcome surfaces via `usePendingMutations()`/`onMutationFailed`, never a
1104
+ * returned `Promise<Value>` (the durable record outlives any promise). */
1105
+ private retryOutboxEntry;
1106
+ /** `entry.dismiss()` (R9): a FAILED entry only — permanently forget it without retrying. */
1107
+ private dismissOutboxEntry;
1108
+ /** T5 (R9): subscribe to "the durable outbox changed" — `usePendingMutations()`'s re-read trigger.
1109
+ * Fires on every local outbox-mutating op AND on an incoming cross-tab `outboxBroadcast` message. */
1110
+ onOutboxChange(listener: () => void): () => void;
1111
+ private notifyOutboxChange;
1112
+ /** Every durable-mutating outbox call site funnels through these four wrappers (instead of a bare
1113
+ * `this.outbox?.xxx(...)`) so `notifyOutboxChange()` — and a rejection's route through
1114
+ * `handleOutboxWriteError` — is never missed at a new call site. */
1115
+ private outboxAppend;
1116
+ private outboxDequeue;
1117
+ private outboxUpdateStatus;
1118
+ /** Record a terminal failure DURABLY (`status: "failed"` + the error) instead of dequeuing — R9:
1119
+ * "failed entries persist until dismissed/retried". */
1120
+ private outboxMarkFailed;
1121
+ /** Routes a rejected fire-and-forget durable-outbox write (append/updateStatus/dequeue/setMeta —
1122
+ * never awaited by its caller, per the write-behind contract) to observability instead of letting
1123
+ * it become an unhandled promise rejection, which several Node/Electron hosts treat as fatal by
1124
+ * default (a `fsOutbox` that has fail-stopped after a disk error rejects EVERY subsequent op —
1125
+ * see `outbox-fs.ts`'s `OutboxClosedError`). When the write carries a `(clientId, seq)` — every
1126
+ * case except a meta-only write — it routes through the SAME R9 channel as any other terminal
1127
+ * mutation failure (`onMutationFailed`, or `notifyMutationFailed`'s own dev-mode loud
1128
+ * `console.error` default). With no such record to attach the failure to (a meta write), a
1129
+ * dev-loud `console.error` is the floor — NEVER swallowed silently either way. */
1130
+ private handleOutboxWriteError;
1131
+ /** T5 (R9): fire `onMutationFailed` for a terminal durable failure with NO live promise awaiter
1132
+ * this session (`hadAwaiter` already checked by every call site) — or, absent a registered
1133
+ * handler, the dev-mode loud `console.error` default (spec-review: "the five-line courtesy" no
1134
+ * position shipped). A no-op for a non-outbox-tracked entry (`clientId`/`seq` undefined) — R9 is
1135
+ * entirely a durable-outbox concern. */
1136
+ private notifyMutationFailed;
1137
+ /** R9 "resume" refire (constructor-only, verdict §(d) Observability: "`onMutationFailed` refires
1138
+ * from durable records on resume"): a fresh `HelipodClient` instance has made zero `mutation()`
1139
+ * calls yet, so EVERY already-`"failed"` durable record found here is trivially "no live awaiter" —
1140
+ * Lunora's `hadAwaiter` check is unconditionally false at this point, no gating needed. */
1141
+ private refireDurableFailures;
1142
+ }
1143
+
1144
+ export { AUTH_REFRESH_UDF_PATH as A, reconnectDelayMs as B, type ClientTransport as C, DEFAULT_DRAIN_CHUNK_SIZE as D, webSocketTransport as E, type FunctionArgs as F, HelipodClient as H, type LoopbackLike as L, type MutationFailedInfo as M, NON_REPLAYABLE_MUTATION_DROPPED as N, type OutboxLockManager as O, type PendingMutation as P, type QueryErrorListener as Q, type RefArgs as R, type WebSocketTransportOptions as W, type PoisonPolicy as a, type AnyFunctionRef as b, type AnyFunctionReference as c, type ClientResetInfo as d, DEFAULT_DRAIN_INTERVAL_MS as e, type DrainHost as f, type FunctionReference as g, type FunctionReturnType as h, OFFLINE_IDENTITY_CHANGED as i, type OptimisticLocalStore as j, type OptimisticStoreView as k, type OptimisticUpdate as l, type OptimisticUpdateFn as m, type OutboxBroadcastLike as n, type OutboxBroadcastMessage as o, OutboxDrain as p, type OutboxDrainOptions as q, type PendingMutationEntry as r, type PendingSummary as s, type QueryListener as t, type RefReturn as u, anyApi as v, computeDrainBackoff as w, createOptimisticLocalStore as x, getFunctionPath as y, loopbackTransport as z };