@helipod/client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-DW55SNHW.js +286 -0
- package/dist/chunk-DW55SNHW.js.map +1 -0
- package/dist/chunk-I7ZJFW4E.js +2232 -0
- package/dist/chunk-I7ZJFW4E.js.map +1 -0
- package/dist/client-mJZjEkhK.d.ts +1144 -0
- package/dist/index.d.ts +268 -0
- package/dist/index.js +642 -0
- package/dist/index.js.map +1 -0
- package/dist/outbox-fs.d.ts +102 -0
- package/dist/outbox-fs.js +361 -0
- package/dist/outbox-fs.js.map +1 -0
- package/dist/outbox-storage-C6VHkXs9.d.ts +191 -0
- package/dist/react.d.ts +114 -0
- package/dist/react.js +139 -0
- package/dist/react.js.map +1 -0
- package/package.json +72 -0
|
@@ -0,0 +1,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 };
|