@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,2232 @@
1
+ import {
2
+ DEFAULT_OUTBOX_MAX_QUEUE_SIZE,
3
+ OUTBOX_VERSION,
4
+ OfflineClientResetError,
5
+ OutboxOverflowError,
6
+ defaultMintClientId,
7
+ mintIdentity
8
+ } from "./chunk-DW55SNHW.js";
9
+
10
+ // src/api.ts
11
+ function getFunctionPath(ref) {
12
+ return typeof ref === "string" ? ref : ref.__path;
13
+ }
14
+ function makeProxy(segments) {
15
+ return new Proxy(
16
+ {},
17
+ {
18
+ get(_target, prop) {
19
+ if (typeof prop !== "string") return void 0;
20
+ if (prop === "__path") {
21
+ if (segments.length < 2) return segments.join(":");
22
+ return `${segments.slice(0, -1).join("/")}:${segments[segments.length - 1]}`;
23
+ }
24
+ return makeProxy([...segments, prop]);
25
+ }
26
+ }
27
+ );
28
+ }
29
+ var anyApi = makeProxy([]);
30
+
31
+ // src/delivery-policy.ts
32
+ var MutationUndeliveredError = class extends Error {
33
+ constructor(message = "mutation outcome unknown: the connection closed before a response arrived") {
34
+ super(message);
35
+ this.name = "MutationUndeliveredError";
36
+ }
37
+ };
38
+ function closeDisposition(entries, opts = {}) {
39
+ const armed = opts.armed ?? false;
40
+ const reject = [];
41
+ const drop = [];
42
+ const retain = [];
43
+ const park = [];
44
+ for (const e of entries) {
45
+ switch (e.status.type) {
46
+ case "unsent":
47
+ retain.push(e.requestId);
48
+ break;
49
+ case "parked":
50
+ retain.push(e.requestId);
51
+ break;
52
+ case "inflight":
53
+ if (armed && e.durable) {
54
+ park.push(e.requestId);
55
+ drop.push(e.requestId);
56
+ } else {
57
+ reject.push(e.requestId);
58
+ drop.push(e.requestId);
59
+ }
60
+ break;
61
+ case "completed":
62
+ drop.push(e.requestId);
63
+ break;
64
+ }
65
+ }
66
+ return { reject, drop, retain, park };
67
+ }
68
+
69
+ // src/optimistic-store.ts
70
+ function isDevMode() {
71
+ const env = globalThis.process?.env;
72
+ return env?.NODE_ENV !== "production";
73
+ }
74
+ function freezeDev(value) {
75
+ if (isDevMode() && value !== null && typeof value === "object") Object.freeze(value);
76
+ return value;
77
+ }
78
+ function createOptimisticLocalStore(view, seed) {
79
+ const ordinals = /* @__PURE__ */ new Map();
80
+ return {
81
+ getQuery: (ref, args) => freezeDev(view.getQuery(ref, args)),
82
+ setQuery: (ref, args, value) => view.setQuery(ref, args, value),
83
+ getAllQueries: (ref) => view.getAllQueries(ref).map(({ args, value }) => ({
84
+ args,
85
+ value: freezeDev(value)
86
+ })),
87
+ placeholderId: (table) => {
88
+ const n = ordinals.get(table) ?? 0;
89
+ ordinals.set(table, n + 1);
90
+ return `${seed.entropy}:${table}:${n}`;
91
+ },
92
+ now: () => seed.now
93
+ };
94
+ }
95
+
96
+ // src/outbox-drain.ts
97
+ import { jsonToConvex } from "@helipod/values";
98
+ var DRAIN_INITIAL_BACKOFF_MS = 250;
99
+ var DRAIN_BACKOFF_BASE = 2;
100
+ var DRAIN_MAX_BACKOFF_MS = 3e4;
101
+ function computeDrainBackoff(attempts, rng = Math.random) {
102
+ const raw = DRAIN_INITIAL_BACKOFF_MS * DRAIN_BACKOFF_BASE ** (attempts + 1);
103
+ const jittered = Math.round(raw * (0.5 + 0.5 * rng()));
104
+ return Math.min(jittered, DRAIN_MAX_BACKOFF_MS);
105
+ }
106
+ var DEFAULT_DRAIN_CHUNK_SIZE = 50;
107
+ var DEFAULT_DRAIN_INTERVAL_MS = 5e3;
108
+ function probeLockManager() {
109
+ const nav = globalThis.navigator;
110
+ const locks = nav?.locks;
111
+ if (locks && typeof locks.request === "function") {
112
+ return {
113
+ request: (name, options, callback) => locks.request(name, options, callback)
114
+ };
115
+ }
116
+ return void 0;
117
+ }
118
+ var OFFLINE_IDENTITY_CHANGED = "OFFLINE_IDENTITY_CHANGED";
119
+ var AUTH_REFRESH_UDF_PATH = "auth:refresh";
120
+ var NON_REPLAYABLE_MUTATION_DROPPED = "NON_REPLAYABLE_MUTATION_DROPPED";
121
+ function dropIfNonReplayable(outbox, entry) {
122
+ if (entry.udfPath !== AUTH_REFRESH_UDF_PATH) return false;
123
+ console.error(
124
+ `[helipod] outbox: dropping a queued "${entry.udfPath}" entry at hydrate \u2014 replaying an auth refresh is never safe (a stale rotation would trip reuse-detection and force-sign-out an honest user); this entry predates the client's transient-mutation fix`
125
+ );
126
+ void outbox.updateStatus(entry.clientId, entry.seq, "failed", {
127
+ message: `mutation "${entry.udfPath}" dropped at hydrate: durable auth-refresh replay is never safe`,
128
+ code: NON_REPLAYABLE_MUTATION_DROPPED
129
+ }).catch(() => {
130
+ });
131
+ return true;
132
+ }
133
+ var OutboxDrain = class {
134
+ host;
135
+ lockName;
136
+ locksOption;
137
+ poisonPolicy;
138
+ chunkSize;
139
+ intervalMs;
140
+ backoffMs;
141
+ onPause;
142
+ started = false;
143
+ leader = false;
144
+ stopped = false;
145
+ paused = false;
146
+ hydrated = false;
147
+ /** The in-flight chunk: requestId → in-log entry. Non-null iff one unacked chunk is outstanding. */
148
+ active = null;
149
+ /** Re-entrancy guard around `maybeDrainNext`'s `await whenBaselineAdopted()` (one flush at a time). */
150
+ flushScheduling = false;
151
+ /** Consecutive transient-stop count — the backoff attempt number (reset on any forward progress). */
152
+ transientAttempts = 0;
153
+ abort = new AbortController();
154
+ releaseLeadership;
155
+ intervalTimer;
156
+ backoffTimer;
157
+ constructor(host, opts) {
158
+ this.host = host;
159
+ this.lockName = opts.lockName;
160
+ this.locksOption = opts.locks;
161
+ this.poisonPolicy = opts.poisonPolicy ?? "skip";
162
+ this.chunkSize = opts.chunkSize ?? DEFAULT_DRAIN_CHUNK_SIZE;
163
+ this.intervalMs = opts.intervalMs ?? DEFAULT_DRAIN_INTERVAL_MS;
164
+ this.backoffMs = opts.backoffMs ?? ((attempts) => computeDrainBackoff(attempts));
165
+ this.onPause = opts.onPause;
166
+ }
167
+ /** Acquire leadership (Web Locks, or single-tab fallback) and, once leader, hydrate + drain. Safe
168
+ * to call once; a no-op afterward. */
169
+ start() {
170
+ if (this.started) return;
171
+ this.started = true;
172
+ const locks = this.locksOption === void 0 ? probeLockManager() : this.locksOption ?? void 0;
173
+ if (!locks) {
174
+ queueMicrotask(() => void this.becomeLeader());
175
+ return;
176
+ }
177
+ void locks.request(this.lockName, { signal: this.abort.signal }, async () => {
178
+ if (this.stopped) return;
179
+ await this.becomeLeader();
180
+ }).catch(() => {
181
+ });
182
+ }
183
+ /** Stop cleanly (client close, or mid-drain lock loss). Releases leadership, cancels a pending
184
+ * lock request, and clears every timer — the durable records make a successor leader safe. */
185
+ stop() {
186
+ if (this.stopped) return;
187
+ this.stopped = true;
188
+ this.leader = false;
189
+ this.active = null;
190
+ if (this.intervalTimer !== void 0) clearInterval(this.intervalTimer);
191
+ if (this.backoffTimer !== void 0) clearTimeout(this.backoffTimer);
192
+ this.intervalTimer = void 0;
193
+ this.backoffTimer = void 0;
194
+ this.abort.abort();
195
+ this.releaseLeadership?.();
196
+ }
197
+ /** Wake the drain (enqueue / reconnect-after-baseline / an explicit nudge). Deferred to a
198
+ * microtask so a synchronous settling frame (e.g. a `ConnectAck` emitted right after reopen)
199
+ * always settles its entries BEFORE the drain re-reads the queue. */
200
+ nudge() {
201
+ if (this.stopped || !this.leader) return;
202
+ queueMicrotask(() => void this.maybeDrainNext());
203
+ }
204
+ /** True iff `requestId` belongs to the drain's in-flight chunk — `client.ts` routes that unit's
205
+ * `MutationResponse` here instead of down the direct-send path. */
206
+ handles(requestId) {
207
+ return this.active?.has(requestId) ?? false;
208
+ }
209
+ /** The transport dropped (a reconnect-class close, NOT the client's `close()` — leadership and
210
+ * the interval survive). The in-flight chunk's unresponded units will never get a response on
211
+ * the new server session, so revert them to re-sendable and clear the chunk NOW — otherwise
212
+ * `canDrain()`'s one-unacked invariant (`active === null`) would wedge the drain for the rest of
213
+ * this tab session (no chunk would ever flush again, and every new mutation would queue behind
214
+ * the stuck backlog until overflow). Called by `client.ts#onTransportClosed` BEFORE the
215
+ * reconciler's S4 close rules run, so the reverted (`unsent`) units are simply retained by the
216
+ * close disposition, promises pending, ready for the reconnect handshake + re-drain. A pending
217
+ * transient-backoff timer is also cleared — the reconnect handshake re-drives the drain, and
218
+ * `canDrain()` would no-op a stale timer against a closed transport anyway. */
219
+ onTransportClosed() {
220
+ this.revertActive();
221
+ this.transientAttempts = 0;
222
+ if (this.backoffTimer !== void 0) {
223
+ clearTimeout(this.backoffTimer);
224
+ this.backoffTimer = void 0;
225
+ }
226
+ }
227
+ /** True while an unacked chunk is in flight — `client.ts#hasOutboxBacklog` counts it so a new
228
+ * `mutation()` enqueues BEHIND the chunk instead of direct-sending ahead of it (the FIFO rule:
229
+ * "while the queue is non-empty, new mutations enqueue behind it"). A chunk's units are
230
+ * `inflight`, which the unsent/parked backlog scan alone would miss when the chunk consumed the
231
+ * entire backlog. */
232
+ get hasActiveChunk() {
233
+ return this.active !== null;
234
+ }
235
+ /** @internal test/debug — the drain halted on a coded failure under `poisonPolicy: "pause"`. */
236
+ get isPaused() {
237
+ return this.paused;
238
+ }
239
+ /** @internal test/debug — the drain currently holds leadership. */
240
+ get isLeader() {
241
+ return this.leader;
242
+ }
243
+ /** Resume a `pause`d drain (T5 owns the app-facing retry surface; this is the mechanism). */
244
+ resume() {
245
+ if (!this.paused) return;
246
+ this.paused = false;
247
+ this.transientAttempts = 0;
248
+ this.nudge();
249
+ }
250
+ async becomeLeader() {
251
+ if (this.stopped) return;
252
+ this.leader = true;
253
+ await this.hydrateOnce();
254
+ if (this.stopped) return;
255
+ if (this.host.transportOpen() && this.host.drainable().length > 0) this.host.ensureInitialHandshake();
256
+ this.startInterval();
257
+ void this.maybeDrainNext();
258
+ await new Promise((resolve) => {
259
+ this.releaseLeadership = resolve;
260
+ });
261
+ }
262
+ startInterval() {
263
+ if (this.intervalTimer !== void 0 || this.intervalMs <= 0) return;
264
+ this.intervalTimer = setInterval(() => void this.maybeDrainNext(), this.intervalMs);
265
+ this.intervalTimer.unref?.();
266
+ }
267
+ /** Load the durable queue into the log (once), under recorded ids, then prune dead meta rows.
268
+ * Only STILL-ACTIVE entries (`unsent`/`inflight`/`parked`) are hydrated into the log — a `failed`
269
+ * entry left behind by a prior session is a terminal, accessor-only record (surfaced via
270
+ * `pendingMutations()`/the constructor's `refireDurableFailures` R9 scan) and must never be
271
+ * resurrected here as a fresh `unsent` log entry: `host.addHydrated` unconditionally stamps
272
+ * `status: "unsent"` regardless of the persisted status, so hydrating a `failed` row verbatim
273
+ * would render a phantom optimistic row for an already-dead mutation AND make it drainable again
274
+ * (a resend of something the server/R9 already settled), on top of double-firing R9 alongside the
275
+ * constructor's unconditional resume scan. Mirrors the same filter `mirrorFromStore`'s cross-tab
276
+ * backstop applies (`client.ts`). */
277
+ async hydrateOnce() {
278
+ if (this.hydrated) return;
279
+ this.hydrated = true;
280
+ const { entries } = await this.host.outbox.loadAll();
281
+ for (const e of entries) {
282
+ if (e.status !== "unsent" && e.status !== "inflight" && e.status !== "parked") continue;
283
+ if (dropIfNonReplayable(this.host.outbox, e)) continue;
284
+ this.host.addHydrated(e);
285
+ }
286
+ await this.pruneDeadMeta();
287
+ }
288
+ /** Delete meta rows for clientIds with no live entries and that aren't the current session's — the
289
+ * T1-flagged unbounded-tiny-rows gap (one dead row accrues per prior tab-session + every reset).
290
+ * Capability-gated: a minimal `OutboxStorage` (a bare test double) may omit the two optional
291
+ * methods, in which case pruning is simply skipped. */
292
+ async pruneDeadMeta() {
293
+ const list = this.host.outbox.listMetaClientIds;
294
+ const del = this.host.outbox.deleteMeta;
295
+ if (!list || !del) return;
296
+ let ids;
297
+ try {
298
+ ids = await list.call(this.host.outbox);
299
+ } catch {
300
+ return;
301
+ }
302
+ const current = this.host.currentClientId();
303
+ const live = /* @__PURE__ */ new Set();
304
+ for (const e of this.host.drainable()) if (e.clientId !== void 0) live.add(e.clientId);
305
+ for (const id of ids) {
306
+ if (id === current || live.has(id)) continue;
307
+ try {
308
+ await del.call(this.host.outbox, id);
309
+ } catch {
310
+ }
311
+ }
312
+ }
313
+ canDrain() {
314
+ return this.leader && !this.stopped && !this.paused && this.active === null && this.host.transportOpen() && this.host.isArmed() && this.host.drainable().length > 0;
315
+ }
316
+ /** Flush the next chunk when eligible. The `flushScheduling` guard + the synchronous `this.active`
317
+ * set (in `flushChunk`) keep exactly one chunk in flight across the `await` yield. */
318
+ async maybeDrainNext() {
319
+ if (this.flushScheduling || !this.canDrain()) return;
320
+ this.flushScheduling = true;
321
+ try {
322
+ await this.host.whenBaselineAdopted();
323
+ if (!this.canDrain()) return;
324
+ this.flushChunk();
325
+ } finally {
326
+ this.flushScheduling = false;
327
+ }
328
+ }
329
+ flushChunk() {
330
+ const all = this.host.drainable();
331
+ const currentFingerprint = this.host.currentFingerprint();
332
+ const chunk = [];
333
+ const map = /* @__PURE__ */ new Map();
334
+ let settledIdentityFailure = false;
335
+ for (const entry of all) {
336
+ if (chunk.length >= this.chunkSize) break;
337
+ if (entry.identityFingerprint !== void 0 && entry.identityFingerprint !== currentFingerprint) {
338
+ console.error(
339
+ `[helipod] outbox: dropping mutation "${entry.udfPath}" \u2014 it was queued under a different identity than the current session (${OFFLINE_IDENTITY_CHANGED})`
340
+ );
341
+ this.host.settleTerminal(
342
+ entry.requestId,
343
+ OFFLINE_IDENTITY_CHANGED,
344
+ `mutation "${entry.udfPath}" dropped: the session identity changed since it was queued`
345
+ );
346
+ settledIdentityFailure = true;
347
+ continue;
348
+ }
349
+ this.host.setStatus(entry, "inflight");
350
+ map.set(entry.requestId, entry);
351
+ chunk.push(this.host.batchEntry(entry));
352
+ }
353
+ if (chunk.length === 0) {
354
+ if (settledIdentityFailure) this.nudge();
355
+ return;
356
+ }
357
+ this.active = map;
358
+ this.host.sendBatch(chunk);
359
+ }
360
+ /** Route one unit's `MutationResponse` (only ever called for a requestId in the active chunk). */
361
+ onResponse(msg) {
362
+ const active = this.active;
363
+ const entry = active?.get(msg.requestId);
364
+ if (!active || !entry) return;
365
+ if (msg.success) {
366
+ active.delete(msg.requestId);
367
+ const value = this.resolveResponseValue(msg);
368
+ this.host.settleApplied(msg.requestId, value, msg.replayed === true, msg.ts);
369
+ this.onForwardProgress();
370
+ return;
371
+ }
372
+ if (msg.code !== void 0) {
373
+ if (this.poisonPolicy === "pause") {
374
+ this.paused = true;
375
+ console.error(
376
+ `[helipod] outbox drain PAUSED on a coded failure of "${entry.udfPath}" (${msg.code}); poisonPolicy="pause" \u2014 the queue is halted until resumed`
377
+ );
378
+ this.onPause?.({ requestId: msg.requestId, udfPath: entry.udfPath, code: msg.code });
379
+ this.revertActive();
380
+ return;
381
+ }
382
+ active.delete(msg.requestId);
383
+ this.host.settleTerminal(msg.requestId, msg.code, `mutation "${entry.udfPath}" failed`);
384
+ this.onForwardProgress();
385
+ return;
386
+ }
387
+ this.transientAttempts++;
388
+ this.revertActive();
389
+ const delay = this.backoffMs(this.transientAttempts);
390
+ this.backoffTimer = setTimeout(() => {
391
+ this.backoffTimer = void 0;
392
+ void this.maybeDrainNext();
393
+ }, delay);
394
+ this.backoffTimer.unref?.();
395
+ }
396
+ resolveResponseValue(msg) {
397
+ if (msg.valueMissing) return null;
398
+ return msg.value !== void 0 ? jsonToConvex(msg.value) : null;
399
+ }
400
+ /** A unit settled (applied or coded-terminal): if the chunk is now empty, advance to the next. */
401
+ onForwardProgress() {
402
+ this.transientAttempts = 0;
403
+ if (this.active && this.active.size === 0) {
404
+ this.active = null;
405
+ this.nudge();
406
+ }
407
+ }
408
+ /** Revert every still-in-flight entry of the active chunk to `unsent` (re-sendable) and clear the
409
+ * chunk — used by the transient-stop and pause paths. The units that got no response are exactly
410
+ * those still in `active`. */
411
+ revertActive() {
412
+ if (!this.active) return;
413
+ for (const entry of this.active.values()) this.host.setStatus(entry, "unsent");
414
+ this.active = null;
415
+ }
416
+ };
417
+
418
+ // src/connect-handshake.ts
419
+ var HELD_STATUSES = /* @__PURE__ */ new Set(["unsent", "inflight", "parked"]);
420
+ function outboxHeldFromLog(entries) {
421
+ const refs = [];
422
+ for (const e of entries) {
423
+ if (e.clientId !== void 0 && e.seq !== void 0 && HELD_STATUSES.has(e.status.type)) {
424
+ refs.push({ clientId: e.clientId, seq: e.seq });
425
+ }
426
+ }
427
+ return refs;
428
+ }
429
+ function outboxHeldFromStore(entries) {
430
+ const refs = [];
431
+ for (const e of entries) {
432
+ if (HELD_STATUSES.has(e.status)) refs.push({ clientId: e.clientId, seq: e.seq });
433
+ }
434
+ return refs;
435
+ }
436
+ function outboxAckedThrough(held) {
437
+ const lowestHeld = /* @__PURE__ */ new Map();
438
+ for (const ref of held) {
439
+ const cur = lowestHeld.get(ref.clientId);
440
+ if (cur === void 0 || ref.seq < cur) lowestHeld.set(ref.clientId, ref.seq);
441
+ }
442
+ const acked = [];
443
+ for (const [clientId, minSeq] of lowestHeld) {
444
+ if (minSeq > 0) acked.push({ clientId, seq: minSeq - 1 });
445
+ }
446
+ return acked;
447
+ }
448
+ function buildConnectMessage(sessionId, clientId, held) {
449
+ return {
450
+ type: "Connect",
451
+ sessionId,
452
+ ...clientId !== void 0 ? { clientId } : {},
453
+ held,
454
+ ackedThrough: outboxAckedThrough(held),
455
+ // DLR Stage 2a: advertise by-id diff support. The server records this on any `Connect` (even a
456
+ // resume-handshake one) and sends `QueryDiff` only to a session that advertised it.
457
+ supportsQueryDiff: true
458
+ };
459
+ }
460
+
461
+ // src/identity-fingerprint.ts
462
+ async function sha256Hex(input) {
463
+ const bytes = new TextEncoder().encode(input);
464
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
465
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
466
+ }
467
+ function sessionFingerprintKey(sessionId) {
468
+ return `session:${sessionId}`;
469
+ }
470
+
471
+ // src/client.ts
472
+ import {
473
+ versionsEqual,
474
+ INITIAL_VERSION
475
+ } from "@helipod/sync";
476
+ import { convexToJson as convexToJson2, jsonToConvex as jsonToConvex4 } from "@helipod/values";
477
+
478
+ // src/layered-store.ts
479
+ import { convexToJson, jsonToConvex as jsonToConvex2 } from "@helipod/values";
480
+ import { applyChanges, driftChecksum } from "@helipod/sync";
481
+ import { base64ToBytes, compareKeyBytes } from "@helipod/index-key-codec";
482
+ function queryHash(path, argsJson) {
483
+ return `${path}:${JSON.stringify(argsJson)}`;
484
+ }
485
+ function renderByIdValue(rows) {
486
+ for (const rv of rows.values()) return jsonToConvex2(rv.row);
487
+ return void 0;
488
+ }
489
+ function renderRangeValue(rows, orderDir) {
490
+ const entries = [...rows.values()].sort(
491
+ (a, b) => compareKeyBytes(base64ToBytes(a.orderKey ?? ""), base64ToBytes(b.orderKey ?? ""))
492
+ );
493
+ if (orderDir === "desc") entries.reverse();
494
+ return entries.map((e) => jsonToConvex2(e.row));
495
+ }
496
+ function renderPageValue(rows, orderDir, meta) {
497
+ return {
498
+ page: renderRangeValue(rows, orderDir),
499
+ nextCursor: meta.nextCursor,
500
+ hasMore: meta.hasMore,
501
+ scanCapped: meta.scanCapped
502
+ };
503
+ }
504
+ var LayeredQueryStore = class {
505
+ byHash = /* @__PURE__ */ new Map();
506
+ byId = /* @__PURE__ */ new Map();
507
+ create(queryId, path, args, hash) {
508
+ const sub = {
509
+ queryId,
510
+ path,
511
+ args,
512
+ hash,
513
+ serverValue: void 0,
514
+ composedValue: void 0,
515
+ answered: false,
516
+ lastHash: void 0,
517
+ listeners: /* @__PURE__ */ new Set()
518
+ };
519
+ this.byHash.set(hash, sub);
520
+ this.byId.set(queryId, sub);
521
+ return sub;
522
+ }
523
+ remove(hash) {
524
+ const sub = this.byHash.get(hash);
525
+ if (!sub) return;
526
+ this.byHash.delete(hash);
527
+ this.byId.delete(sub.queryId);
528
+ }
529
+ /** Set a subscription's authoritative base (from a `QueryUpdated` modification). Does NOT fire —
530
+ * `recompose` owns all listener firing so the base+drop+rebuild happen as one atomic frame.
531
+ * `hash` is the server-minted result fingerprint carried on the same modification — stored
532
+ * verbatim (undefined clears `lastHash`, e.g. an old server that never sends `hash`).
533
+ *
534
+ * DLR Stage 2b (Finding 2): a `QueryUpdated` is the ONLY way a DIFFABLE (range/by-id) sub ever
535
+ * reaches this method — the server sends a full RERUN answer instead of a `QueryDiff` only on a
536
+ * RERUN-fallback (a `SetAuth` identity switch drops the server-side row-map; a fleet-forwarded
537
+ * RERUN does the same), never in steady state (steady state for a diffable sub is `QueryDiff`).
538
+ * So revert the sub to a plain RERUN-rendered sub here: drop the PRIOR identity's `diffRows` and
539
+ * `renderMode`. Otherwise a subsequent incremental `QueryDiff` would merge onto the stale previous-
540
+ * identity row-map and `renderRangeValue` the prior user's rows for ~1 RTT until drift-resync heals
541
+ * — a transient cross-identity leak on an auth-scoped range query. Cleared → a later incremental
542
+ * diff instead hits `reconcile.ts`'s uninitialized-render-mode guard (→ resync), and a later
543
+ * `QueryDiff` reset re-establishes `renderMode`/`diffRows` cleanly. The immediate frame renders
544
+ * `value` directly (recompose reads `serverValue`, never `diffRows`), so no stale row is shown. */
545
+ setServerValue(sub, value, hash) {
546
+ sub.serverValue = value;
547
+ sub.lastHash = hash;
548
+ sub.answered = true;
549
+ sub.diffRows = void 0;
550
+ sub.renderMode = void 0;
551
+ sub.orderDir = void 0;
552
+ sub.pageMeta = void 0;
553
+ }
554
+ /** Mark a subscription as having received its first reply WITHOUT a base value (from a
555
+ * `QueryFailed` modification — there is no server row to render, only an error). See the
556
+ * `answered` field doc for why this is distinct from `setServerValue`. */
557
+ markAnswered(sub) {
558
+ sub.answered = true;
559
+ }
560
+ /** Ingest a `QueryUnchanged` modification (subscription resume, design 2025-11-28): the fresh
561
+ * server-side re-run's hash matched what this session echoed, so there is no new value on the
562
+ * wire — `serverValue`/`lastHash`/`composedValue` all stay exactly as they were. It still counts
563
+ * as a delivered reply (`answered = true`, same as `setServerValue`/`markAnswered`) — see the
564
+ * `answered` field doc. The caller (`reconcile.ts`) is responsible for passing this sub's `hash`
565
+ * into `recompose`'s `forceNotify` set — see that method's doc for why a `QueryUnchanged` must
566
+ * still fire listeners to introduce no new observable difference for app code. */
567
+ markUnchanged(sub) {
568
+ sub.answered = true;
569
+ }
570
+ /**
571
+ * DLR Stage 2a/2b — apply a `QueryDiff`'s changes to a DIFFABLE query's keyed row-map, re-derive
572
+ * the rendered `serverValue` as a FRESH reference (so `recompose`'s reference-inequality check
573
+ * fires listeners), and report whether the client-recomputed drift checksum diverged from the
574
+ * server's. The caller (the reconciler) triggers a scoped resync on `drift === true`.
575
+ *
576
+ * `reset` (2b — widened from a bare `true`) governs where the diff applies FROM and how the
577
+ * result renders:
578
+ * - `undefined` (an incremental diff): merges `changes` onto the RUNNING `sub.diffRows` map —
579
+ * `sub.renderMode`/`orderDir` are left exactly as they were from the last reset.
580
+ * - `true` (a by-id reset): the classic 2a reset — `sub.renderMode = "byid"`.
581
+ * - `{ mode, orderDir }` (a range reset, or a future non-byid mode): `sub.renderMode = mode`,
582
+ * `sub.orderDir = orderDir`.
583
+ * Both reset forms rebuild from an EMPTY map, never merge onto `sub.diffRows` — a reset is a full
584
+ * re-baseline (the initial subscribe answer, or a range resync after a drift/table-invalidation),
585
+ * so a row that's no longer in the result set must disappear, not linger from the prior baseline.
586
+ * `applyChanges` is copy-on-write, so `diffRows` becomes a new Map each apply — the client never
587
+ * mutates a map a listener may still hold.
588
+ *
589
+ * `lastHash` OWNERSHIP (DLR 2b Task 10): this method does NOT touch `sub.lastHash` — the caller
590
+ * (`reconcile.ts#ingestTransition`'s `QueryDiff` arm) owns it, since only the caller knows whether
591
+ * this call was a RESET (carries the server's resume fingerprint, `mod.hash`, to store) or an
592
+ * INCREMENTAL diff (no fingerprint — the caller clears it instead). Leaving it here would mean
593
+ * either always clearing it (breaking resume for every diffable sub, the Task 10 regression this
594
+ * fixes) or reaching into wire-message fields (`mod.hash`) this store-level method doesn't receive.
595
+ *
596
+ * NOTE ON AN UNINITIALIZED `renderMode` (DLR 2b Task 10 residual): this method does NOT itself
597
+ * guard against an INCREMENTAL diff (`reset === undefined`) arriving while `renderMode` has never
598
+ * been established — it can't distinguish that from the ordinary "first-ever call, reset omitted"
599
+ * shorthand several of this file's own unit tests use to mean "build from an empty baseline" (a
600
+ * pattern that predates `reset`'s widening and has no way here to tell "legitimately fresh" apart
601
+ * from "already answered by something else, never diff-initialized"). The caller
602
+ * (`reconcile.ts#ingestTransition`) owns that distinction instead — it has `sub.answered`'s
603
+ * PRE-CALL value on hand, which this method's own post-call mutation of `sub.answered` would
604
+ * otherwise erase, and doing it there also means the store-level surface tested here stays exactly
605
+ * as it always was.
606
+ */
607
+ applyDiff(sub, changes, checksum, reset) {
608
+ if (reset === true) {
609
+ sub.renderMode = "byid";
610
+ } else if (reset) {
611
+ sub.renderMode = reset.mode;
612
+ sub.orderDir = reset.orderDir;
613
+ if (reset.mode === "page") {
614
+ sub.pageMeta = { nextCursor: reset.nextCursor, hasMore: reset.hasMore, scanCapped: reset.scanCapped };
615
+ }
616
+ }
617
+ const base = reset !== void 0 ? /* @__PURE__ */ new Map() : sub.diffRows ?? /* @__PURE__ */ new Map();
618
+ const next = applyChanges(base, changes);
619
+ sub.diffRows = next;
620
+ sub.serverValue = sub.renderMode === "page" ? renderPageValue(next, sub.orderDir ?? "asc", sub.pageMeta) : sub.renderMode === "range" ? renderRangeValue(next, sub.orderDir ?? "asc") : renderByIdValue(next);
621
+ sub.answered = true;
622
+ return { drift: driftChecksum(next) !== checksum };
623
+ }
624
+ serverValueOf(hash) {
625
+ return this.byHash.get(hash)?.serverValue;
626
+ }
627
+ /**
628
+ * Rebuild every subscription's `composedValue` = replay the surviving updates (in `requestId`
629
+ * order) over the current `serverValue`s, then fire listeners wherever the composed value's
630
+ * reference changed. Transactional per-updater: a throwing updater is buffered-and-discarded and
631
+ * returned as a `ReplayDrop` (the caller removes it from the log); the rebuild always completes.
632
+ *
633
+ * @param entries surviving pending mutations, in replay order
634
+ * @param invokeUpdate runs one entry's updater against the view (kept in the reconciler so the
635
+ * entry `seed` can feed `placeholderId()`/`now()` when T5 wires them)
636
+ * @param forceNotify subscription `hash`es (subscription resume) to fire listeners for even when
637
+ * the composed reference didn't change — a `QueryUnchanged` ingest has no new
638
+ * value to swap in (`serverValue` is retained as-is), yet this store has NEVER
639
+ * done content-based dedup for a plain `QueryUpdated` either: `setServerValue`
640
+ * unconditionally overwrites `serverValue`, and — because `jsonToConvex` mints
641
+ * a fresh object on every decode — the reference-inequality check below ALWAYS
642
+ * fires for a content-identical `QueryUpdated` today (verified in
643
+ * `test/resume-client.test.ts`'s Step-1 comment). `forceNotify` reproduces
644
+ * that same always-fires behavior for `QueryUnchanged`, so it introduces no new
645
+ * observable difference for app code. One nuance worth flagging: a
646
+ * `QueryUnchanged` notify hands listeners the RETAINED `serverValue` reference
647
+ * (nothing new was decoded), whereas a value-equal `QueryUpdated` today mints a
648
+ * fresh object every time — so a reference-equality consumer (e.g. React's
649
+ * `useState`, which bails out of a re-render when the new state === the old
650
+ * state) may legitimately skip one redundant re-render on this path that it
651
+ * wouldn't skip on an equivalent full send. Content identity is hash-proven
652
+ * either way; only the object identity differs.
653
+ */
654
+ recompose(entries, invokeUpdate, forceNotify) {
655
+ const overlay = /* @__PURE__ */ new Map();
656
+ const dropped = [];
657
+ for (const entry of entries) {
658
+ if (!entry.update) continue;
659
+ const local = /* @__PURE__ */ new Map();
660
+ const touched = /* @__PURE__ */ new Set();
661
+ const read = (hash) => local.has(hash) ? local.get(hash) : overlay.has(hash) ? overlay.get(hash) : this.serverValueOf(hash);
662
+ const view = {
663
+ getQuery: (ref, args = {}) => read(queryHash(getFunctionPath(ref), convexToJson(args))),
664
+ setQuery: (ref, args, value) => {
665
+ const h = queryHash(getFunctionPath(ref), convexToJson(args));
666
+ local.set(h, value);
667
+ touched.add(h);
668
+ },
669
+ getAllQueries: (ref) => {
670
+ const path = getFunctionPath(ref);
671
+ const out = [];
672
+ for (const s of this.byHash.values()) {
673
+ if (s.path === path) out.push({ args: jsonToConvex2(s.args), value: read(s.hash) });
674
+ }
675
+ return out;
676
+ }
677
+ };
678
+ try {
679
+ invokeUpdate(entry, view);
680
+ } catch (error) {
681
+ dropped.push({ requestId: entry.requestId, error });
682
+ continue;
683
+ }
684
+ for (const h of touched) overlay.set(h, local.get(h));
685
+ entry.touched = touched;
686
+ }
687
+ for (const sub of this.byHash.values()) {
688
+ const next = overlay.has(sub.hash) ? overlay.get(sub.hash) : sub.serverValue;
689
+ if (next !== sub.composedValue || forceNotify?.has(sub.hash)) {
690
+ sub.composedValue = next;
691
+ if (next !== void 0) {
692
+ for (const l of sub.listeners) l.onUpdate(next);
693
+ }
694
+ }
695
+ }
696
+ return dropped;
697
+ }
698
+ };
699
+
700
+ // src/reconcile.ts
701
+ import { jsonToConvex as jsonToConvex3 } from "@helipod/values";
702
+
703
+ // src/mutation-log.ts
704
+ var MutationLog = class {
705
+ entries = /* @__PURE__ */ new Map();
706
+ add(entry) {
707
+ this.entries.set(entry.requestId, entry);
708
+ }
709
+ get(requestId) {
710
+ return this.entries.get(requestId);
711
+ }
712
+ delete(requestId) {
713
+ return this.entries.delete(requestId);
714
+ }
715
+ /** All entries in requestId (insertion) order — the replay order. */
716
+ entriesInOrder() {
717
+ return [...this.entries.values()];
718
+ }
719
+ get size() {
720
+ return this.entries.size;
721
+ }
722
+ };
723
+
724
+ // src/reconcile.ts
725
+ function versionCoversCommit(maxObservedTs, commitTs) {
726
+ return commitTs <= maxObservedTs && commitTs > 0;
727
+ }
728
+ var DEFAULT_GATE_TIMEOUT_MS = 1e4;
729
+ var Reconciler = class {
730
+ constructor(store, opts = {}) {
731
+ this.store = store;
732
+ this.gateTimeoutMs = opts.gateTimeoutMs ?? DEFAULT_GATE_TIMEOUT_MS;
733
+ this.onDrift = opts.onDrift;
734
+ }
735
+ store;
736
+ log = new MutationLog();
737
+ observedTs = 0;
738
+ gateTimeoutMs;
739
+ timers = /* @__PURE__ */ new Map();
740
+ /** DLR Stage 2a — invoked when a `QueryDiff`'s client-recomputed drift checksum diverges from the
741
+ * server's. The client wires this to a scoped resync (`client.ts` passes `() => this.resync()`).
742
+ * For by-id the diff is trivially correct so this should never fire in normal operation; it is the
743
+ * safety net that 2b's harder differ inherits already proven. */
744
+ onDrift;
745
+ /** Max `endVersion.ts` observed this session (reset on close). Exposed for tests. */
746
+ get maxObservedTs() {
747
+ return this.observedTs;
748
+ }
749
+ entries() {
750
+ return this.log.entriesInOrder();
751
+ }
752
+ /** T6: `unsent` entries in FIFO (requestId/insertion) order — flushed on transport reopen. */
753
+ unsentInOrder() {
754
+ return this.log.entriesInOrder().filter((e) => e.status.type === "unsent");
755
+ }
756
+ /** T3: the log entry for `requestId` (or `undefined`). Lets `client.ts` read an outbox entry's
757
+ * recorded `(clientId, seq)` for dequeue-on-success BEFORE a settling event removes it from the
758
+ * log. Read-only — all mutation still flows through the events below. */
759
+ getEntry(requestId) {
760
+ return this.log.get(requestId);
761
+ }
762
+ /**
763
+ * The drop-on-verdict-after-baseline rule (T3, verdict §(d) "Reload and rendering — the fork,
764
+ * decided"). A CROSS-SESSION entry whose recorded verdict is `applied` (learned via the `Connect`
765
+ * handshake's `ConnectAck` or a drain replay-ack) drops its optimistic layer once the reconnect
766
+ * baseline Transition has been ADOPTED. Sound because the entry's commit necessarily predates
767
+ * this session's `Connect`, hence predates the baseline's read snapshot — so the baseline already
768
+ * renders the effect; removing the (registry-rebuilt, T5) layer in the SAME one-pass `rebuild()`
769
+ * is flicker-free (the authoritative rows are already in the base, so the composed view never
770
+ * blinks the row away). The rule is deliberately **layer-agnostic**: the entry may hold no layer
771
+ * at all — a parked entry (its `update` was cleared at close), a plain non-optimistic mutation,
772
+ * or (until T5 ships the registry) any hydrated entry — in which case this is a clean removal.
773
+ *
774
+ * The CALLER (`client.ts`) is responsible for gating the call on baseline adoption; this method
775
+ * assumes that gate has already passed. `versionCoversCommit` (the same-session gate predicate)
776
+ * is intentionally untouched — same-session entries still drop on observed inclusion, never here.
777
+ */
778
+ onVerdictAfterBaseline(requestId) {
779
+ if (!this.log.get(requestId)) return;
780
+ this.log.delete(requestId);
781
+ this.clearTimer(requestId);
782
+ this.rebuild();
783
+ }
784
+ /**
785
+ * T5: add a HYDRATED (cross-reload) durable entry to the log — `client.ts#addHydratedEntry`'s
786
+ * counterpart to `initiate()` for a live call-site mutation. `entry.update` may already be set
787
+ * (a registry hit — `client.ts` looks it up BEFORE calling this). Unlike `initiate()`, where the
788
+ * OWN entry's throw is rethrown synchronously to the `mutation()` caller, a REGISTERED updater
789
+ * that throws here is ordinary replay-drop collateral — warned and dropped via the normal
790
+ * `rebuild()` path, never rethrown (there is no synchronous caller on the hydrate path to
791
+ * propagate to; the entry still drains fine, only its rendering is lost). An entry with no
792
+ * `update` (no registry hit, or the registry simply wasn't configured) is added without a
793
+ * recompose pass at all — a plain layerless entry, exactly T4's pre-registry behavior.
794
+ */
795
+ addHydrated(entry) {
796
+ this.log.add(entry);
797
+ if (entry.update) this.rebuild();
798
+ }
799
+ invokeUpdate = (entry, view) => {
800
+ const store = createOptimisticLocalStore(view, entry.seed);
801
+ entry.update(store, jsonToConvex3(entry.args));
802
+ };
803
+ /** Rebuild composed values; drop + warn any entry whose updater threw during replay.
804
+ * `forceNotify` (subscription resume) forwards straight to `store.recompose` — see its doc. */
805
+ rebuild(forceNotify) {
806
+ const dropped = this.store.recompose(this.log.entriesInOrder(), this.invokeUpdate, forceNotify);
807
+ for (const d of dropped) {
808
+ const path = this.log.get(d.requestId)?.udfPath ?? d.requestId;
809
+ this.log.delete(d.requestId);
810
+ this.clearTimer(d.requestId);
811
+ console.warn(`[helipod] optimistic update for "${path}" threw during replay; dropping its pending layer`, d.error);
812
+ }
813
+ }
814
+ /**
815
+ * Event 1 — mutation initiation. Add the entry, replay all surviving updates over the current
816
+ * base. If THIS entry's updater throws, it is removed and the error rethrown **synchronously** so
817
+ * the caller sends nothing (a prior entry throwing is contained + warned, not rethrown).
818
+ */
819
+ initiate(entry) {
820
+ this.log.add(entry);
821
+ if (!entry.update) return;
822
+ const dropped = this.store.recompose(this.log.entriesInOrder(), this.invokeUpdate);
823
+ let ownError;
824
+ for (const d of dropped) {
825
+ this.log.delete(d.requestId);
826
+ this.clearTimer(d.requestId);
827
+ if (d.requestId === entry.requestId) {
828
+ ownError = { error: d.error };
829
+ } else {
830
+ const path = this.log.get(d.requestId)?.udfPath ?? d.requestId;
831
+ console.warn(`[helipod] optimistic update for "${path}" threw during replay; dropping its pending layer`, d.error);
832
+ }
833
+ }
834
+ if (ownError) throw ownError.error;
835
+ }
836
+ /**
837
+ * Event 2 — a contiguous (or resync-adopted) Transition, applied as ONE synchronous pass:
838
+ * advance the frontier, apply modifications to the base, drop every gated `completed` layer, then
839
+ * rebuild composed. The frame where a layer disappears is the same frame its authoritative rows
840
+ * appear — the no-flicker guarantee. An empty (`modifications: []`) ts-advancing Transition (T2)
841
+ * flows through here with zero special-casing: the loop simply does no base writes.
842
+ */
843
+ ingestTransition(modifications, endTs) {
844
+ this.observedTs = Math.max(this.observedTs, endTs);
845
+ let unchanged;
846
+ for (const mod of modifications) {
847
+ if (mod.type === "QueryUpdated") {
848
+ const sub = this.store.byId.get(mod.queryId);
849
+ if (sub) this.store.setServerValue(sub, jsonToConvex3(mod.value), mod.hash);
850
+ } else if (mod.type === "QueryFailed") {
851
+ const sub = this.store.byId.get(mod.queryId);
852
+ if (sub) {
853
+ this.store.markAnswered(sub);
854
+ console.error(`[helipod] query "${sub.path}" failed: ${mod.error}`);
855
+ for (const l of sub.listeners) l.onError?.(mod.error);
856
+ }
857
+ } else if (mod.type === "QueryUnchanged") {
858
+ const sub = this.store.byId.get(mod.queryId);
859
+ if (sub) {
860
+ this.store.markUnchanged(sub);
861
+ (unchanged ??= /* @__PURE__ */ new Set()).add(sub.hash);
862
+ }
863
+ } else if (mod.type === "QueryDiff") {
864
+ const sub = this.store.byId.get(mod.queryId);
865
+ if (sub) {
866
+ if (mod.reset === void 0 && sub.renderMode === void 0 && sub.answered) {
867
+ console.error(`[helipod] query "${sub.path}" incremental diff arrived with no established render mode; resyncing`);
868
+ sub.lastHash = void 0;
869
+ this.onDrift?.(mod.queryId);
870
+ } else {
871
+ const { drift } = this.store.applyDiff(sub, mod.changes, mod.checksum, mod.reset);
872
+ sub.lastHash = mod.reset !== void 0 ? mod.hash : void 0;
873
+ if (drift) {
874
+ console.error(`[helipod] query "${sub.path}" diff checksum mismatch; resyncing`);
875
+ this.onDrift?.(mod.queryId);
876
+ }
877
+ }
878
+ }
879
+ }
880
+ }
881
+ for (const entry of this.log.entriesInOrder()) {
882
+ if (entry.status.type === "completed" && versionCoversCommit(this.observedTs, entry.status.commitTs)) {
883
+ this.log.delete(entry.requestId);
884
+ this.clearTimer(entry.requestId);
885
+ }
886
+ }
887
+ this.rebuild(unchanged);
888
+ }
889
+ /**
890
+ * Event 3 — `MutationResponse` success carrying `ts` (W1). The client already resolved the
891
+ * promise (D3). Here: drop now if there is nothing to protect (no updater / nothing touched), or
892
+ * the gate is already covered, or `ts` is missing/≤0 (accept one-frame flicker over a wedge —
893
+ * the server-side `commitTs > 0` assertion makes this unreachable); otherwise hold the layer as
894
+ * `completed` and arm the gate timer.
895
+ */
896
+ onMutationSuccess(requestId, ts) {
897
+ const entry = this.log.get(requestId);
898
+ if (!entry) return;
899
+ if (!entry.update || entry.touched.size === 0) {
900
+ this.log.delete(requestId);
901
+ return;
902
+ }
903
+ if (ts === void 0 || ts <= 0) {
904
+ console.warn(`[helipod] mutation "${entry.udfPath}" acked with no usable commitTs (ts=${ts}); dropping its layer now`);
905
+ this.log.delete(requestId);
906
+ this.rebuild();
907
+ return;
908
+ }
909
+ if (versionCoversCommit(this.observedTs, ts)) {
910
+ this.log.delete(requestId);
911
+ this.rebuild();
912
+ return;
913
+ }
914
+ entry.status = { type: "completed", commitTs: ts, completedAt: Date.now() };
915
+ this.armGateTimer(requestId);
916
+ }
917
+ /** Event 4 — `MutationResponse` failure. The client rejected the promise; drop the layer + rebuild. */
918
+ onMutationFailure(requestId) {
919
+ if (!this.log.get(requestId)) return;
920
+ this.log.delete(requestId);
921
+ this.clearTimer(requestId);
922
+ this.rebuild();
923
+ }
924
+ /**
925
+ * Event 6 — transport close (S4). `unsent` retained; `inflight`/`completed` layers drop; the
926
+ * frontier resets; composed rebuilds over the retained set. Returns the `inflight` ids the client
927
+ * must reject with `MutationUndeliveredError`. NO layer crosses a session.
928
+ *
929
+ * Task 2 extends this with the S4 park swap: when `armed` (a `ConnectAck` has proven server-side
930
+ * dedup — T3 sets it via `client.ts#setOutboxArmed`) AND an `inflight` entry's durable append has
931
+ * already committed (`entry.durable`), it PARKS instead of rejecting — its `status` flips to
932
+ * `"parked"` and its `update` closure is cleared (the layer still drops, via the normal
933
+ * `rebuild()` below: `recompose` skips any entry with no `update`, the same mechanism a plain
934
+ * non-optimistic mutation already relies on) but the entry itself STAYS in the log, ready for a
935
+ * future drain (T4) to resend under its recorded `(clientId, seq)`. Every other `drop`ped id
936
+ * (rejected-inflight, completed) is still fully removed from the log, exactly as before.
937
+ */
938
+ closeSession(armed = false) {
939
+ const disp = closeDisposition(this.log.entriesInOrder(), { armed });
940
+ const parkedIds = new Set(disp.park);
941
+ for (const rid of disp.park) {
942
+ const entry = this.log.get(rid);
943
+ if (entry) {
944
+ entry.status = { type: "parked" };
945
+ entry.update = void 0;
946
+ }
947
+ }
948
+ for (const rid of disp.drop) {
949
+ if (parkedIds.has(rid)) continue;
950
+ this.log.delete(rid);
951
+ this.clearTimer(rid);
952
+ }
953
+ this.observedTs = 0;
954
+ this.rebuild();
955
+ return { rejectedInflight: disp.reject, parked: disp.park };
956
+ }
957
+ armGateTimer(requestId) {
958
+ this.clearTimer(requestId);
959
+ const timer = setTimeout(() => {
960
+ this.timers.delete(requestId);
961
+ const entry = this.log.get(requestId);
962
+ if (!entry || entry.status.type !== "completed") return;
963
+ console.warn(`[helipod] mutation "${entry.udfPath}" layer not confirmed within ${this.gateTimeoutMs}ms; dropping it`);
964
+ this.log.delete(requestId);
965
+ this.rebuild();
966
+ }, this.gateTimeoutMs);
967
+ timer.unref?.();
968
+ this.timers.set(requestId, timer);
969
+ }
970
+ clearTimer(requestId) {
971
+ const timer = this.timers.get(requestId);
972
+ if (timer !== void 0) {
973
+ clearTimeout(timer);
974
+ this.timers.delete(requestId);
975
+ }
976
+ }
977
+ };
978
+
979
+ // src/client.ts
980
+ function isOutboxBroadcastMessage(data) {
981
+ if (typeof data !== "object" || data === null || !("kind" in data)) return false;
982
+ const kind = data.kind;
983
+ return kind === "enqueued" || kind === "settled" || kind === "failed";
984
+ }
985
+ function probeBroadcastChannel(name) {
986
+ if (typeof BroadcastChannel === "undefined") return void 0;
987
+ const channel = new BroadcastChannel(name);
988
+ let closed = false;
989
+ const wrapper = {
990
+ // A `postMessage` racing an in-flight write-behind `.then()` against `close()` (e.g. a client
991
+ // torn down mid-test, or an app unmounting while an append is still resolving) must NEVER throw
992
+ // out from under `notifyOutboxChange()` — `closed` makes this a harmless no-op instead of the
993
+ // DOM's `InvalidStateError`.
994
+ postMessage: (message) => {
995
+ if (!closed) channel.postMessage(message);
996
+ },
997
+ onmessage: null,
998
+ close: () => {
999
+ closed = true;
1000
+ channel.close();
1001
+ }
1002
+ };
1003
+ channel.onmessage = (ev) => wrapper.onmessage?.({ data: ev.data });
1004
+ return wrapper;
1005
+ }
1006
+ var entropyCounter = 0;
1007
+ function makeEntropy() {
1008
+ return `${Date.now().toString(36)}-${(entropyCounter++).toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1009
+ }
1010
+ var HelipodClient = class {
1011
+ transport;
1012
+ version = { ...INITIAL_VERSION };
1013
+ resyncing = false;
1014
+ closed = false;
1015
+ // DLR Stage 3: the observed-ts frontier as of the moment BEFORE the current close. `closeSession`
1016
+ // resets `reconciler.maxObservedTs` to 0 on every transport close (reconcile.ts ~line 320, "the
1017
+ // ts-gate is only sound over one monotone feed") — so on a genuine reconnect, `maxObservedTs` is
1018
+ // already 0 by the time `resync()` runs and echoing it alone would defeat the resume watermark.
1019
+ // Captured in `onTransportClosed()` BEFORE `closeSession()` runs; `resync()` echoes
1020
+ // `Math.max(resumeSinceTs, reconciler.maxObservedTs)` so a real reconnect (observedTs reset to 0)
1021
+ // uses this snapshot, while a same-session drift resync (observedTs intact, no close in between)
1022
+ // uses the live value instead.
1023
+ resumeSinceTs = 0;
1024
+ store = new LayeredQueryStore();
1025
+ reconciler;
1026
+ /** Mutation promise callbacks, keyed by requestId — resolved/rejected here; layers live in the log. */
1027
+ pendingMutationCallbacks = /* @__PURE__ */ new Map();
1028
+ pendingActions = /* @__PURE__ */ new Map();
1029
+ broadcastListeners = /* @__PURE__ */ new Set();
1030
+ disposeTransport;
1031
+ disposeClose;
1032
+ disposeReopen;
1033
+ nextQueryId = 1;
1034
+ nextRequestId = 1;
1035
+ /** The last token passed to `setAuth` (T6: replayed on reconnect). Unset until `setAuth` is
1036
+ * first called — a transport that never had auth set never sends a spurious `SetAuth` on reopen. */
1037
+ hasSetAuth = false;
1038
+ lastAuthToken = null;
1039
+ /** Absent unless `opts.outbox` is configured — a client constructed without it behaves exactly
1040
+ * as before this seam existed (`outbox-storage.ts`'s file doc: "never touches this file's
1041
+ * runtime branches that matter"). */
1042
+ outbox;
1043
+ /** Resolves once this tab-session's clientId is durably persisted (`mintIdentity`,
1044
+ * `outbox-storage.ts`) — ALWAYS a fresh clientId, never one reused from a prior session. Public
1045
+ * contract for tests/direct inspection; `mutation()` itself never awaits this (see
1046
+ * `outboxClientId`/`outboxNextSeq` below — the synchronous counterparts it actually reads). */
1047
+ outboxIdentity;
1048
+ /** This tab-session's clientId, minted SYNCHRONOUSLY at construction (Task 2) — `mutation()` must
1049
+ * stay fully synchronous (T1's open concern), so it cannot await `outboxIdentity`'s async
1050
+ * `getMeta`/`setMeta` round-trip. Fed into `mintIdentity` via `opts.mintClientId` below so the
1051
+ * durable meta row names this SAME id. Set once, iff `opts.outbox` is configured; never reused
1052
+ * across a reload (a fresh `HelipodClient` always mints again). */
1053
+ outboxClientId;
1054
+ /** In-memory serial `seq` counter for `outboxClientId` (verdict §(d): "seqs minted serially
1055
+ * in-memory per tab"). Starts at 0 synchronously; `outboxIdentity`'s resolution only ever
1056
+ * reconciles it UPWARD (never re-hands-out a seq already allocated locally) for the
1057
+ * astronomically-unlikely colliding-clientId case `mintIdentity` itself guards against. */
1058
+ outboxNextSeq = 0;
1059
+ /** Monotonic per-tab counter for `OutboxEntry.order` — the drain's (T4) FIFO key across the
1060
+ * WHOLE shared queue (every clientId/tab). Seeded from wall-clock time so multiple tabs sharing
1061
+ * one outbox interleave in roughly chronological order; strictly increasing per call within
1062
+ * this tab regardless of clock resolution. Cross-tab total ordering is a best-effort aid to the
1063
+ * drain's efficiency, NOT a correctness requirement — "locks are efficiency; correctness is the
1064
+ * records" (verdict §(d) "Drain"). */
1065
+ outboxOrderCounter = 0;
1066
+ /** Cache of `identityFingerprint` (SHA-256 hex of the last `SetAuth` token, or `"anon"` for
1067
+ * none/empty) — see `setAuth()` below and spec §(k)7. Stamped synchronously onto every entry;
1068
+ * computed asynchronously (SubtleCrypto) whenever `setAuth` is called with a real token. */
1069
+ outboxFingerprint = "anon";
1070
+ /** When true, a managed `createAuthClient` owns the outbox fingerprint (derived from the stable
1071
+ * `sessionId`, not the rotating token) — `setAuth`'s token-hash recompute is suppressed so
1072
+ * rotation never orphans queued offline mutations mid-drain (spec decision 9). The raw
1073
+ * `setAuth(token)` path (no `createAuthClient`) leaves this false and keeps token-hash
1074
+ * fingerprinting byte-for-byte unchanged. */
1075
+ sessionFingerprintActive = false;
1076
+ /** The `session:<sessionId>` key whose SHA-256 is the active managed fingerprint — guards a stale
1077
+ * async digest from overwriting a newer session's fingerprint. */
1078
+ sessionFingerprintKey = null;
1079
+ /** The S4 swap's capability flag (verdict §(d) "S4 swap, feature-detected") — flipped by
1080
+ * `setOutboxArmed()`, which T3's Connect handshake calls once a `ConnectAck` proves server-side
1081
+ * receipt dedup exists for this session. Defaults `false`: today's fail-fast, byte-for-byte,
1082
+ * whether or not an outbox is configured. */
1083
+ outboxArmed = false;
1084
+ outboxMaxQueueSize;
1085
+ /** The last `ConnectAck.deploymentId` seen — the same-timeline proof stamp (verdict §(g) hazard
1086
+ * 15's client half). Surfaced via `getOutboxDeploymentId()`; also written into the durable meta
1087
+ * row so a future reload can compare timelines. Undefined until the first `ConnectAck`. */
1088
+ outboxDeploymentId;
1089
+ /** App callback fired once whenever a `ConnectAck{known: false}` resets this client's identity
1090
+ * (verdict §(d) Retention). Optional constructor config. */
1091
+ onClientResetCallback;
1092
+ /** True while the Connect handshake is waiting for the first post-`Connect` baseline Transition
1093
+ * to be ADOPTED through S3 (verdict §(d) / spec decision 5 — "a NEW await"). While true, the
1094
+ * drop rule for `applied` cross-session entries is DEFERRED (queued in `outboxPendingDrops`) and
1095
+ * `whenBaselineAdopted()` (T4's drain gate) stays pending. */
1096
+ outboxAwaitingBaseline = false;
1097
+ /** requestIds whose `applied`-verdict layer drop is deferred until the baseline is adopted (so the
1098
+ * drop is flicker-free — the baseline already renders the effect). Drained by `markBaselineAdopted`. */
1099
+ outboxPendingDrops = [];
1100
+ /** Resolvers for in-flight `whenBaselineAdopted()` promises — settled together when the baseline
1101
+ * Transition adopts (or immediately, when a reopen had no live subscriptions to re-baseline). */
1102
+ outboxBaselineResolvers = [];
1103
+ /** Whether a `Connect` handshake has already gone out on the CURRENT connection (reset at close).
1104
+ * Guards against a double-handshake when both the reopen path and the drain's first-connect path
1105
+ * could fire — the drain's `ensureInitialHandshake()` is a no-op once a reopen already sent one. */
1106
+ outboxConnectSent = false;
1107
+ /** DLR Stage 2a — whether this connection has advertised `supportsQueryDiff` (reset at close). A
1108
+ * non-outbox client has no resume `Connect` to piggyback the capability on (an outbox client's
1109
+ * `buildConnectMessage` carries it), so it sends a minimal capability-only `Connect` before its
1110
+ * first subscribe. Without this the server never records the capability and every by-id sub falls
1111
+ * back to RERUN — correct, but the diff path never engages. */
1112
+ diffCapabilitySent = false;
1113
+ /** The drain (Task 4) — the Web Locks leader that turns the durable queue into exactly-once server
1114
+ * effects. Present iff `opts.outbox` is configured; started at construction. */
1115
+ outboxDrain;
1116
+ /* -------------------------------------------------------------------------------------------
1117
+ * T5 — the `optimisticUpdates` registry, R9 observability.
1118
+ * ------------------------------------------------------------------------------------------- */
1119
+ /** T5: the registry `mutation()` NEVER consults — only `addHydratedEntry` does, at hydrate time
1120
+ * (verdict §(d): "call-site closure wins for the live call; the registry is consulted only at
1121
+ * hydrate"). Plain string-keyed: a generated `UdfPathOf<Api>` union (`@helipod/codegen`) narrows
1122
+ * the caller's OWN object-literal keys; this package never imports that generated type. */
1123
+ optimisticUpdates;
1124
+ /** udfPaths already warned for a registry miss at hydrate — "one warn per udfPath" (spec §(k)6),
1125
+ * not once per missed ENTRY (a stale backlog of the same unregistered udfPath warns exactly once). */
1126
+ optimisticUpdateMissWarned = /* @__PURE__ */ new Set();
1127
+ /** T5 (R9): fired for a terminal durable failure with no live promise awaiter THIS session (a
1128
+ * hydrated/retried entry, or one discovered already-failed at construction — "resume"). Never
1129
+ * fired for a failure a live `mutation()` caller's own rejected promise already delivered
1130
+ * (Lunora's `hadAwaiter` — no double notification for one failure). */
1131
+ onMutationFailedCallback;
1132
+ /** T5 (R9): same-instance listeners for "the durable outbox changed" — `usePendingMutations()`'s
1133
+ * re-read trigger. Fired locally on every outbox-mutating op AND on an incoming cross-tab
1134
+ * `outboxBroadcast` message (unified into one path — a listener never needs to know which). */
1135
+ outboxChangeListeners = /* @__PURE__ */ new Set();
1136
+ /** T5 (R9): the cross-tab nudge — `undefined` when no outbox is configured or the probe/injected
1137
+ * option resolved to nothing (single-tab observability still works via `outboxChangeListeners`). */
1138
+ outboxBroadcast;
1139
+ /** T-crosstab: serializes `mirrorFromStore()` — a second call arriving while one is already
1140
+ * in-flight (a rapid burst of `enqueued` broadcasts) sets this bit instead of racing a second
1141
+ * `loadAll()`; the in-flight run loops once more on completion so the caller's freshest read is
1142
+ * never dropped. */
1143
+ mirrorInFlight = false;
1144
+ mirrorRerun = false;
1145
+ constructor(transport, opts = {}) {
1146
+ this.transport = transport;
1147
+ this.reconciler = new Reconciler(this.store, { gateTimeoutMs: opts.gateTimeoutMs, onDrift: () => this.resync() });
1148
+ this.outbox = opts.outbox;
1149
+ this.onClientResetCallback = opts.onClientReset;
1150
+ this.outboxMaxQueueSize = opts.outboxMaxQueueSize ?? DEFAULT_OUTBOX_MAX_QUEUE_SIZE;
1151
+ this.optimisticUpdates = opts.optimisticUpdates ?? {};
1152
+ this.onMutationFailedCallback = opts.onMutationFailed;
1153
+ if (opts.outbox) {
1154
+ this.outboxClientId = defaultMintClientId();
1155
+ this.outboxIdentity = mintIdentity(opts.outbox, { mintClientId: () => this.outboxClientId }).then((id) => {
1156
+ this.outboxNextSeq = Math.max(this.outboxNextSeq, id.nextSeq);
1157
+ return id;
1158
+ });
1159
+ this.outboxIdentity.catch((err) => this.handleOutboxWriteError("mintIdentity", this.outboxClientId, void 0, void 0, err));
1160
+ this.outboxDrain = new OutboxDrain(this.makeDrainHost(), {
1161
+ lockName: `helipod:outbox:${this.originTag()}:${opts.outboxDeployment ?? "default"}`,
1162
+ locks: opts.outboxLocks,
1163
+ poisonPolicy: opts.poisonPolicy,
1164
+ chunkSize: opts.outboxChunkSize,
1165
+ intervalMs: opts.outboxDrainIntervalMs,
1166
+ backoffMs: opts.outboxBackoffMs,
1167
+ onPause: opts.onOutboxPause
1168
+ });
1169
+ this.outboxBroadcast = opts.outboxBroadcast === null ? void 0 : opts.outboxBroadcast ?? probeBroadcastChannel(`helipod:outbox:${this.originTag()}:${opts.outboxDeployment ?? "default"}:pending`);
1170
+ if (this.outboxBroadcast) {
1171
+ this.outboxBroadcast.onmessage = (event) => {
1172
+ for (const l of this.outboxChangeListeners) l();
1173
+ try {
1174
+ this.handleOutboxBroadcastMessage(event.data);
1175
+ } catch (err) {
1176
+ if (isDevMode()) console.error("[helipod] outbox: error handling a cross-tab broadcast message", err);
1177
+ }
1178
+ };
1179
+ }
1180
+ void this.refireDurableFailures();
1181
+ void this.mirrorFromStore().catch((err) => this.handleOutboxWriteError("mirrorFromStore", void 0, void 0, void 0, err));
1182
+ }
1183
+ this.disposeTransport = transport.onMessage((msg) => this.onServerMessage(msg));
1184
+ this.disposeClose = transport.onClose(() => this.onTransportClosed());
1185
+ this.disposeReopen = transport.onReopen?.(() => this.onTransportReopened());
1186
+ this.outboxDrain?.start();
1187
+ }
1188
+ /** The origin component of the drain's Web Locks name — `location.origin` in a browser, a stable
1189
+ * fallback elsewhere (Node/SSR share one origin; correctness is the records, not the lock). */
1190
+ originTag() {
1191
+ const loc = globalThis.location;
1192
+ return loc?.origin ?? "app";
1193
+ }
1194
+ /** @internal This tab-session's durable outbox identity, or `undefined` when no `outbox` was
1195
+ * configured. Exposed for direct testing of the identity-mint behavior; `mutation()` itself
1196
+ * reads the synchronous `outboxClientId`/`outboxNextSeq` counterparts, never this promise
1197
+ * (see the field doc above `outboxClientId`). */
1198
+ getOutboxIdentity() {
1199
+ return this.outboxIdentity;
1200
+ }
1201
+ /** @internal T3's Connect handshake calls this once a `ConnectAck` proves server-side receipt
1202
+ * dedup exists for this session — see verdict §(d) "S4 swap, feature-detected". Before that (no
1203
+ * outbox configured, a fresh/pre-handshake session, or an old server that never sends
1204
+ * `ConnectAck`), `close()` behaves exactly as it always has: today's fail-fast, byte-for-byte. */
1205
+ setOutboxArmed(armed) {
1206
+ this.outboxArmed = armed;
1207
+ }
1208
+ subscribe(ref, args = {}, onUpdate, onError) {
1209
+ const path = getFunctionPath(ref);
1210
+ const argsJson = convexToJson2(args);
1211
+ const hash = queryHash(path, argsJson);
1212
+ let sub = this.store.byHash.get(hash);
1213
+ if (!sub) {
1214
+ const queryId = this.nextQueryId++;
1215
+ sub = this.store.create(queryId, path, argsJson, hash);
1216
+ this.maybeSendDiffCapability();
1217
+ this.transport.send({ type: "ModifyQuerySet", add: [{ queryId, udfPath: path, args: argsJson }], remove: [] });
1218
+ }
1219
+ const listener = { onUpdate, onError };
1220
+ sub.listeners.add(listener);
1221
+ if (sub.composedValue !== void 0) onUpdate(sub.composedValue);
1222
+ return () => {
1223
+ const s = this.store.byHash.get(hash);
1224
+ if (!s) return;
1225
+ s.listeners.delete(listener);
1226
+ if (s.listeners.size === 0) {
1227
+ this.transport.send({ type: "ModifyQuerySet", add: [], remove: [s.queryId] });
1228
+ this.store.remove(hash);
1229
+ }
1230
+ };
1231
+ }
1232
+ query(ref, args = {}) {
1233
+ return new Promise((resolve, reject) => {
1234
+ const unsubscribe = this.subscribe(
1235
+ ref,
1236
+ args,
1237
+ (value) => {
1238
+ resolve(value);
1239
+ queueMicrotask(() => unsubscribe());
1240
+ },
1241
+ (error) => {
1242
+ reject(new Error(error));
1243
+ queueMicrotask(() => unsubscribe());
1244
+ }
1245
+ );
1246
+ });
1247
+ }
1248
+ mutation(ref, args = {}, opts = {}) {
1249
+ const path = getFunctionPath(ref);
1250
+ const argsJson = convexToJson2(args);
1251
+ const useOutbox = this.outbox !== void 0 && opts.transient !== true;
1252
+ if (useOutbox && this.outboxQueueDepth() >= this.outboxMaxQueueSize) {
1253
+ return Promise.reject(new OutboxOverflowError());
1254
+ }
1255
+ const requestId = String(this.nextRequestId++);
1256
+ const entry = {
1257
+ requestId,
1258
+ udfPath: path,
1259
+ args: argsJson,
1260
+ update: opts.optimisticUpdate,
1261
+ seed: { entropy: makeEntropy(), now: Date.now() },
1262
+ touched: /* @__PURE__ */ new Set(),
1263
+ status: { type: "unsent" }
1264
+ };
1265
+ if (useOutbox) {
1266
+ entry.clientId = this.outboxClientId;
1267
+ entry.seq = this.outboxNextSeq++;
1268
+ entry.order = this.nextOutboxOrder();
1269
+ entry.identityFingerprint = this.outboxFingerprint;
1270
+ entry.enqueuedAt = Date.now();
1271
+ }
1272
+ const queueBusy = useOutbox && this.hasOutboxBacklog();
1273
+ this.reconciler.initiate(entry);
1274
+ return new Promise((resolve, reject) => {
1275
+ this.pendingMutationCallbacks.set(requestId, { resolve, reject });
1276
+ if (this.closed || queueBusy) {
1277
+ entry.status = { type: "unsent" };
1278
+ } else {
1279
+ entry.status = { type: "inflight" };
1280
+ this.transport.send(this.mutationMessage(entry));
1281
+ }
1282
+ if (useOutbox) {
1283
+ void this.outbox.append(this.toOutboxEntry(entry)).then(() => {
1284
+ entry.durable = true;
1285
+ this.outboxDrain?.nudge();
1286
+ this.notifyOutboxChange();
1287
+ }).catch((err) => this.handleOutboxWriteError("append", entry.clientId, entry.seq, entry.udfPath, err));
1288
+ }
1289
+ });
1290
+ }
1291
+ /** The wire `Mutation` message for `entry` — carries `(clientId, seq)` whenever an outbox is
1292
+ * configured (park-safety, verdict §(d)), and OMITS the fields entirely (not merely `undefined`)
1293
+ * when it isn't, so a client with no `outbox` sends exactly today's shape, byte-for-byte. */
1294
+ mutationMessage(entry) {
1295
+ return {
1296
+ type: "Mutation",
1297
+ requestId: entry.requestId,
1298
+ udfPath: entry.udfPath,
1299
+ args: entry.args,
1300
+ ...entry.clientId !== void 0 ? { clientId: entry.clientId, seq: entry.seq } : {}
1301
+ };
1302
+ }
1303
+ /** The persisted `OutboxStorage` twin of `entry` — only ever called when `this.outbox` (and thus
1304
+ * `entry.clientId`/`seq`/`order`/`enqueuedAt`) is set. */
1305
+ toOutboxEntry(entry) {
1306
+ return {
1307
+ clientId: entry.clientId,
1308
+ seq: entry.seq,
1309
+ requestId: entry.requestId,
1310
+ udfPath: entry.udfPath,
1311
+ args: entry.args,
1312
+ seed: entry.seed,
1313
+ order: entry.order,
1314
+ status: entry.status.type === "unsent" ? "unsent" : "inflight",
1315
+ identityFingerprint: entry.identityFingerprint,
1316
+ outboxVersion: OUTBOX_VERSION,
1317
+ enqueuedAt: entry.enqueuedAt
1318
+ };
1319
+ }
1320
+ /** True while any OTHER entry is `unsent` (queued for a flush) or `parked` (queued for a future
1321
+ * drain) — the FIFO-preserving gate a new mutation enqueues behind (verdict §(d) "Enqueue").
1322
+ * A drain chunk in flight also counts (its units are `inflight`, which the scan alone would miss
1323
+ * when the chunk consumed the whole backlog) — otherwise a mutation issued mid-chunk would
1324
+ * direct-send AHEAD of a still-unsettled older unit, breaking the FIFO promise if the chunk
1325
+ * transient-stops and re-sends. Plain live in-flight direct-sends deliberately do NOT count:
1326
+ * "when empty, live sends go direct and concurrent" (T2's scoping, unchanged). */
1327
+ hasOutboxBacklog() {
1328
+ if (this.outboxDrain?.hasActiveChunk) return true;
1329
+ for (const e of this.reconciler.entries()) {
1330
+ if (e.status.type === "unsent" || e.status.type === "parked") return true;
1331
+ }
1332
+ return false;
1333
+ }
1334
+ /** Count of outbox-tracked entries not yet fully settled (excludes `completed` — already acked,
1335
+ * held only for the ts-gate) — the overflow cap's occupancy (verdict §(d) "Enqueue": "bounded,
1336
+ * default 1000"). */
1337
+ outboxQueueDepth() {
1338
+ let n = 0;
1339
+ for (const e of this.reconciler.entries()) {
1340
+ if (e.clientId !== void 0 && e.status.type !== "completed") n++;
1341
+ }
1342
+ return n;
1343
+ }
1344
+ /** Monotonic `OutboxEntry.order` allocator — see the `outboxOrderCounter` field doc. */
1345
+ nextOutboxOrder() {
1346
+ const now = Date.now();
1347
+ this.outboxOrderCounter = this.outboxOrderCounter >= now ? this.outboxOrderCounter + 1 : now;
1348
+ return this.outboxOrderCounter;
1349
+ }
1350
+ action(ref, args = {}) {
1351
+ const requestId = String(this.nextRequestId++);
1352
+ return new Promise((resolve, reject) => {
1353
+ this.pendingActions.set(requestId, { resolve, reject });
1354
+ this.transport.send({ type: "Action", requestId, udfPath: getFunctionPath(ref), args: convexToJson2(args) });
1355
+ });
1356
+ }
1357
+ /** Set (or clear) the session identity for this connection; the server re-runs subscriptions under it. */
1358
+ setAuth(token) {
1359
+ this.hasSetAuth = true;
1360
+ this.lastAuthToken = token;
1361
+ this.transport.send({ type: "SetAuth", token });
1362
+ if (this.outbox && !this.sessionFingerprintActive) {
1363
+ if (!token) {
1364
+ this.outboxFingerprint = "anon";
1365
+ } else {
1366
+ const forToken = token;
1367
+ void sha256Hex(forToken).then((hex) => {
1368
+ if (this.lastAuthToken === forToken) this.outboxFingerprint = hex;
1369
+ });
1370
+ }
1371
+ }
1372
+ }
1373
+ /** Managed-session fingerprinting (spec decision 9): a `createAuthClient` calls this so the durable
1374
+ * outbox's `identityFingerprint` derives from the STABLE `sessionId`, not the rotating access
1375
+ * token — otherwise a rotation mid-drain would orphan queued offline mutations under a new
1376
+ * fingerprint. Pass `null` to hand the fingerprint back to the raw `setAuth` token-hash path
1377
+ * (e.g. on sign-out). No-op when no outbox is configured. */
1378
+ setSessionFingerprint(sessionId) {
1379
+ this.sessionFingerprintActive = sessionId !== null;
1380
+ if (sessionId === null) {
1381
+ this.sessionFingerprintKey = null;
1382
+ if (this.outbox) {
1383
+ const token = this.lastAuthToken;
1384
+ if (!token) this.outboxFingerprint = "anon";
1385
+ else void sha256Hex(token).then((hex) => {
1386
+ if (this.lastAuthToken === token && !this.sessionFingerprintActive) this.outboxFingerprint = hex;
1387
+ });
1388
+ }
1389
+ return;
1390
+ }
1391
+ if (!this.outbox) return;
1392
+ const key = sessionFingerprintKey(sessionId);
1393
+ this.sessionFingerprintKey = key;
1394
+ void sha256Hex(key).then((hex) => {
1395
+ if (this.sessionFingerprintKey === key) this.outboxFingerprint = hex;
1396
+ });
1397
+ }
1398
+ /** Publish an ephemeral event (presence/typing) — bypasses the engine. */
1399
+ publishEphemeral(topic, event) {
1400
+ this.transport.send({ type: "EphemeralPublish", topic, event: convexToJson2(event) });
1401
+ }
1402
+ /** Listen for ephemeral broadcasts (presence/typing) from other clients. */
1403
+ onBroadcast(listener) {
1404
+ this.broadcastListeners.add(listener);
1405
+ return () => this.broadcastListeners.delete(listener);
1406
+ }
1407
+ close() {
1408
+ this.outboxDrain?.stop();
1409
+ this.outboxBroadcast?.close();
1410
+ this.disposeTransport();
1411
+ this.disposeClose();
1412
+ this.disposeReopen?.();
1413
+ this.transport.close();
1414
+ this.onTransportClosed();
1415
+ }
1416
+ /** @internal test/debug only — the observed-inclusion frontier (resets to 0 at close). */
1417
+ get __maxObservedTs() {
1418
+ return this.reconciler.maxObservedTs;
1419
+ }
1420
+ /** @internal test/debug only — the live pending-mutation log, in requestId order. */
1421
+ get __pending() {
1422
+ return this.reconciler.entries();
1423
+ }
1424
+ /** @internal test/debug only — the current `identityFingerprint` cache (see `setAuth`); polling
1425
+ * this (rather than calling `mutation()` repeatedly, which consumes seqs) is how a test waits
1426
+ * out the async SHA-256 digest without depending on a fixed tick count. */
1427
+ get __outboxFingerprint() {
1428
+ return this.outboxFingerprint;
1429
+ }
1430
+ onServerMessage(msg) {
1431
+ switch (msg.type) {
1432
+ case "Transition": {
1433
+ if (this.resyncing) {
1434
+ this.reconciler.ingestTransition(msg.modifications, msg.endVersion.ts);
1435
+ this.version = msg.endVersion;
1436
+ this.resyncing = false;
1437
+ if (this.outboxAwaitingBaseline) this.markBaselineAdopted();
1438
+ return;
1439
+ }
1440
+ if (!versionsEqual(msg.startVersion, this.version)) {
1441
+ this.resync();
1442
+ return;
1443
+ }
1444
+ this.reconciler.ingestTransition(msg.modifications, msg.endVersion.ts);
1445
+ this.version = msg.endVersion;
1446
+ if (this.outboxAwaitingBaseline) this.markBaselineAdopted();
1447
+ return;
1448
+ }
1449
+ case "MutationResponse": {
1450
+ if (this.outboxDrain?.handles(msg.requestId)) {
1451
+ this.outboxDrain.onResponse(msg);
1452
+ return;
1453
+ }
1454
+ const entry = this.reconciler.getEntry(msg.requestId);
1455
+ const pending = this.pendingMutationCallbacks.get(msg.requestId);
1456
+ const hadAwaiter = pending !== void 0;
1457
+ this.pendingMutationCallbacks.delete(msg.requestId);
1458
+ if (msg.success) {
1459
+ pending?.resolve(jsonToConvex4(msg.value ?? null));
1460
+ this.reconciler.onMutationSuccess(msg.requestId, msg.ts);
1461
+ this.dequeueOutboxEntry(entry);
1462
+ } else {
1463
+ pending?.reject(this.mutationError(msg.error, msg.code));
1464
+ this.reconciler.onMutationFailure(msg.requestId);
1465
+ if (entry?.clientId !== void 0 && entry.seq !== void 0) {
1466
+ this.outboxMarkFailed(entry.clientId, entry.seq, msg.error, msg.code);
1467
+ }
1468
+ if (!hadAwaiter) this.notifyMutationFailed(entry?.clientId, entry?.seq, entry?.udfPath ?? "unknown", { message: msg.error, code: msg.code });
1469
+ }
1470
+ return;
1471
+ }
1472
+ case "ConnectAck":
1473
+ this.handleConnectAck(msg);
1474
+ return;
1475
+ case "ActionResponse": {
1476
+ const pending = this.pendingActions.get(msg.requestId);
1477
+ if (pending) {
1478
+ this.pendingActions.delete(msg.requestId);
1479
+ if (msg.success) pending.resolve(jsonToConvex4(msg.value));
1480
+ else pending.reject(new Error(msg.error));
1481
+ }
1482
+ return;
1483
+ }
1484
+ case "Broadcast": {
1485
+ const event = jsonToConvex4(msg.event);
1486
+ for (const listener of this.broadcastListeners) listener(msg.topic, event);
1487
+ return;
1488
+ }
1489
+ default:
1490
+ return;
1491
+ }
1492
+ }
1493
+ /** A frame was missed: reset and re-subscribe all live queries; adopt the server's next state. */
1494
+ resync() {
1495
+ this.resyncing = true;
1496
+ this.version = { ...INITIAL_VERSION };
1497
+ const subs = [...this.store.byId.values()];
1498
+ if (subs.length === 0) {
1499
+ this.resyncing = false;
1500
+ return;
1501
+ }
1502
+ this.transport.send({
1503
+ type: "ModifyQuerySet",
1504
+ // `resultHash` (subscription resume): echoed ONLY for a sub that was actually delivered a
1505
+ // base value and still has its fingerprint on hand — a failed sub (`serverValue` never set),
1506
+ // a never-answered sub, or one whose last `QueryUpdated` had no `hash` (old server) echoes
1507
+ // nothing, falling through to today's full-send byte-for-byte. Note this condition doesn't
1508
+ // check "currently failed" — a sub that failed AFTER a prior success still has a retained
1509
+ // `serverValue`/`lastHash` from that success and still echoes it here. That's sound: the
1510
+ // server only replies `QueryUnchanged` if the FRESH re-run (against live, current state)
1511
+ // hashes equal to the echoed base — i.e. the query has recovered back to that exact value —
1512
+ // in which case `QueryUnchanged` renders exactly what a full `QueryUpdated` send would have
1513
+ // delivered. Any other outcome (still failing, or recovered to a different value) arrives as
1514
+ // a normal `QueryFailed`/`QueryUpdated` modification, not `QueryUnchanged`.
1515
+ add: subs.map((s) => ({
1516
+ queryId: s.queryId,
1517
+ udfPath: s.path,
1518
+ args: s.args,
1519
+ ...s.answered && s.serverValue !== void 0 && s.lastHash !== void 0 ? { resultHash: s.lastHash } : {},
1520
+ // DLR Stage 3: echo the client's observed-inclusion frontier so the server can skip the
1521
+ // re-run entirely when nothing touched this query's read-set since. Fresh `subscribe()`
1522
+ // never sets this — only a resume resubscribe has a meaningful watermark to echo. `Math.max`
1523
+ // with `resumeSinceTs` (the pre-close snapshot) covers both resume paths: a real transport
1524
+ // close resets `reconciler.maxObservedTs` to 0 (see `resumeSinceTs`'s own comment), so the
1525
+ // snapshot wins there; a same-session drift resync never closed, so `maxObservedTs` is still
1526
+ // live and wins instead.
1527
+ sinceTs: Math.max(this.resumeSinceTs, this.reconciler.maxObservedTs)
1528
+ })),
1529
+ remove: []
1530
+ });
1531
+ }
1532
+ onTransportClosed() {
1533
+ this.closed = true;
1534
+ this.outboxConnectSent = false;
1535
+ this.diffCapabilitySent = false;
1536
+ this.outboxDrain?.onTransportClosed();
1537
+ this.resumeSinceTs = this.reconciler.maxObservedTs;
1538
+ const { rejectedInflight, parked } = this.reconciler.closeSession(this.outboxArmed);
1539
+ for (const rid of rejectedInflight) {
1540
+ const pending = this.pendingMutationCallbacks.get(rid);
1541
+ this.pendingMutationCallbacks.delete(rid);
1542
+ pending?.reject(new MutationUndeliveredError());
1543
+ }
1544
+ for (const rid of parked) {
1545
+ const entry = this.reconciler.getEntry(rid);
1546
+ if (entry?.clientId !== void 0 && entry.seq !== void 0) this.outboxUpdateStatus(entry.clientId, entry.seq, "parked");
1547
+ }
1548
+ for (const [, pending] of this.pendingActions) pending.reject(new Error("connection closed"));
1549
+ this.pendingActions.clear();
1550
+ }
1551
+ /**
1552
+ * T6: the transport reconnected (a fresh session — the server has no state for it). Order is
1553
+ * load-bearing (verdict §(c) event 6): `SetAuth` replay first (the server re-runs subscriptions
1554
+ * under the right identity), THEN resubscribe every live query (the existing resync path — it
1555
+ * adopts the reply as a fresh baseline regardless of its start version), THEN flush every
1556
+ * `unsent` mutation FIFO — each transitions `unsent` -> `inflight` reusing its ORIGINAL
1557
+ * `requestId` (never re-minted), so the promise created at `mutation()` call time stays the one
1558
+ * that resolves when the new session's `MutationResponse` arrives.
1559
+ */
1560
+ onTransportReopened() {
1561
+ this.closed = false;
1562
+ if (this.hasSetAuth) this.transport.send({ type: "SetAuth", token: this.lastAuthToken });
1563
+ this.maybeSendDiffCapability();
1564
+ this.resync();
1565
+ if (this.outbox) {
1566
+ this.initiateHandshake(this.resyncing);
1567
+ this.outboxDrain?.nudge();
1568
+ return;
1569
+ }
1570
+ for (const entry of this.reconciler.unsentInOrder()) {
1571
+ entry.status = { type: "inflight" };
1572
+ this.transport.send(this.mutationMessage(entry));
1573
+ }
1574
+ }
1575
+ /** Send the `Connect` resume handshake once per connection (idempotent via `outboxConnectSent`),
1576
+ * arming the baseline await. Shared by the reopen path and the drain's first-connect path (Task 4
1577
+ * / T3 handoff #1: a fresh-client-first-connect after reload has no reopen event, so the drain
1578
+ * triggers the same handshake on becoming leader with a durable backlog). */
1579
+ initiateHandshake(expectTransition) {
1580
+ if (this.outboxConnectSent || this.closed || !this.outbox) return;
1581
+ this.outboxConnectSent = true;
1582
+ this.beginBaselineAwait(expectTransition);
1583
+ this.sendConnect();
1584
+ }
1585
+ /** True iff at least one live subscription has NOT yet received its first server reply
1586
+ * (`!sub.answered` — set by `ingestTransition` on EITHER outcome, `QueryUpdated` or `QueryFailed`).
1587
+ * The drain's first-connect `ensureInitialHandshake` gate (T4 bug fix, later widened to cover the
1588
+ * failed-query shape too — re-review FIX 2): a subscription created (and answered) BEFORE the
1589
+ * drain's async hydrate finishes has already consumed its one-shot Transition by the time the
1590
+ * handshake arms — waiting for ANOTHER one that will never come on a quiet deployment would starve
1591
+ * `whenBaselineAdopted()` (and so the drain) forever. Only a subscription still awaiting its first
1592
+ * reply guarantees a future Transition is actually coming — that's the one worth waiting for.
1593
+ * (Deliberately NOT `sub.serverValue === undefined`: a `QueryFailed` reply never sets `serverValue`
1594
+ * — there's no base to render — so that check misclassified an already-answered failed query as
1595
+ * still-undelivered and reproduced the same deadlock via the failed-query path.) */
1596
+ hasUndeliveredSubscription() {
1597
+ for (const sub of this.store.byId.values()) {
1598
+ if (!sub.answered) return true;
1599
+ }
1600
+ return false;
1601
+ }
1602
+ /* ---------------------------------------------------------------------------------------------
1603
+ * T3 — the Connect resume handshake, verdict settlement, the baseline-gated drop rule, and reset.
1604
+ * ------------------------------------------------------------------------------------------- */
1605
+ /** @internal T4's drain gate. Resolves once the first post-`Connect` baseline Transition has been
1606
+ * adopted through S3 (verdict §(d) / spec decision 5). Resolves immediately when no handshake is
1607
+ * in flight (nothing to await) or when a reopen had no live subscriptions to re-baseline. */
1608
+ whenBaselineAdopted() {
1609
+ if (!this.outboxAwaitingBaseline) return Promise.resolve();
1610
+ return new Promise((resolve) => this.outboxBaselineResolvers.push(resolve));
1611
+ }
1612
+ /** @internal test/debug — the last `ConnectAck.deploymentId` (the same-timeline proof stamp), or
1613
+ * `undefined` before any handshake completed. */
1614
+ getOutboxDeploymentId() {
1615
+ return this.outboxDeploymentId;
1616
+ }
1617
+ /** @internal test/debug — whether the S4 park swap is armed (a `ConnectAck` has proven dedup). */
1618
+ get __outboxArmed() {
1619
+ return this.outboxArmed;
1620
+ }
1621
+ /** Begin awaiting the post-`Connect` baseline. `expectTransition` is true iff a baseline Transition
1622
+ * is actually coming — for a reopen, `this.resyncing` (set iff `resync()` re-subscribed live
1623
+ * queries); for a first connect, whether any live subscription is still awaiting its first
1624
+ * delivery (`hasUndeliveredSubscription()` — NOT merely whether one exists: a subscription
1625
+ * created and already answered before the handshake armed has nothing left to wait for). With
1626
+ * nothing pending, there is no baseline frame coming and adoption is immediate. */
1627
+ beginBaselineAwait(expectTransition) {
1628
+ this.outboxAwaitingBaseline = expectTransition;
1629
+ if (!expectTransition) this.markBaselineAdopted();
1630
+ }
1631
+ /** The baseline Transition adopted (or there was none to await): fire every deferred `applied`
1632
+ * layer drop (each flicker-free now — the baseline renders the effect), release the drain gate,
1633
+ * and wake the drain (reconnect-after-baseline). */
1634
+ markBaselineAdopted() {
1635
+ this.outboxAwaitingBaseline = false;
1636
+ for (const rid of this.outboxPendingDrops.splice(0)) this.reconciler.onVerdictAfterBaseline(rid);
1637
+ const resolvers = this.outboxBaselineResolvers.splice(0);
1638
+ for (const resolve of resolvers) resolve();
1639
+ this.outboxDrain?.nudge();
1640
+ }
1641
+ /** Send the `Connect` resume handshake: this tab-session's clientId, the `held` durable entries
1642
+ * (every not-yet-settled `(clientId, seq)` in the log — the server classifies each into
1643
+ * `ConnectAck.results`), and `ackedThrough` (the contiguous settled-prefix per clientId, for
1644
+ * server-side retention pruning). Delegates the pure computation to `./connect-handshake` — the
1645
+ * SAME shared module the headless drain (`headless-drain.ts`) builds its own `Connect` from. */
1646
+ sendConnect() {
1647
+ const held = outboxHeldFromLog(this.reconciler.entries());
1648
+ this.transport.send(buildConnectMessage(makeEntropy(), this.outboxClientId, held));
1649
+ }
1650
+ /** DLR Stage 2a — advertise by-id diff support once per connection for a NON-outbox client (an
1651
+ * outbox client's resume `Connect` already carries the flag). A capability-only `Connect` (no
1652
+ * `clientId`/`held`/`ackedThrough`) is the reserved server no-op path — it records the capability
1653
+ * and sends no `ConnectAck` — so it never interferes with the outbox handshake or backpressure.
1654
+ * Sent BEFORE the first `ModifyQuerySet` (fresh connect) and before `resync()` (reopen) so the
1655
+ * server has the capability recorded when it decides a by-id sub's initial answer; the ordered
1656
+ * transport guarantees delivery order. Even if it somehow arrived late the diff path self-heals
1657
+ * (a pre-capability RERUN answer, then diffs resume), but sending it first keeps the common path
1658
+ * on the diff answer from the very first subscribe. */
1659
+ maybeSendDiffCapability() {
1660
+ if (this.outbox || this.diffCapabilitySent || this.closed) return;
1661
+ this.diffCapabilitySent = true;
1662
+ this.transport.send({ type: "Connect", sessionId: makeEntropy(), supportsQueryDiff: true });
1663
+ }
1664
+ /** Process a `ConnectAck` (verdict §(e)): the capability proof arms the S4 park swap; the
1665
+ * deploymentId is surfaced + persisted; `known: false` triggers `onClientReset`; otherwise each
1666
+ * classified `held` seq is settled (`applied`/`failed`/`stale` terminal; `unknown` left for the
1667
+ * drain). */
1668
+ handleConnectAck(msg) {
1669
+ this.outboxArmed = true;
1670
+ this.outboxDeploymentId = msg.deploymentId;
1671
+ if (this.outbox && this.outboxClientId !== void 0) {
1672
+ void this.outbox.setMeta(this.outboxClientId, { nextSeq: this.outboxNextSeq, deployment: msg.deploymentId }).catch((err) => this.handleOutboxWriteError("setMeta", this.outboxClientId, void 0, void 0, err));
1673
+ }
1674
+ if (!msg.known) {
1675
+ void this.onClientReset().then(() => this.outboxDrain?.nudge());
1676
+ return;
1677
+ }
1678
+ for (const v of msg.results) this.settleVerdict(v);
1679
+ this.outboxDrain?.nudge();
1680
+ }
1681
+ /** Settle one classified `held` seq from a `ConnectAck` (or, later, a drain replay-ack). */
1682
+ settleVerdict(v) {
1683
+ const entry = this.findOutboxEntry(v.clientId, v.seq);
1684
+ switch (v.verdict) {
1685
+ case "applied": {
1686
+ const value = v.valueMissing ? null : jsonToConvex4(v.value ?? null);
1687
+ if (entry) this.resolvePending(entry.requestId, value);
1688
+ this.outboxDequeue(v.clientId, v.seq);
1689
+ if (entry) this.dropAfterBaseline(entry.requestId);
1690
+ break;
1691
+ }
1692
+ case "failed": {
1693
+ const hadAwaiter = entry ? this.pendingMutationCallbacks.has(entry.requestId) : false;
1694
+ const message = `mutation "${entry?.udfPath ?? "unknown"}" failed`;
1695
+ if (entry) this.rejectPending(entry.requestId, this.mutationError(message, v.code));
1696
+ this.outboxMarkFailed(v.clientId, v.seq, message, v.code);
1697
+ if (entry) this.reconciler.onMutationFailure(entry.requestId);
1698
+ if (!hadAwaiter) this.notifyMutationFailed(v.clientId, v.seq, entry?.udfPath ?? "unknown", { message, code: v.code });
1699
+ break;
1700
+ }
1701
+ case "stale": {
1702
+ const hadAwaiter = entry ? this.pendingMutationCallbacks.has(entry.requestId) : false;
1703
+ const message = "mutation disowned (STALE_CLIENT)";
1704
+ const code = v.code ?? "STALE_CLIENT";
1705
+ if (entry) this.rejectPending(entry.requestId, this.mutationError(message, code));
1706
+ this.outboxMarkFailed(v.clientId, v.seq, message, code);
1707
+ if (entry) this.reconciler.onMutationFailure(entry.requestId);
1708
+ if (!hadAwaiter) this.notifyMutationFailed(v.clientId, v.seq, entry?.udfPath ?? "unknown", { message, code });
1709
+ break;
1710
+ }
1711
+ case "unknown":
1712
+ break;
1713
+ }
1714
+ }
1715
+ /** `known: false` — the server disowned this client's history (verdict §(d) Retention). Re-mint a
1716
+ * fresh clientId + meta; re-enqueue every `unsent` entry under the new clientId + NEW seqs (never
1717
+ * applied, so safe); reject every `parked` entry LOUDLY (in-flight-at-disconnect, no server dedup
1718
+ * → a blind resend could double-apply); fire the `onClientReset` callback. */
1719
+ async onClientReset() {
1720
+ const oldClientId = this.outboxClientId;
1721
+ const fresh = defaultMintClientId();
1722
+ this.outboxClientId = fresh;
1723
+ this.outboxNextSeq = 0;
1724
+ let parkedRejected = 0;
1725
+ let unsentReEnqueued = 0;
1726
+ for (const entry of [...this.reconciler.entries()]) {
1727
+ const recordedClientId = entry.clientId ?? oldClientId;
1728
+ if (entry.status.type === "parked") {
1729
+ const hadAwaiter = this.pendingMutationCallbacks.has(entry.requestId);
1730
+ const resetMessage = "the server disowned this client's mutation history (swept/foreign timeline)";
1731
+ if (recordedClientId !== void 0 && entry.seq !== void 0) {
1732
+ this.outboxMarkFailed(recordedClientId, entry.seq, resetMessage, "OFFLINE_CLIENT_RESET");
1733
+ }
1734
+ this.rejectPending(entry.requestId, new OfflineClientResetError());
1735
+ this.reconciler.onMutationFailure(entry.requestId);
1736
+ if (!hadAwaiter) this.notifyMutationFailed(recordedClientId, entry.seq, entry.udfPath, { message: resetMessage, code: "OFFLINE_CLIENT_RESET" });
1737
+ parkedRejected++;
1738
+ } else if (entry.status.type === "unsent") {
1739
+ if (recordedClientId !== void 0 && entry.seq !== void 0) this.outboxDequeue(recordedClientId, entry.seq);
1740
+ entry.clientId = fresh;
1741
+ entry.seq = this.outboxNextSeq++;
1742
+ entry.order = this.nextOutboxOrder();
1743
+ this.outboxAppend(entry);
1744
+ unsentReEnqueued++;
1745
+ }
1746
+ }
1747
+ if (this.outbox) {
1748
+ await mintIdentity(this.outbox, { mintClientId: () => fresh, deployment: this.outboxDeploymentId });
1749
+ await this.outbox.setMeta(fresh, { nextSeq: this.outboxNextSeq, deployment: this.outboxDeploymentId });
1750
+ }
1751
+ this.onClientResetCallback?.({ oldClientId, newClientId: fresh, unsentReEnqueued, parkedRejected });
1752
+ }
1753
+ /** The in-memory log entry with this recorded `(clientId, seq)`, or `undefined`. */
1754
+ findOutboxEntry(clientId, seq) {
1755
+ for (const e of this.reconciler.entries()) {
1756
+ if (e.clientId === clientId && e.seq === seq) return e;
1757
+ }
1758
+ return void 0;
1759
+ }
1760
+ /** Resolve a pending mutation promise by requestId (no-op if it already settled / has no awaiter). */
1761
+ resolvePending(requestId, value) {
1762
+ const pending = this.pendingMutationCallbacks.get(requestId);
1763
+ this.pendingMutationCallbacks.delete(requestId);
1764
+ pending?.resolve(value);
1765
+ }
1766
+ /** Reject a pending mutation promise by requestId (no-op if it already settled / has no awaiter). */
1767
+ rejectPending(requestId, error) {
1768
+ const pending = this.pendingMutationCallbacks.get(requestId);
1769
+ this.pendingMutationCallbacks.delete(requestId);
1770
+ pending?.reject(error);
1771
+ }
1772
+ /** Drop an `applied` cross-session entry's layer — deferred until the baseline is adopted (so the
1773
+ * drop is flicker-free), or immediately if it already has. */
1774
+ dropAfterBaseline(requestId) {
1775
+ if (this.outboxAwaitingBaseline) this.outboxPendingDrops.push(requestId);
1776
+ else this.reconciler.onVerdictAfterBaseline(requestId);
1777
+ }
1778
+ /** Dequeue a settled durable entry from the outbox store (no-op without an outbox / clientId). */
1779
+ dequeueOutboxEntry(entry) {
1780
+ if (entry?.clientId !== void 0 && entry.seq !== void 0) this.outboxDequeue(entry.clientId, entry.seq);
1781
+ }
1782
+ /** An `Error` carrying the server's terminal verdict `code` (STALE_CLIENT, an app error code) so
1783
+ * the drain's coded-vs-codeless retry policy (T4) and apps can key off it. */
1784
+ mutationError(message, code) {
1785
+ const err = new Error(message);
1786
+ if (code !== void 0) err.code = code;
1787
+ return err;
1788
+ }
1789
+ /* ---------------------------------------------------------------------------------------------
1790
+ * Task 4 — the drain host. These bind the drain's `DrainHost` seam to the client's private state
1791
+ * so the T3 settlement primitives (`resolvePending`/`rejectPending`, `dequeue`, the drop rule) are
1792
+ * REUSED by the drain, not forked (verdict §(d) "Drain").
1793
+ * ------------------------------------------------------------------------------------------- */
1794
+ /** @internal test/debug — the live drain (Task 4), or `undefined` without an outbox. */
1795
+ get __outboxDrain() {
1796
+ return this.outboxDrain;
1797
+ }
1798
+ makeDrainHost() {
1799
+ return {
1800
+ outbox: this.outbox,
1801
+ currentClientId: () => this.outboxClientId,
1802
+ currentFingerprint: () => this.outboxFingerprint,
1803
+ transportOpen: () => !this.closed,
1804
+ isArmed: () => this.outboxArmed,
1805
+ drainable: () => this.drainableEntries(),
1806
+ addHydrated: (entry) => this.addHydratedEntry(entry),
1807
+ ensureInitialHandshake: () => this.initiateHandshake(this.hasUndeliveredSubscription()),
1808
+ setStatus: (entry, status) => {
1809
+ entry.status = { type: status };
1810
+ if (entry.clientId !== void 0 && entry.seq !== void 0) this.outboxUpdateStatus(entry.clientId, entry.seq, status);
1811
+ },
1812
+ batchEntry: (entry) => this.drainBatchEntry(entry),
1813
+ sendBatch: (entries) => this.transport.send({ type: "MutationBatch", entries }),
1814
+ settleApplied: (requestId, value, replayed, ts) => this.drainSettleApplied(requestId, value, replayed, ts),
1815
+ settleTerminal: (requestId, code, message) => this.drainSettleTerminal(requestId, code, message),
1816
+ whenBaselineAdopted: () => this.whenBaselineAdopted()
1817
+ };
1818
+ }
1819
+ /** Drain-eligible entries: durable, recorded `(clientId, seq)`, still `unsent`/`parked`, FIFO by
1820
+ * the persisted `order`. Excludes `inflight` (a live direct-send or an in-flight chunk unit) and
1821
+ * `completed`. */
1822
+ drainableEntries() {
1823
+ return this.reconciler.entries().filter(
1824
+ (e) => e.clientId !== void 0 && e.seq !== void 0 && e.durable === true && (e.status.type === "unsent" || e.status.type === "parked")
1825
+ ).sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
1826
+ }
1827
+ /** Add a hydrated durable entry into the log under a FRESH requestId (the persisted requestId was
1828
+ * session-correlation only; a fresh one avoids colliding with this session's requestId counter).
1829
+ * Idempotent by `(clientId, seq)` — a direct-send this session already tracks is not re-added.
1830
+ * T5: `entry.update` is populated from the `optimisticUpdates` registry (hydrate-only lookup — a
1831
+ * live call-site closure is never in play here, there IS no live call site for a cross-reload
1832
+ * entry); a registry miss is layerless (no `update` at all), a clean drop under the baseline-gated
1833
+ * drop rule exactly as T4 shipped it. */
1834
+ addHydratedEntry(e) {
1835
+ if (dropIfNonReplayable(this.outbox, e)) return;
1836
+ for (const existing of this.reconciler.entries()) {
1837
+ if (existing.clientId === e.clientId && existing.seq === e.seq) return;
1838
+ }
1839
+ this.outboxOrderCounter = Math.max(this.outboxOrderCounter, e.order);
1840
+ const entry = {
1841
+ requestId: String(this.nextRequestId++),
1842
+ udfPath: e.udfPath,
1843
+ args: e.args,
1844
+ seed: e.seed,
1845
+ touched: /* @__PURE__ */ new Set(),
1846
+ status: { type: "unsent" },
1847
+ clientId: e.clientId,
1848
+ seq: e.seq,
1849
+ order: e.order,
1850
+ identityFingerprint: e.identityFingerprint,
1851
+ enqueuedAt: e.enqueuedAt,
1852
+ durable: true,
1853
+ update: this.lookupHydratedUpdate(e.udfPath)
1854
+ };
1855
+ this.reconciler.addHydrated(entry);
1856
+ }
1857
+ /** T5: the registry lookup `addHydratedEntry` makes — hydrate-time ONLY (verdict §(d): "the
1858
+ * registry is consulted at hydrate only"). A miss warns ONCE per udfPath (not per entry — a
1859
+ * backlog of many unregistered entries for the same udfPath warns once) and returns `undefined`:
1860
+ * the entry still drains fine, only its optimistic rendering is skipped (spec §(k)6). */
1861
+ lookupHydratedUpdate(udfPath) {
1862
+ const fn = this.optimisticUpdates[udfPath];
1863
+ if (fn) return fn;
1864
+ if (!this.optimisticUpdateMissWarned.has(udfPath)) {
1865
+ this.optimisticUpdateMissWarned.add(udfPath);
1866
+ console.warn(
1867
+ `[helipod] outbox: no optimisticUpdates registered for "${udfPath}" \u2014 a hydrated cross-reload mutation for it will drain without an optimistic layer (rendering only; the mutation itself is unaffected)`
1868
+ );
1869
+ }
1870
+ return void 0;
1871
+ }
1872
+ /* ---------------------------------------------------------------------------------------------
1873
+ * T-crosstab (browser-ux spec Part A) — live cross-tab rendering. Extends the hydrate machinery
1874
+ * above (`addHydratedEntry`/`lookupHydratedUpdate`) with live callers driven by the broadcast
1875
+ * channel, instead of its one construction-time caller (`OutboxDrain#hydrateOnce`).
1876
+ * ------------------------------------------------------------------------------------------- */
1877
+ /** The one predicate distinguishing a MIRRORED entry (another tab-session's durable append, or
1878
+ * this tab's own past-session hydrate — either way, no live promise) from an entry THIS instance
1879
+ * itself initiated live (hazard (a), "own-tab discrimination"): durable AND no
1880
+ * `pendingMutationCallbacks` registered for its `requestId`. A live `mutation()` caller has a
1881
+ * callback registered only UNTIL its own wire response settles it — resolved/rejected callbacks
1882
+ * are deleted immediately (`resolvePending`/`rejectPending`), well before a `completed` layer's
1883
+ * gate gets a chance to drop it. So a live caller's entry looks exactly like a true mirror
1884
+ * (`isMirroredEntry` returns `true`) for the whole post-ack, still-gated window — this predicate
1885
+ * alone does NOT distinguish "own tab, post-ack" from "another tab's mirror"; it is the
1886
+ * `status.type === "completed"` skip in `mirrorFromStore`'s backstop pass that closes that gap
1887
+ * (a `completed` layer is owned by its gate, never force-settled by this predicate or the store).
1888
+ * Used both by the dispatch methods below and the doc comment on `mirrorFromStore`'s backstop pass
1889
+ * — a single documented helper, never inlined twice (brief hazard (a)). */
1890
+ isMirroredEntry(entry) {
1891
+ return entry.durable === true && !this.pendingMutationCallbacks.has(entry.requestId);
1892
+ }
1893
+ /** Handle an incoming (already-fan-out'd) broadcast payload — the typed half of `onmessage`. A
1894
+ * payload that doesn't match `OutboxBroadcastMessage` (the legacy bare `1`, or anything else) is
1895
+ * simply not recognized: the accessor nudge already fired in the caller, nothing else happens. */
1896
+ handleOutboxBroadcastMessage(data) {
1897
+ if (!this.outbox || !isOutboxBroadcastMessage(data)) return;
1898
+ if (data.kind === "enqueued") {
1899
+ void this.mirrorFromStore().catch((err) => this.handleOutboxWriteError("mirrorFromStore", void 0, void 0, void 0, err));
1900
+ } else {
1901
+ this.onCrossTabSettle(data);
1902
+ }
1903
+ }
1904
+ /** Re-read the durable store and reconcile this tab's mirrored set against it — the `enqueued`
1905
+ * broadcast's handler, and the missed-message backstop (spec Part A "Rules"): a mirrored entry
1906
+ * absent from the fresh snapshot (settled/failed elsewhere, whose OWN targeted broadcast this tab
1907
+ * never received) drops via the same `dropAfterBaseline` one-pass rule the verdict-after-baseline
1908
+ * path uses. Serialized on `mirrorInFlight` — a second call arriving mid-read sets `mirrorRerun`
1909
+ * and is folded into one more pass after the current read finishes, rather than racing a second
1910
+ * `loadAll()` against it.
1911
+ *
1912
+ * Only STILL-ACTIVE entries (`unsent`/`inflight`/`parked`) are (re-)hydrated — a `failed` (or a
1913
+ * stray `completed`) entry is a terminal, accessor-only record (`pendingMutations()` already
1914
+ * surfaces it) and must never be resurrected as a fresh `unsent` optimistic layer. Without this
1915
+ * guard, a tab that itself once owned a now-terminally-failed entry would keep reviving its OWN
1916
+ * dead record on every subsequent `enqueued` broadcast (this store never dequeues a `failed`
1917
+ * entry — R9 "persists until dismissed/retried") — and, being `durable` with no live callback,
1918
+ * that revived entry would then match `isMirroredEntry`, making the tab react to an UNRELATED
1919
+ * later `settled`/`failed` broadcast that merely happens to name the same `(clientId, seq)`.
1920
+ *
1921
+ * Two review-fixed hazards in the reconcile loop below (both stem from the SAME root cause: the
1922
+ * store's presence/absence is not always the authority for a mirrored layer's fate):
1923
+ * - a `completed` mirrored layer (this tab already got a targeted `settled` broadcast and is
1924
+ * holding it gated until ITS OWN feed observes `commitTs`, per `onCrossTabSettle`) is SKIPPED
1925
+ * entirely here, never force-dropped merely because the store record is absent. The leader's
1926
+ * own `drainSettleApplied` dequeues the record right after posting `settled`, and THAT
1927
+ * dequeue's `{kind:"enqueued"}` follow-up broadcast is exactly what drives this backstop pass —
1928
+ * so a `completed` entry being store-absent is the ordinary, expected case, not a missed
1929
+ * message. Force-dropping it here would race ahead of this tab's own gate (a flicker the gate
1930
+ * exists to prevent) — CRITICAL. The same skip also protects THIS tab's own just-acked live
1931
+ * mutation: its `pendingMutationCallbacks` entry is deleted the moment its wire response
1932
+ * settles it (see `isMirroredEntry`'s doc — the callback does NOT survive the whole gated
1933
+ * window, only until the response arrives), so during that gated window it is
1934
+ * indistinguishable from a true mirror to `isMirroredEntry` and would otherwise be dropped by a
1935
+ * totally unrelated tab's `enqueued` broadcast.
1936
+ * - the "is this mirror still active" check now reads from ACTIVE-status (`unsent`/`inflight`/
1937
+ * `parked`) entries only, not "any entry present in the store" — a mirror whose backing record
1938
+ * flipped to `failed` (R9 never dequeues a failure) is present-in-store but no longer active;
1939
+ * treating presence alone as "still live" would leave a permanent phantom optimistic row behind
1940
+ * a missed `failed` broadcast. Such an entry is instead settled failed right here (same effect
1941
+ * as `onCrossTabSettle`'s `failed` branch — mark failed + fire R9), using the terminal verdict
1942
+ * already recorded on the store row itself. */
1943
+ async mirrorFromStore() {
1944
+ if (!this.outbox) return;
1945
+ if (this.mirrorInFlight) {
1946
+ this.mirrorRerun = true;
1947
+ return;
1948
+ }
1949
+ this.mirrorInFlight = true;
1950
+ try {
1951
+ do {
1952
+ this.mirrorRerun = false;
1953
+ const { entries } = await this.outbox.loadAll();
1954
+ const activeLive = /* @__PURE__ */ new Set();
1955
+ const byKey = /* @__PURE__ */ new Map();
1956
+ for (const e of entries) {
1957
+ const key = `${e.clientId}:${e.seq}`;
1958
+ byKey.set(key, e);
1959
+ if (e.status === "unsent" || e.status === "inflight" || e.status === "parked") {
1960
+ activeLive.add(key);
1961
+ this.addHydratedEntry(e);
1962
+ }
1963
+ }
1964
+ for (const entry of this.reconciler.entries()) {
1965
+ if (!this.isMirroredEntry(entry)) continue;
1966
+ if (entry.clientId === void 0 || entry.seq === void 0) continue;
1967
+ if (entry.status.type === "completed") continue;
1968
+ const key = `${entry.clientId}:${entry.seq}`;
1969
+ if (activeLive.has(key)) continue;
1970
+ const stored = byKey.get(key);
1971
+ if (stored?.status === "failed") {
1972
+ this.reconciler.onMutationFailure(entry.requestId);
1973
+ this.notifyMutationFailed(entry.clientId, entry.seq, entry.udfPath, stored.error ?? { message: `mutation "${entry.udfPath}" failed` });
1974
+ } else {
1975
+ this.dropAfterBaseline(entry.requestId);
1976
+ }
1977
+ }
1978
+ } while (this.mirrorRerun);
1979
+ } finally {
1980
+ this.mirrorInFlight = false;
1981
+ }
1982
+ }
1983
+ /** Handle a targeted `settled`/`failed` broadcast — the leader's flicker-free fast path (Part A's
1984
+ * normal route; `mirrorFromStore`'s backstop above is the fallback for a missed message). Ignored
1985
+ * entirely for an entry this tab doesn't know about, or one it initiated live itself (hazard (a)).
1986
+ * Never touches the durable store — the leader (whichever tab settled it) already wrote that;
1987
+ * this only updates THIS tab's in-memory reconciler layer + R9 observability. */
1988
+ onCrossTabSettle(msg) {
1989
+ const entry = this.findOutboxEntry(msg.clientId, msg.seq);
1990
+ if (!entry || !this.isMirroredEntry(entry)) return;
1991
+ if (msg.kind === "settled") {
1992
+ this.reconciler.onMutationSuccess(entry.requestId, msg.commitTs);
1993
+ } else {
1994
+ this.reconciler.onMutationFailure(entry.requestId);
1995
+ this.notifyMutationFailed(entry.clientId, entry.seq, entry.udfPath, { message: msg.message, code: msg.code });
1996
+ }
1997
+ }
1998
+ drainBatchEntry(entry) {
1999
+ return { requestId: entry.requestId, udfPath: entry.udfPath, args: entry.args, clientId: entry.clientId, seq: entry.seq };
2000
+ }
2001
+ /** applied settlement for a drained unit — resolve the awaiting promise (if any) and dequeue the
2002
+ * durable record ALWAYS; then route the layer drop by `replayed` (T4 review fix — the ungated
2003
+ * fresh-apply drop):
2004
+ * - `replayed: true` — a resend whose commit predates this session's `Connect`, reusing the same
2005
+ * primitive `settleVerdict`'s `applied` case uses (T3's unconditional baseline-gated drop).
2006
+ * Drop-soundness (T3 watch item, scoped to replays ONLY): the drop is gated on baseline
2007
+ * adoption, not on the replay's carried commitTs — the entry's commit necessarily predates this
2008
+ * session's `Connect`, so it predates the baseline's read snapshot and the baseline already
2009
+ * renders the effect. Historical-ts-vs-current-base is therefore still covered; the drop is
2010
+ * flicker-free by the same one-pass rule as T3's handshake.
2011
+ * - a FRESH apply (`replayed` absent/false — this session's OWN first execution, a genuinely new
2012
+ * `ts`) — the argument above does NOT apply: nothing proves this commit predates the baseline,
2013
+ * so an unconditional drop here would remove a still-rendered layer before its authoritative row
2014
+ * ever appears (a flicker). Instead this routes through the normal same-session gate,
2015
+ * `onMutationSuccess` (the response `ts`) — the exact same shipped no-flicker discipline the
2016
+ * direct-send path uses at `MutationResponse` (see the `case "MutationResponse"` handler above):
2017
+ * hold the layer `completed` until this client's own reactive feed observes `ts`.
2018
+ *
2019
+ * T-crosstab: AFTER the local settle above, the leader (this tab, if it holds the drain lock)
2020
+ * also posts a targeted `settled` broadcast so a tab MIRRORING this same `(clientId, seq)` gets
2021
+ * the flicker-free fast path instead of waiting for its own next `enqueued`-triggered backstop
2022
+ * read. Posted only when the entry carries a durable `(clientId, seq)` — a plain non-outbox
2023
+ * mutation has nothing for another tab to have mirrored in the first place.
2024
+ */
2025
+ drainSettleApplied(requestId, value, replayed, ts) {
2026
+ const entry = this.reconciler.getEntry(requestId);
2027
+ this.resolvePending(requestId, value);
2028
+ if (entry?.clientId !== void 0 && entry.seq !== void 0) this.outboxDequeue(entry.clientId, entry.seq);
2029
+ if (!entry) return;
2030
+ if (replayed) this.dropAfterBaseline(requestId);
2031
+ else this.reconciler.onMutationSuccess(requestId, ts);
2032
+ if (entry.clientId !== void 0 && entry.seq !== void 0) {
2033
+ this.outboxBroadcast?.postMessage({ kind: "settled", clientId: entry.clientId, seq: entry.seq, commitTs: ts ?? 0 });
2034
+ }
2035
+ }
2036
+ /** Terminal settlement for a drained unit (a coded server verdict, or the identity gate) — reject
2037
+ * the awaiting promise (coded), MARK the durable record `"failed"` (R9: never dequeue a terminal
2038
+ * failure — it persists until dismissed/retried), drop the layer, and (T5) refire `onMutationFailed`
2039
+ * / the dev-loud default when nothing awaited this failure this session. T-crosstab: AFTER all of
2040
+ * the above, also posts a targeted `failed` broadcast (same durability gate as `drainSettleApplied`
2041
+ * above) — a mirroring tab's own `onCrossTabSettle` fires ITS OWN `onMutationFailed`/dev-loud
2042
+ * default; it never double-delivers THIS tab's own notification above. */
2043
+ drainSettleTerminal(requestId, code, message) {
2044
+ const entry = this.reconciler.getEntry(requestId);
2045
+ const hadAwaiter = this.pendingMutationCallbacks.has(requestId);
2046
+ this.rejectPending(requestId, this.mutationError(message, code));
2047
+ if (entry?.clientId !== void 0 && entry.seq !== void 0) this.outboxMarkFailed(entry.clientId, entry.seq, message, code);
2048
+ if (entry) this.reconciler.onMutationFailure(requestId);
2049
+ if (!hadAwaiter) this.notifyMutationFailed(entry?.clientId, entry?.seq, entry?.udfPath ?? "unknown", { message, code });
2050
+ if (entry?.clientId !== void 0 && entry.seq !== void 0) {
2051
+ this.outboxBroadcast?.postMessage({ kind: "failed", clientId: entry.clientId, seq: entry.seq, code, message });
2052
+ }
2053
+ }
2054
+ /* ---------------------------------------------------------------------------------------------
2055
+ * T5 — R9 observability: `pendingMutations()`/`usePendingMutations()`, `pendingSummary()`,
2056
+ * `onMutationFailed` refire, the dev-mode loud default, and the outbox-change notification bus
2057
+ * every durable-mutating operation above funnels through.
2058
+ * ------------------------------------------------------------------------------------------- */
2059
+ /** A snapshot of the durable outbox — `usePendingMutations()`'s underlying read. `[]` without an
2060
+ * outbox configured (verdict §(d) R9). Each row's `retry()`/`dismiss()` close over the entry as
2061
+ * read HERE (no extra storage round-trip — `retry()` needs `args`/`seed`/`identityFingerprint`,
2062
+ * all captured already). */
2063
+ async pendingMutations() {
2064
+ if (!this.outbox) return [];
2065
+ const { entries } = await this.outbox.loadAll();
2066
+ return entries.map((e) => this.toPendingMutationEntry(e));
2067
+ }
2068
+ /** T5 (R9, hazard 2's client half): count + oldest-age advisory over the durable queue — cheap
2069
+ * enough to poll for a "your offline changes may be lost soon" banner ahead of a storage cliff
2070
+ * (Safari's 7-day eviction). `{count: 0, oldestEnqueuedAt: undefined, oldestAgeMs: undefined}`
2071
+ * without an outbox configured, or with an empty one. */
2072
+ async pendingSummary() {
2073
+ if (!this.outbox) return { count: 0, oldestEnqueuedAt: void 0, oldestAgeMs: void 0 };
2074
+ const { entries } = await this.outbox.loadAll();
2075
+ if (entries.length === 0) return { count: 0, oldestEnqueuedAt: void 0, oldestAgeMs: void 0 };
2076
+ let oldest = entries[0].enqueuedAt;
2077
+ for (const e of entries) if (e.enqueuedAt < oldest) oldest = e.enqueuedAt;
2078
+ return { count: entries.length, oldestEnqueuedAt: oldest, oldestAgeMs: Date.now() - oldest };
2079
+ }
2080
+ toPendingMutationEntry(e) {
2081
+ return {
2082
+ clientId: e.clientId,
2083
+ seq: e.seq,
2084
+ udfPath: e.udfPath,
2085
+ status: e.status,
2086
+ enqueuedAt: e.enqueuedAt,
2087
+ error: e.error,
2088
+ retry: () => this.retryOutboxEntry(e),
2089
+ dismiss: () => this.dismissOutboxEntry(e)
2090
+ };
2091
+ }
2092
+ /** `entry.retry()` (R9): a FAILED entry only — everything else is a harmless no-op (verdict §(b):
2093
+ * "never reuse a seq for a new attempt"). Dequeues the OLD (failed-verdict) durable record and
2094
+ * builds a brand-new `PendingMutation` — fresh requestId/seq/order, the CURRENT session's identity
2095
+ * fingerprint (a fair shot even if identity rotated since the original failure), reconstructed
2096
+ * exactly like a hydrated entry (same udfPath/args/seed; the registry is consulted — there is no
2097
+ * live call-site closure for a retry either). No live promise is registered: like a hydrated
2098
+ * entry, its eventual outcome surfaces via `usePendingMutations()`/`onMutationFailed`, never a
2099
+ * returned `Promise<Value>` (the durable record outlives any promise). */
2100
+ async retryOutboxEntry(e) {
2101
+ if (!this.outbox || e.status !== "failed" || this.outboxClientId === void 0) return;
2102
+ const requestId = String(this.nextRequestId++);
2103
+ const entry = {
2104
+ requestId,
2105
+ udfPath: e.udfPath,
2106
+ args: e.args,
2107
+ seed: e.seed,
2108
+ touched: /* @__PURE__ */ new Set(),
2109
+ status: { type: "unsent" },
2110
+ clientId: this.outboxClientId,
2111
+ seq: this.outboxNextSeq++,
2112
+ order: this.nextOutboxOrder(),
2113
+ identityFingerprint: this.outboxFingerprint,
2114
+ enqueuedAt: Date.now(),
2115
+ durable: false,
2116
+ update: this.lookupHydratedUpdate(e.udfPath)
2117
+ };
2118
+ this.reconciler.addHydrated(entry);
2119
+ this.outboxAppend(entry);
2120
+ this.outboxDequeue(e.clientId, e.seq);
2121
+ this.outboxDrain?.nudge();
2122
+ }
2123
+ /** `entry.dismiss()` (R9): a FAILED entry only — permanently forget it without retrying. */
2124
+ async dismissOutboxEntry(e) {
2125
+ if (!this.outbox || e.status !== "failed") return;
2126
+ this.outboxDequeue(e.clientId, e.seq);
2127
+ }
2128
+ /** T5 (R9): subscribe to "the durable outbox changed" — `usePendingMutations()`'s re-read trigger.
2129
+ * Fires on every local outbox-mutating op AND on an incoming cross-tab `outboxBroadcast` message. */
2130
+ onOutboxChange(listener) {
2131
+ this.outboxChangeListeners.add(listener);
2132
+ return () => this.outboxChangeListeners.delete(listener);
2133
+ }
2134
+ notifyOutboxChange() {
2135
+ for (const l of this.outboxChangeListeners) l();
2136
+ this.outboxBroadcast?.postMessage({ kind: "enqueued" });
2137
+ }
2138
+ /** Every durable-mutating outbox call site funnels through these four wrappers (instead of a bare
2139
+ * `this.outbox?.xxx(...)`) so `notifyOutboxChange()` — and a rejection's route through
2140
+ * `handleOutboxWriteError` — is never missed at a new call site. */
2141
+ outboxAppend(entry) {
2142
+ if (!this.outbox) return;
2143
+ void this.outbox.append(this.toOutboxEntry(entry)).then(() => {
2144
+ entry.durable = true;
2145
+ this.outboxDrain?.nudge();
2146
+ this.notifyOutboxChange();
2147
+ }).catch((err) => this.handleOutboxWriteError("append", entry.clientId, entry.seq, entry.udfPath, err));
2148
+ }
2149
+ outboxDequeue(clientId, seq) {
2150
+ if (!this.outbox) return;
2151
+ void this.outbox.dequeue(clientId, seq).then(() => this.notifyOutboxChange()).catch((err) => this.handleOutboxWriteError("dequeue", clientId, seq, void 0, err));
2152
+ }
2153
+ outboxUpdateStatus(clientId, seq, status) {
2154
+ if (!this.outbox) return;
2155
+ void this.outbox.updateStatus(clientId, seq, status).then(() => this.notifyOutboxChange()).catch((err) => this.handleOutboxWriteError("updateStatus", clientId, seq, void 0, err));
2156
+ }
2157
+ /** Record a terminal failure DURABLY (`status: "failed"` + the error) instead of dequeuing — R9:
2158
+ * "failed entries persist until dismissed/retried". */
2159
+ outboxMarkFailed(clientId, seq, message, code) {
2160
+ if (!this.outbox) return;
2161
+ void this.outbox.updateStatus(clientId, seq, "failed", { message, code }).then(() => this.notifyOutboxChange()).catch((err) => this.handleOutboxWriteError("updateStatus", clientId, seq, void 0, err));
2162
+ }
2163
+ /** Routes a rejected fire-and-forget durable-outbox write (append/updateStatus/dequeue/setMeta —
2164
+ * never awaited by its caller, per the write-behind contract) to observability instead of letting
2165
+ * it become an unhandled promise rejection, which several Node/Electron hosts treat as fatal by
2166
+ * default (a `fsOutbox` that has fail-stopped after a disk error rejects EVERY subsequent op —
2167
+ * see `outbox-fs.ts`'s `OutboxClosedError`). When the write carries a `(clientId, seq)` — every
2168
+ * case except a meta-only write — it routes through the SAME R9 channel as any other terminal
2169
+ * mutation failure (`onMutationFailed`, or `notifyMutationFailed`'s own dev-mode loud
2170
+ * `console.error` default). With no such record to attach the failure to (a meta write), a
2171
+ * dev-loud `console.error` is the floor — NEVER swallowed silently either way. */
2172
+ handleOutboxWriteError(op, clientId, seq, udfPath, err) {
2173
+ const message = err instanceof Error ? err.message : String(err);
2174
+ const code = typeof err === "object" && err !== null && "code" in err ? String(err.code) : void 0;
2175
+ if (clientId !== void 0 && seq !== void 0) {
2176
+ this.notifyMutationFailed(clientId, seq, udfPath ?? "unknown", { message: `durable outbox ${op} failed: ${message}`, code });
2177
+ } else if (isDevMode()) {
2178
+ console.error(`[helipod] durable outbox ${op} failed (clientId=${clientId ?? "unknown"}):`, err);
2179
+ }
2180
+ }
2181
+ /** T5 (R9): fire `onMutationFailed` for a terminal durable failure with NO live promise awaiter
2182
+ * this session (`hadAwaiter` already checked by every call site) — or, absent a registered
2183
+ * handler, the dev-mode loud `console.error` default (spec-review: "the five-line courtesy" no
2184
+ * position shipped). A no-op for a non-outbox-tracked entry (`clientId`/`seq` undefined) — R9 is
2185
+ * entirely a durable-outbox concern. */
2186
+ notifyMutationFailed(clientId, seq, udfPath, error) {
2187
+ if (clientId === void 0 || seq === void 0) return;
2188
+ if (this.onMutationFailedCallback) {
2189
+ this.onMutationFailedCallback({ clientId, seq, udfPath, error });
2190
+ } else if (isDevMode()) {
2191
+ console.error(
2192
+ `[helipod] outbox: mutation "${udfPath}" (clientId=${clientId}, seq=${seq}) failed terminally${error.code ? ` (${error.code})` : ""} with no onMutationFailed handler registered: ${error.message}`
2193
+ );
2194
+ }
2195
+ }
2196
+ /** R9 "resume" refire (constructor-only, verdict §(d) Observability: "`onMutationFailed` refires
2197
+ * from durable records on resume"): a fresh `HelipodClient` instance has made zero `mutation()`
2198
+ * calls yet, so EVERY already-`"failed"` durable record found here is trivially "no live awaiter" —
2199
+ * Lunora's `hadAwaiter` check is unconditionally false at this point, no gating needed. */
2200
+ async refireDurableFailures() {
2201
+ if (!this.outbox) return;
2202
+ const { entries } = await this.outbox.loadAll();
2203
+ for (const e of entries) {
2204
+ if (e.status === "failed") {
2205
+ this.notifyMutationFailed(e.clientId, e.seq, e.udfPath, e.error ?? { message: `mutation "${e.udfPath}" failed` });
2206
+ }
2207
+ }
2208
+ }
2209
+ };
2210
+
2211
+ export {
2212
+ getFunctionPath,
2213
+ anyApi,
2214
+ MutationUndeliveredError,
2215
+ createOptimisticLocalStore,
2216
+ computeDrainBackoff,
2217
+ DEFAULT_DRAIN_CHUNK_SIZE,
2218
+ DEFAULT_DRAIN_INTERVAL_MS,
2219
+ OFFLINE_IDENTITY_CHANGED,
2220
+ AUTH_REFRESH_UDF_PATH,
2221
+ NON_REPLAYABLE_MUTATION_DROPPED,
2222
+ dropIfNonReplayable,
2223
+ OutboxDrain,
2224
+ outboxHeldFromLog,
2225
+ outboxHeldFromStore,
2226
+ outboxAckedThrough,
2227
+ buildConnectMessage,
2228
+ sha256Hex,
2229
+ sessionFingerprintKey,
2230
+ HelipodClient
2231
+ };
2232
+ //# sourceMappingURL=chunk-I7ZJFW4E.js.map