@helipod/sync 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1354 @@
1
+ // src/protocol.ts
2
+ var INITIAL_VERSION = { querySet: 0, ts: 0 };
3
+ function versionsEqual(a, b) {
4
+ return a.querySet === b.querySet && a.ts === b.ts;
5
+ }
6
+ function compareStateVersion(a, b) {
7
+ if (a.querySet !== b.querySet) return a.querySet < b.querySet ? -1 : 1;
8
+ if (a.ts !== b.ts) return a.ts < b.ts ? -1 : 1;
9
+ return 0;
10
+ }
11
+ function isContiguous(prevEnd, nextStart) {
12
+ return versionsEqual(prevEnd, nextStart);
13
+ }
14
+ function parseClientMessage(raw) {
15
+ return JSON.parse(raw);
16
+ }
17
+ function encodeServerMessage(msg) {
18
+ return JSON.stringify(msg);
19
+ }
20
+
21
+ // src/change.ts
22
+ function applyChanges(rows, changes) {
23
+ const out = new Map(rows);
24
+ for (const c of changes) {
25
+ if (c.t === "remove") out.delete(c.key);
26
+ else out.set(c.key, { row: c.row, ts: c.ts, orderKey: c.orderKey });
27
+ }
28
+ return out;
29
+ }
30
+ function driftChecksum(rows) {
31
+ let acc = 0;
32
+ for (const [key, rv] of rows) {
33
+ let h = 2166136261;
34
+ const mix = (byte) => {
35
+ h ^= byte;
36
+ h = Math.imul(h, 16777619) >>> 0;
37
+ };
38
+ for (let i = 0; i < key.length; i++) mix(key.charCodeAt(i) & 255);
39
+ mix(0);
40
+ const tsStr = String(rv.ts);
41
+ for (let i = 0; i < tsStr.length; i++) mix(tsStr.charCodeAt(i) & 255);
42
+ mix(0);
43
+ const ok = rv.orderKey ?? "";
44
+ for (let i = 0; i < ok.length; i++) mix(ok.charCodeAt(i) & 255);
45
+ acc = (acc ^ h >>> 0) >>> 0;
46
+ }
47
+ return acc.toString(16).padStart(8, "0");
48
+ }
49
+
50
+ // src/subscription-manager.ts
51
+ import { deserializeKeyRange, IntervalIndex } from "@helipod/index-key-codec";
52
+ function subKey(sessionId, queryId) {
53
+ return `${sessionId} ${queryId}`;
54
+ }
55
+ var SubscriptionManager = class {
56
+ byKey = /* @__PURE__ */ new Map();
57
+ byTable = /* @__PURE__ */ new Map();
58
+ byRange = new IntervalIndex();
59
+ tableFallbackKeys = /* @__PURE__ */ new Set();
60
+ deserializedRanges = /* @__PURE__ */ new Map();
61
+ /**
62
+ * M2c: index from global (D1) table name -> subscription keys whose read set touched it, via
63
+ * `Subscription.globalTables`. Populated UNCONDITIONALLY in `add` (independent of
64
+ * `readRanges`/`tableFallbackKeys`) so a MIXED subscription — one with both local `readRanges`
65
+ * and a global-table read — still lands here. Matched by a THIRD, ungated loop in
66
+ * `findAffectedByRanges`; the existing table-fallback loop is gated on `tableFallbackKeys`
67
+ * precisely because it must NOT fire for a sub with non-empty `readRanges`, but global-table
68
+ * reads have no `readRanges` entry of their own, so that gate would wrongly exclude a mixed sub.
69
+ */
70
+ byGlobalTable = /* @__PURE__ */ new Map();
71
+ add(sub) {
72
+ const key = subKey(sub.sessionId, sub.queryId);
73
+ this.removeKey(key);
74
+ this.byKey.set(key, sub);
75
+ for (const table of sub.tables) {
76
+ let set = this.byTable.get(table);
77
+ if (!set) {
78
+ set = /* @__PURE__ */ new Set();
79
+ this.byTable.set(table, set);
80
+ }
81
+ set.add(key);
82
+ }
83
+ if (sub.readRanges.length > 0) {
84
+ const ranges = sub.readRanges.map(deserializeKeyRange);
85
+ this.deserializedRanges.set(key, ranges);
86
+ for (const range of ranges) this.byRange.insert(range, key);
87
+ } else {
88
+ this.tableFallbackKeys.add(key);
89
+ }
90
+ for (const t of sub.globalTables ?? []) {
91
+ let s = this.byGlobalTable.get(t);
92
+ if (!s) {
93
+ s = /* @__PURE__ */ new Set();
94
+ this.byGlobalTable.set(t, s);
95
+ }
96
+ s.add(key);
97
+ }
98
+ }
99
+ removeKey(key) {
100
+ const existing = this.byKey.get(key);
101
+ if (!existing) return;
102
+ const ranges = this.deserializedRanges.get(key);
103
+ if (ranges) {
104
+ for (const range of ranges) this.byRange.remove(range, key);
105
+ this.deserializedRanges.delete(key);
106
+ }
107
+ this.tableFallbackKeys.delete(key);
108
+ for (const table of existing.tables) this.byTable.get(table)?.delete(key);
109
+ for (const t of existing.globalTables ?? []) {
110
+ const s = this.byGlobalTable.get(t);
111
+ s?.delete(key);
112
+ if (s && s.size === 0) this.byGlobalTable.delete(t);
113
+ }
114
+ this.byKey.delete(key);
115
+ }
116
+ remove(sessionId, queryId) {
117
+ this.removeKey(subKey(sessionId, queryId));
118
+ }
119
+ removeSession(sessionId) {
120
+ const prefix = `${sessionId} `;
121
+ for (const key of [...this.byKey.keys()]) if (key.startsWith(prefix)) this.removeKey(key);
122
+ }
123
+ get(sessionId, queryId) {
124
+ return this.byKey.get(subKey(sessionId, queryId));
125
+ }
126
+ /**
127
+ * Subscriptions whose read set intersects the given write ranges (surgical invalidation),
128
+ * unioned with table-fallback subscriptions (empty readRanges) touched by the write tables.
129
+ * Same result set as the retired linear scan, computed in O(log N + k) per write range.
130
+ */
131
+ findAffectedByRanges(writeRanges, writeTables) {
132
+ const keys = /* @__PURE__ */ new Set();
133
+ for (const w of writeRanges) {
134
+ const wr = deserializeKeyRange(w);
135
+ for (const key of this.byRange.queryOverlaps(wr)) keys.add(key);
136
+ }
137
+ for (const table of writeTables) {
138
+ const set = this.byTable.get(table);
139
+ if (!set) continue;
140
+ for (const key of set) if (this.tableFallbackKeys.has(key)) keys.add(key);
141
+ }
142
+ for (const table of writeTables) {
143
+ const set = this.byGlobalTable.get(table);
144
+ if (set) for (const key of set) keys.add(key);
145
+ }
146
+ const out = [];
147
+ for (const key of keys) {
148
+ const sub = this.byKey.get(key);
149
+ if (sub) out.push(sub);
150
+ }
151
+ return out;
152
+ }
153
+ /** Subscriptions whose read set touched any of the given tables (deduped). */
154
+ findAffectedByTables(tables) {
155
+ const out = /* @__PURE__ */ new Map();
156
+ for (const table of tables) {
157
+ const keys = this.byTable.get(table);
158
+ if (!keys) continue;
159
+ for (const key of keys) {
160
+ const sub = this.byKey.get(key);
161
+ if (sub) out.set(key, sub);
162
+ }
163
+ }
164
+ return [...out.values()];
165
+ }
166
+ /** All subscriptions for a session (e.g. to re-run them when identity changes). */
167
+ forSession(sessionId) {
168
+ const prefix = `${sessionId} `;
169
+ const out = [];
170
+ for (const [key, sub] of this.byKey) if (key.startsWith(prefix)) out.push(sub);
171
+ return out;
172
+ }
173
+ get size() {
174
+ return this.byKey.size;
175
+ }
176
+ /** Global (D1) table names with at least one live subscriber (M2c). */
177
+ subscribedGlobalTables() {
178
+ return [...this.byGlobalTable.keys()];
179
+ }
180
+ };
181
+
182
+ // src/resume-registry.ts
183
+ import { deserializeKeyRange as deserializeKeyRange2, IntervalIndex as IntervalIndex2 } from "@helipod/index-key-codec";
184
+ var TTL_MS = 6e4;
185
+ function regKey(identity, path, argsJson) {
186
+ return `${identity ?? ""} ${path} ${JSON.stringify(argsJson)}`;
187
+ }
188
+ var ResumeRegistry = class {
189
+ entries = /* @__PURE__ */ new Map();
190
+ byTable = /* @__PURE__ */ new Map();
191
+ byRange = new IntervalIndex2();
192
+ tableFallbackKeys = /* @__PURE__ */ new Set();
193
+ deserializedRanges = /* @__PURE__ */ new Map();
194
+ upsert(key, readRanges, tables, atTs, wasDiffable, globalTables = []) {
195
+ const existing = this.entries.get(key);
196
+ const lastInvalidatedTs = Math.max(existing?.lastInvalidatedTs ?? atTs, atTs);
197
+ const refCount = existing?.refCount ?? 0;
198
+ this.unindex(key);
199
+ this.entries.set(key, { readRanges, tables, globalTables, lastInvalidatedTs, wasDiffable, refCount, expiresAtMs: void 0 });
200
+ this.index(key, readRanges, tables);
201
+ }
202
+ index(key, readRanges, tables) {
203
+ for (const table of tables) {
204
+ let set = this.byTable.get(table);
205
+ if (!set) {
206
+ set = /* @__PURE__ */ new Set();
207
+ this.byTable.set(table, set);
208
+ }
209
+ set.add(key);
210
+ }
211
+ if (readRanges.length > 0) {
212
+ const ranges = readRanges.map(deserializeKeyRange2);
213
+ this.deserializedRanges.set(key, ranges);
214
+ for (const range of ranges) this.byRange.insert(range, key);
215
+ } else {
216
+ this.tableFallbackKeys.add(key);
217
+ }
218
+ }
219
+ unindex(key) {
220
+ const existing = this.entries.get(key);
221
+ if (!existing) return;
222
+ const ranges = this.deserializedRanges.get(key);
223
+ if (ranges) {
224
+ for (const range of ranges) this.byRange.remove(range, key);
225
+ this.deserializedRanges.delete(key);
226
+ }
227
+ this.tableFallbackKeys.delete(key);
228
+ for (const table of existing.tables) this.byTable.get(table)?.delete(key);
229
+ }
230
+ /**
231
+ * Advances `lastInvalidatedTs` for every entry whose read set intersects the write — including
232
+ * entries with refCount 0 that are only TTL-retained (they remain indexed until swept).
233
+ */
234
+ advanceOnCommit(writtenRanges, writtenTables, commitTs) {
235
+ const keys = /* @__PURE__ */ new Set();
236
+ for (const w of writtenRanges) {
237
+ const wr = deserializeKeyRange2(w);
238
+ for (const key of this.byRange.queryOverlaps(wr)) keys.add(key);
239
+ }
240
+ for (const table of writtenTables) {
241
+ const set = this.byTable.get(table);
242
+ if (!set) continue;
243
+ for (const key of set) if (this.tableFallbackKeys.has(key)) keys.add(key);
244
+ }
245
+ for (const key of keys) {
246
+ const entry = this.entries.get(key);
247
+ if (entry) entry.lastInvalidatedTs = Math.max(entry.lastInvalidatedTs, commitTs);
248
+ }
249
+ }
250
+ lookup(key) {
251
+ const entry = this.entries.get(key);
252
+ if (!entry) return void 0;
253
+ return { readRanges: entry.readRanges, tables: entry.tables, globalTables: entry.globalTables, lastInvalidatedTs: entry.lastInvalidatedTs, wasDiffable: entry.wasDiffable };
254
+ }
255
+ retain(key) {
256
+ const entry = this.entries.get(key);
257
+ if (!entry) return;
258
+ entry.refCount++;
259
+ entry.expiresAtMs = void 0;
260
+ }
261
+ release(key, nowMs) {
262
+ const entry = this.entries.get(key);
263
+ if (!entry) return;
264
+ entry.refCount--;
265
+ if (entry.refCount <= 0) entry.expiresAtMs = nowMs + TTL_MS;
266
+ }
267
+ /** Evicts entries with no live subscribers whose TTL has elapsed. Removes them from both indexes. */
268
+ sweep(nowMs) {
269
+ for (const [key, entry] of this.entries) {
270
+ if (entry.refCount <= 0 && entry.expiresAtMs !== void 0 && entry.expiresAtMs <= nowMs) {
271
+ this.unindex(key);
272
+ this.entries.delete(key);
273
+ }
274
+ }
275
+ }
276
+ /** @internal test/debug only — a live entry's current refCount, or undefined if no such entry. */
277
+ __refCount(key) {
278
+ return this.entries.get(key)?.refCount;
279
+ }
280
+ /** @internal test/debug only — a live entry's pending TTL expiry, or undefined (not pending / no
281
+ * such entry). */
282
+ __expiresAtMs(key) {
283
+ return this.entries.get(key)?.expiresAtMs;
284
+ }
285
+ };
286
+
287
+ // src/handler.ts
288
+ import { createHash } from "crypto";
289
+ import { convexToJson } from "@helipod/values";
290
+ import { isRetryableError, isHelipodError } from "@helipod/errors";
291
+ import { tableOfKeyspaceId } from "@helipod/index-key-codec";
292
+
293
+ // src/classify.ts
294
+ import { deserializeKeyRange as deserializeKeyRange3, keySuccessor, compareKeyBytes } from "@helipod/index-key-codec";
295
+ function isPointRange(r) {
296
+ if (!r.keyspace.startsWith("table:")) return false;
297
+ if (r.end === null) return false;
298
+ const { start, end } = deserializeKeyRange3(r);
299
+ if (end === null) return false;
300
+ const succ = keySuccessor(start);
301
+ return compareKeyBytes(end, succ) === 0;
302
+ }
303
+ function singleDocId(value) {
304
+ if (value === null || value === void 0) return "";
305
+ if (Array.isArray(value)) return null;
306
+ if (typeof value === "object") {
307
+ const id = value["_id"];
308
+ return typeof id === "string" ? id : null;
309
+ }
310
+ return null;
311
+ }
312
+ function classifyByIdRead(value, readRanges) {
313
+ if (readRanges.length !== 1) return null;
314
+ const r = readRanges[0];
315
+ if (!isPointRange(r)) return null;
316
+ const docId = singleDocId(value);
317
+ if (docId === null) return null;
318
+ return { keyspace: r.keyspace, key: r.start, docId };
319
+ }
320
+ function rangeReadFromDiffable(d) {
321
+ return { keyspace: d.keyspace, bounds: d.bounds, filters: d.filters, order: d.order, fields: d.fields };
322
+ }
323
+ function pageReadFromDiffable(d) {
324
+ return { keyspace: d.keyspace, bounds: d.bounds, filters: d.filters, order: d.order, fields: d.fields, pageMeta: d.pageMeta };
325
+ }
326
+
327
+ // src/commit-differ.ts
328
+ import { evaluateFilter, extractIndexKey } from "@helipod/query-engine";
329
+ import { deserializeKeyRange as deserializeKeyRange4, keyInRange, serializeKeyRange } from "@helipod/index-key-codec";
330
+ function byIdChangesFor(byId, prev, wd) {
331
+ if (!wd) return { changes: [], next: prev };
332
+ if (wd.keyspace !== byId.keyspace) {
333
+ console.error(
334
+ `[sync] byIdChangesFor: wd.keyspace "${wd.keyspace}" !== byId.keyspace "${byId.keyspace}" (caller bug)`
335
+ );
336
+ }
337
+ const docId = wd.docId;
338
+ let change;
339
+ if (wd.newRow === null) change = { t: "remove", key: docId };
340
+ else if (!wd.wasPresent || !prev.has(docId)) change = { t: "add", key: docId, row: wd.newRow, ts: wd.ts };
341
+ else change = { t: "edit", key: docId, row: wd.newRow, ts: wd.ts };
342
+ const changes = [change];
343
+ return { changes, next: applyChanges(prev, changes) };
344
+ }
345
+ function byIdResetChanges(docId, row, ts) {
346
+ const next = /* @__PURE__ */ new Map();
347
+ if (row === null) return { changes: [], next };
348
+ next.set(docId, { row, ts });
349
+ return { changes: [{ t: "add", key: docId, row, ts }], next };
350
+ }
351
+ function toBase64(bytes) {
352
+ return serializeKeyRange({ keyspace: "", start: bytes, end: null }).start;
353
+ }
354
+ function fromBase64(b64) {
355
+ return deserializeKeyRange4({ keyspace: "", start: b64, end: null }).start;
356
+ }
357
+ function orderKeyFor(range, row) {
358
+ const key = extractIndexKey(row, range.fields);
359
+ return toBase64(key);
360
+ }
361
+ function inBounds(range, orderKeyB64) {
362
+ const bounds = deserializeKeyRange4(range.bounds);
363
+ return keyInRange(fromBase64(orderKeyB64), bounds);
364
+ }
365
+ function passesFilters(range, row) {
366
+ return range.filters.every((f) => evaluateFilter(row, f));
367
+ }
368
+ function rangeResetChanges(range, orderedDocs, ts) {
369
+ const changes = [];
370
+ const next = /* @__PURE__ */ new Map();
371
+ for (const row of orderedDocs) {
372
+ const key = String(row._id);
373
+ const orderKey = orderKeyFor(range, row);
374
+ changes.push({ t: "add", key, row, ts, orderKey });
375
+ next.set(key, { row, ts, orderKey });
376
+ }
377
+ return { changes, next };
378
+ }
379
+ function rangeChangesFor(range, prev, writtenDocs) {
380
+ const changes = [];
381
+ for (const wd of writtenDocs) {
382
+ const key = wd.docId;
383
+ const before = prev.has(key);
384
+ const orderKey = wd.newRow !== null ? orderKeyFor(range, wd.newRow) : void 0;
385
+ const after = wd.newRow !== null && orderKey !== void 0 && inBounds(range, orderKey) && passesFilters(range, wd.newRow);
386
+ if (!before && after) changes.push({ t: "add", key, row: wd.newRow, ts: wd.ts, orderKey });
387
+ else if (before && after) changes.push({ t: "edit", key, row: wd.newRow, ts: wd.ts, orderKey });
388
+ else if (before && !after) changes.push({ t: "remove", key });
389
+ }
390
+ return { changes, next: applyChanges(prev, changes) };
391
+ }
392
+
393
+ // src/session-controllers.ts
394
+ var DEFAULT_HIGH_WATER = 1024 * 1024;
395
+ var DEFAULT_MAX_QUEUED = 200;
396
+ var DEFAULT_SLOW_CLIENT_MS = 3e4;
397
+ var SessionBackpressureController = class {
398
+ constructor(socket, opts = {}, now = () => Date.now(), onOverflow = () => {
399
+ }) {
400
+ this.socket = socket;
401
+ this.now = now;
402
+ this.onOverflow = onOverflow;
403
+ this.highWaterBytes = opts.highWaterBytes ?? DEFAULT_HIGH_WATER;
404
+ this.maxQueuedFrames = opts.maxQueuedFrames ?? DEFAULT_MAX_QUEUED;
405
+ this.slowClientTimeoutMs = opts.slowClientTimeoutMs ?? DEFAULT_SLOW_CLIENT_MS;
406
+ this.maxUndroppableQueuedFrames = opts.maxUndroppableQueuedFrames ?? this.maxQueuedFrames;
407
+ }
408
+ socket;
409
+ now;
410
+ onOverflow;
411
+ highWaterBytes;
412
+ maxQueuedFrames;
413
+ slowClientTimeoutMs;
414
+ maxUndroppableQueuedFrames;
415
+ queue = [];
416
+ _droppedFrames = 0;
417
+ /** Count of undroppable frames currently sitting in `queue` — the separate overflow budget. */
418
+ undroppableQueuedCount = 0;
419
+ /** True once `onOverflow` has fired, so a dying session can't fire it twice. */
420
+ overflowed = false;
421
+ /** Wall-clock ms at which the current backpressure episode began, or null if not backpressured. */
422
+ backpressureSince = null;
423
+ /** True once any frame has been dropped in the current episode; resets on full drain. */
424
+ _droppedThisEpisode = false;
425
+ get droppedFrames() {
426
+ return this._droppedFrames;
427
+ }
428
+ /** True once anything was dropped since the last fully-drained state (the per-episode warn flag). */
429
+ get droppedThisEpisode() {
430
+ return this._droppedThisEpisode;
431
+ }
432
+ /**
433
+ * The ONLY way frames leave a session. Sends now, queues, or drops per the class contract.
434
+ * `undroppable` (default false) exempts a frame from BOTH drop paths below — the cap check
435
+ * and the sustained-backpressure abandon — so it only ever queues or sends, never vanishes.
436
+ */
437
+ send(data, undroppable = false) {
438
+ this.flush();
439
+ if (this.queue.length === 0 && this.socket.bufferedAmount < this.highWaterBytes) {
440
+ this.socket.send(data);
441
+ return;
442
+ }
443
+ if (undroppable) {
444
+ if (this.undroppableQueuedCount >= this.maxUndroppableQueuedFrames) {
445
+ this.overflow();
446
+ return;
447
+ }
448
+ this.queue.push({ data, undroppable: true });
449
+ this.undroppableQueuedCount += 1;
450
+ return;
451
+ }
452
+ if (this.backpressureSince === null) this.backpressureSince = this.now();
453
+ if (this.now() - this.backpressureSince >= this.slowClientTimeoutMs) {
454
+ this.dropQueue();
455
+ this.countDrop();
456
+ return;
457
+ }
458
+ if (this.queue.length >= this.maxQueuedFrames) {
459
+ this.countDrop();
460
+ return;
461
+ }
462
+ this.queue.push({ data, undroppable: false });
463
+ }
464
+ /**
465
+ * Deliver as many queued frames as the socket buffer will take. Called before each send and on a
466
+ * periodic sweep, so a client that recovers (or goes terminally slow) without new traffic still
467
+ * gets its queue drained (or abandoned). Resets the episode once fully drained.
468
+ */
469
+ flush() {
470
+ while (this.queue.length > 0 && this.socket.bufferedAmount < this.highWaterBytes) {
471
+ const frame = this.queue.shift();
472
+ if (frame.undroppable) this.undroppableQueuedCount -= 1;
473
+ this.socket.send(frame.data);
474
+ }
475
+ if (this.queue.length === 0 && this.socket.bufferedAmount < this.highWaterBytes) {
476
+ this.backpressureSince = null;
477
+ this._droppedThisEpisode = false;
478
+ return;
479
+ }
480
+ if (this.backpressureSince === null) this.backpressureSince = this.now();
481
+ if (this.queue.length > 0 && this.now() - this.backpressureSince >= this.slowClientTimeoutMs) {
482
+ this.dropQueue();
483
+ }
484
+ }
485
+ /** Abandon the queue to drops — EXCEPT undroppable frames, which stay queued for a later flush. */
486
+ dropQueue() {
487
+ const survivors = this.queue.filter((f) => f.undroppable);
488
+ const droppedCount = this.queue.length - survivors.length;
489
+ if (droppedCount === 0) return;
490
+ this._droppedFrames += droppedCount;
491
+ this.queue.length = 0;
492
+ this.queue.push(...survivors);
493
+ this.markEpisodeDropped();
494
+ }
495
+ countDrop() {
496
+ this._droppedFrames += 1;
497
+ this.markEpisodeDropped();
498
+ }
499
+ /**
500
+ * The undroppable queue exceeded its cap. Fires `onOverflow` exactly once (a session that's
501
+ * already dying doesn't need a second kill signal) with a distinct, greppable log reason —
502
+ * deliberately NOT reusing the backpressure-drop warning text, since this is a different failure
503
+ * mode (session termination, not a dropped frame) that ops needs to be able to tell apart.
504
+ */
505
+ overflow() {
506
+ if (this.overflowed) return;
507
+ this.overflowed = true;
508
+ console.warn(
509
+ `[sync] undroppable-queue-overflow: terminating session (queued undroppable frames >= cap=${this.maxUndroppableQueuedFrames})`
510
+ );
511
+ this.onOverflow();
512
+ }
513
+ markEpisodeDropped() {
514
+ if (this._droppedThisEpisode) return;
515
+ this._droppedThisEpisode = true;
516
+ console.warn(`[sync] backpressure: dropping frames for slow client (total dropped=${this._droppedFrames})`);
517
+ }
518
+ };
519
+ var DEFAULT_PING_INTERVAL_MS = 3e4;
520
+ var DEFAULT_MISSED_PONG_LIMIT = 2;
521
+ var SessionHeartbeatController = class {
522
+ constructor(socket, onDead, opts = {}) {
523
+ this.socket = socket;
524
+ this.onDead = onDead;
525
+ this.pingIntervalMs = opts.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
526
+ this.missedPongLimit = opts.missedPongLimit ?? DEFAULT_MISSED_PONG_LIMIT;
527
+ }
528
+ socket;
529
+ onDead;
530
+ pingIntervalMs;
531
+ missedPongLimit;
532
+ timer = null;
533
+ missed = 0;
534
+ dead = false;
535
+ /** Begin pinging. No-op when the socket cannot ping (loopback exemption) or already started. */
536
+ start() {
537
+ if (!this.socket.ping) return;
538
+ if (this.timer !== null) return;
539
+ this.missed = 0;
540
+ this.dead = false;
541
+ this.timer = setInterval(() => this.tick(), this.pingIntervalMs);
542
+ }
543
+ stop() {
544
+ if (this.timer !== null) {
545
+ clearInterval(this.timer);
546
+ this.timer = null;
547
+ }
548
+ }
549
+ /** Any inbound message is liveness credit — resets the consecutive-miss counter. */
550
+ noteActivity() {
551
+ this.missed = 0;
552
+ }
553
+ tick() {
554
+ this.socket.ping?.(() => {
555
+ this.missed = 0;
556
+ });
557
+ this.missed += 1;
558
+ if (this.missed >= this.missedPongLimit) this.fireDead();
559
+ }
560
+ fireDead() {
561
+ if (this.dead) return;
562
+ this.dead = true;
563
+ this.stop();
564
+ this.onDead();
565
+ }
566
+ };
567
+
568
+ // src/handler.ts
569
+ var FLUSH_SWEEP_MS = 1e3;
570
+ function errMessage(e) {
571
+ return e instanceof Error ? e.message : String(e);
572
+ }
573
+ var COMMITTED_TS_ERROR_KEY = /* @__PURE__ */ Symbol.for("helipod.executor.committedTs");
574
+ function committedTsOfError(e) {
575
+ if (e !== null && typeof e === "object") {
576
+ const ts = e[COMMITTED_TS_ERROR_KEY];
577
+ if (typeof ts === "number") return ts;
578
+ }
579
+ return void 0;
580
+ }
581
+ function subKey2(sessionId, queryId) {
582
+ return `${sessionId} ${queryId}`;
583
+ }
584
+ function hashValue(value) {
585
+ return "sha256:" + createHash("sha256").update(JSON.stringify(value)).digest("hex");
586
+ }
587
+ var SyncProtocolHandler = class {
588
+ constructor(executor, options = {}) {
589
+ this.executor = executor;
590
+ this.options = options;
591
+ this.verifyAdmin = options.verifyAdmin ?? (() => false);
592
+ if (!options.disableBackgroundTimers) {
593
+ this.sweepTimer = setInterval(() => {
594
+ for (const session of this.sessions.values()) session.bp.flush();
595
+ this.resumeRegistry.sweep(Date.now());
596
+ }, FLUSH_SWEEP_MS);
597
+ this.sweepTimer.unref?.();
598
+ }
599
+ }
600
+ executor;
601
+ options;
602
+ sessions = /* @__PURE__ */ new Map();
603
+ subscriptions = new SubscriptionManager();
604
+ notifyTail = Promise.resolve();
605
+ /**
606
+ * G4 fleet fallback (client-sync verdict §(d) item 2): sessionId → the commitTs of a FORWARDED
607
+ * mutation whose origin tag couldn't ride this (forwarding) node's local fan-out. Satisfied with
608
+ * an empty ts-advancing Transition once the drain processes a commit at-or-above it (gated on the
609
+ * drain's last-processed commitTs — see `sweepPendingFrontiers`). Holds at most one entry per
610
+ * in-flight forwarded mutation per session; cleared on satisfy or disconnect, so the sweep it
611
+ * drives stays tiny (usually empty on a single-node deployment, where nothing is ever forwarded).
612
+ */
613
+ pendingFrontiers = /* @__PURE__ */ new Map();
614
+ /**
615
+ * DLR 2a CommitDiffer state: `${sessionId} ${queryId}` -> the current materialized 0-or-1-row map
616
+ * for a DIFFABLE_BYID sub whose client is diff-capable. EPHEMERAL — reseeded on every subscribe
617
+ * (`doModifyQuerySet`'s reset), updated on every incremental diff (`doNotifyWrites`), and dropped on
618
+ * unsubscribe/disconnect. NOT a durable CVR: if lost (process restart, or a sub falling back to the
619
+ * RERUN/QueryUpdated path for one turn) the drift checksum's client-side mismatch check is the
620
+ * backstop that resyncs the one affected query — see `change.ts`'s `driftChecksum` doc comment.
621
+ */
622
+ byIdRowMap = /* @__PURE__ */ new Map();
623
+ /**
624
+ * DLR 2b — the response-before-Transition gate (replaces the fragile, timer-starvable
625
+ * `setTimeout(0)`). `commitTs` → a one-shot latch a diff-capable origin's OWN reactive Transition
626
+ * parks on inside `doNotifyWrites`, released once `processMutation` has actually enqueued that
627
+ * commit's `MutationResponse` onto the session's outbound queue.
628
+ *
629
+ * WHY commit-time registration (via {@link registerOriginResponseGate}, called from the runtime's
630
+ * fan-out subscribe callback) rather than lazily inside `doNotifyWrites`: the fan-out drain is
631
+ * SERIAL, so under load `doNotifyWrites` for a commit can run long after that commit's response was
632
+ * already sent (the drain is backed up behind a flood). Registering the gate inside `doNotifyWrites`
633
+ * would then happen AFTER the release — the release would find no gate (no-op), and the late gate
634
+ * would park FOREVER, wedging the whole `notifyTail` (the backpressure-flood regression). The
635
+ * subscribe callback instead fires SYNCHRONOUSLY inside the commit (before `runMutation` resolves,
636
+ * hence before the response can be sent), so the gate always exists before its release, whatever the
637
+ * drain backlog.
638
+ *
639
+ * WHY a microtask latch, not `setTimeout(0)`: the release runs as `processMutation` resumes after
640
+ * `await runMutation` — a MICROTASK, which a tight `await`-loop of mutations (`for (…) await
641
+ * client.mutation(…)`) drains between every iteration. The old timer sat in Node's TIMER phase,
642
+ * which that same loop STARVES; because the yield sat ON the single `notifyTail`, a starved timer
643
+ * stalled the entire fan-out chain. A microtask cannot be starved and cannot stall the tail.
644
+ *
645
+ * Scoped to a diff-capable LOCAL origin session (see `registerOriginResponseGate`), so every gate
646
+ * created here is balanced by exactly one release from that session's own `processMutation` — on
647
+ * EVERY post-commit outcome: the success path releases inline, and a commit-then-throw (or any
648
+ * throw after the commit) releases from its catch via the `committedTs` the executor stamps on the
649
+ * error (see `releaseOriginResponseGate`/`committedTsOfError`). Entries are transient: one per
650
+ * in-flight diff-capable-origin commit, created at commit and dropped on release (or on
651
+ * `disconnect`, which resolves+drops any still-pending gate for the vanishing session so a
652
+ * mid-flight teardown can never strand a parked `doNotifyWrites`). The `sessionId` is retained so
653
+ * that disconnect backstop can find a session's gates in this commitTs-keyed map.
654
+ */
655
+ originResponseGates = /* @__PURE__ */ new Map();
656
+ /**
657
+ * DLR Stage 3: the compute-saving half of reconnect resume (see `resume-registry.ts`'s doc
658
+ * comment). Populated on every subscribe (`doModifyQuerySet`), advanced on every commit
659
+ * (`doNotifyWrites`, unconditionally — independent of `bySession`, so an entry with zero live
660
+ * subscribers still advances during its TTL-retained "gap"), and retain/release-tracked across
661
+ * subscribe/unsubscribe/disconnect. Not yet CONSULTED anywhere (that's a later task) — this task
662
+ * only keeps it correctly populated.
663
+ */
664
+ resumeRegistry = new ResumeRegistry();
665
+ /**
666
+ * M2c fix: callbacks registered via {@link onGlobalSubscribe}, fired whenever a subscription is
667
+ * (re-)registered with a non-empty `globalTables` read set — from `doModifyQuerySet` (fresh
668
+ * subscribe, or a resume-skip carrying a retained global-table read set), from `handleSetAuth`'s
669
+ * re-exec (an identity flip can make a query newly read a global table), and from
670
+ * `sendSessionTransition`'s live-re-run (a data/identity-dependent query can shift onto a global
671
+ * table across a RERUN refresh). See `DriverContext.onGlobalSubscribe`'s doc comment
672
+ * (`@helipod/component`) for why this exists — closing the busy-DO-late-subscribe gap in the
673
+ * M2c global-reactivity poller.
674
+ */
675
+ globalSubscribeListeners = [];
676
+ verifyAdmin;
677
+ /** Periodic drain sweep — drains recovered clients and abandons terminally-slow queues. */
678
+ sweepTimer = null;
679
+ connect(sessionId, socket) {
680
+ const bp = new SessionBackpressureController(socket, this.options.backpressure, void 0, () => this.reap(sessionId));
681
+ const hb = new SessionHeartbeatController(socket, () => this.reap(sessionId), this.options.heartbeat);
682
+ this.sessions.set(sessionId, { sessionId, socket, version: { ...INITIAL_VERSION }, identity: null, privileged: false, bp, hb, supportsQueryDiff: false });
683
+ if (!this.options.disableBackgroundTimers) hb.start();
684
+ }
685
+ disconnect(sessionId) {
686
+ const session = this.sessions.get(sessionId);
687
+ session?.hb.stop();
688
+ for (const sub of this.subscriptions.forSession(sessionId)) {
689
+ if (sub.resumeKey) this.resumeRegistry.release(sub.resumeKey, Date.now());
690
+ }
691
+ this.subscriptions.removeSession(sessionId);
692
+ this.sessions.delete(sessionId);
693
+ this.pendingFrontiers.delete(sessionId);
694
+ this.clearByIdRowMapForSession(sessionId);
695
+ for (const [commitTs, gate] of this.originResponseGates) {
696
+ if (gate.sessionId === sessionId) this.releaseOriginResponseGate(commitTs);
697
+ }
698
+ }
699
+ /** Drop every `byIdRowMap` entry for a session (disconnect/reap) — ephemeral per-sub state, never
700
+ * durable, so there's nothing to persist on the way out. */
701
+ clearByIdRowMapForSession(sessionId) {
702
+ const prefix = `${sessionId} `;
703
+ for (const key of [...this.byIdRowMap.keys()]) if (key.startsWith(prefix)) this.byIdRowMap.delete(key);
704
+ }
705
+ /** Reap a session whose heartbeat went dead: close the socket, then tear down like a disconnect. */
706
+ reap(sessionId) {
707
+ this.sessions.get(sessionId)?.socket.close();
708
+ this.disconnect(sessionId);
709
+ }
710
+ /** Stop the background sweep. Call on shutdown; sessions must already be disconnected. */
711
+ dispose() {
712
+ if (this.sweepTimer !== null) {
713
+ clearInterval(this.sweepTimer);
714
+ this.sweepTimer = null;
715
+ }
716
+ }
717
+ /** @internal test/debug only — the live ResumeRegistry (DLR Stage 3), for tests to assert
718
+ * population/advance/retain-release wiring without duplicating its internals. */
719
+ get __resumeRegistry() {
720
+ return this.resumeRegistry;
721
+ }
722
+ /** @internal test/debug only — the live SubscriptionManager (M2c), for tests to assert
723
+ * registration wiring (e.g. `globalTables`) without duplicating its internals. */
724
+ get __subscriptions() {
725
+ return this.subscriptions;
726
+ }
727
+ /**
728
+ * M2c Task 6: global (D1-backed) table names with at least one live subscriber right now —
729
+ * delegates to `SubscriptionManager.subscribedGlobalTables()`. This is the ONLY thing a
730
+ * `GlobalReactivityPoller` needs from the sync tier to decide which tables are worth polling D1
731
+ * for (its other dependency, `notifyWrites`, already exists on this class). A public method
732
+ * (unlike `__subscriptions` above) because it is a real runtime dependency wired onto
733
+ * `DriverContext`, not a test-only escape hatch.
734
+ */
735
+ subscribedGlobalTables() {
736
+ return this.subscriptions.subscribedGlobalTables();
737
+ }
738
+ /**
739
+ * M2c fix: register `cb` to fire whenever a subscription is (re-)registered with a non-empty
740
+ * global-table read set — a fresh `doModifyQuerySet` subscribe/resume, a `handleSetAuth` re-exec,
741
+ * or a `sendSessionTransition` live-re-run (see the `globalSubscribeListeners` field doc for all
742
+ * three sites). A driver (e.g. `GlobalReactivityPollerDriver`) uses this to arm itself on any of
743
+ * these late/renewed subscribes, rather than relying solely on a boot-time force-arm that a busy
744
+ * (never-hibernating) DO may never repeat. Not unsubscribed — callers are drivers with the same
745
+ * lifetime as this handler.
746
+ */
747
+ onGlobalSubscribe(cb) {
748
+ this.globalSubscribeListeners.push(cb);
749
+ }
750
+ /** Fire every registered {@link onGlobalSubscribe} listener. Swallows listener throws (never let a
751
+ * driver's own bug break subscription registration). */
752
+ fireGlobalSubscribe() {
753
+ for (const cb of this.globalSubscribeListeners) {
754
+ try {
755
+ cb();
756
+ } catch (e) {
757
+ console.error("[sync] onGlobalSubscribe listener threw:", e);
758
+ }
759
+ }
760
+ }
761
+ send(session, msg) {
762
+ const undroppable = msg.type === "MutationResponse" || msg.type === "ActionResponse";
763
+ session.bp.send(encodeServerMessage(msg), undroppable);
764
+ }
765
+ /**
766
+ * `MutationResponse.ts` (W1) must be the mutation's real commitTs — a client-side optimistic-
767
+ * update gate treats it as an ack signal, and a `0` (or absent) commitTs there would either
768
+ * false-close the gate immediately or wedge a pending layer forever. `commitTs` SHOULD always
769
+ * be a positive integer for a committed mutation; the one known way it can leak as `<= 0` is
770
+ * the `?? 0n` fallback for a forwarded-fleet-write whose owner commitTs didn't make it back
771
+ * (`runtime-embedded/src/runtime.ts`). This codebase has no existing dev/prod split (no
772
+ * `NODE_ENV`/`__DEV__` convention anywhere in `packages/`), so this is unconditional: log
773
+ * loudly every time, and never put a lying `0` on the wire — omit `ts` instead, which is
774
+ * exactly the pre-W1 wire shape every client already knows how to handle.
775
+ */
776
+ mutationResponseTs(commitTs) {
777
+ if (commitTs > 0) return commitTs;
778
+ console.error(
779
+ `[sync] MutationResponse: commitTs invariant violated (expected > 0, got ${commitTs}); omitting ts from the wire`
780
+ );
781
+ return void 0;
782
+ }
783
+ async handleMessage(sessionId, raw) {
784
+ const session = this.sessions.get(sessionId);
785
+ if (!session) throw new Error(`unknown session: ${sessionId}`);
786
+ session.hb.noteActivity();
787
+ const msg = parseClientMessage(raw);
788
+ switch (msg.type) {
789
+ case "Connect":
790
+ return this.handleConnect(session, msg);
791
+ case "ModifyQuerySet":
792
+ return this.handleModifyQuerySet(session, msg);
793
+ case "Mutation":
794
+ return this.handleMutation(session, msg);
795
+ case "MutationBatch":
796
+ return this.handleMutationBatch(session, msg);
797
+ case "Action":
798
+ return this.handleAction(session, msg);
799
+ case "EphemeralPublish":
800
+ this.publishEphemeral(msg.topic, msg.event, sessionId);
801
+ return;
802
+ case "SetAuth":
803
+ return this.handleSetAuth(session, msg);
804
+ case "SetAdminAuth":
805
+ return this.handleSetAdminAuth(session, msg);
806
+ }
807
+ }
808
+ /** Run a subscription's query — privileged for _admin:* on a privileged session; else identity-scoped. */
809
+ async execSub(session, udfPath, args) {
810
+ if (udfPath.startsWith("_admin:")) {
811
+ if (!session.privileged) throw new Error("Forbidden: admin subscription requires admin auth");
812
+ return this.executor.runAdminQuery(udfPath, args);
813
+ }
814
+ return this.executor.runQuery(udfPath, args, session.identity);
815
+ }
816
+ /**
817
+ * G1 hardening (client-sync verdict §(d) item 3): a query-set change is SERIALIZED with the
818
+ * reactive fan-out on the same `notifyTail`, per handler. The shipped code ran MQS inline while
819
+ * `notifyWrites` ran on the tail, so a concurrent invalidation could deliver a NEWER value and
820
+ * then MQS deliver an OLDER one under contiguous brackets — a silent base regression (with
821
+ * optimistic layers, "your own committed write vanishes"). Enqueuing MQS on the tail makes the two
822
+ * strictly ordered: the enqueued unit reads `session.version` at EXECUTION time (inside
823
+ * `doModifyQuerySet`), so its bracket chains contiguously off whatever notify ran just before it.
824
+ * `execSub`→`runQuery` never re-enters this tail (it's a pure engine read), so there is no
825
+ * deadlock — subscribe just waits behind any pending notifies (the accepted latency cost).
826
+ */
827
+ handleModifyQuerySet(session, msg) {
828
+ const run = this.notifyTail.then(() => this.doModifyQuerySet(session, msg));
829
+ this.notifyTail = run.catch(() => void 0);
830
+ return run;
831
+ }
832
+ async doModifyQuerySet(session, msg) {
833
+ const modifications = [];
834
+ for (const q of msg.add) {
835
+ try {
836
+ if (q.sinceTs !== void 0) {
837
+ const rrKey2 = regKey(session.identity, q.udfPath, q.args);
838
+ const entry = this.resumeRegistry.lookup(rrKey2);
839
+ if (entry && !entry.wasDiffable && entry.globalTables.length === 0 && entry.lastInvalidatedTs <= q.sinceTs) {
840
+ this.subscriptions.add({
841
+ sessionId: session.sessionId,
842
+ queryId: q.queryId,
843
+ udfPath: q.udfPath,
844
+ args: q.args,
845
+ tables: [...entry.tables],
846
+ readRanges: entry.readRanges,
847
+ globalTables: [...entry.globalTables],
848
+ byId: void 0,
849
+ resumeKey: rrKey2
850
+ });
851
+ this.resumeRegistry.retain(rrKey2);
852
+ if (entry.globalTables.length > 0) this.fireGlobalSubscribe();
853
+ modifications.push({ type: "QueryUnchanged", queryId: q.queryId });
854
+ continue;
855
+ }
856
+ }
857
+ const { value, tables, readRanges, globalTables, diffableRange, diffablePage } = await this.execSub(session, q.udfPath, q.args);
858
+ const byId = classifyByIdRead(value, readRanges) ?? void 0;
859
+ const range = diffableRange ? rangeReadFromDiffable(diffableRange) : void 0;
860
+ const page = diffablePage ? pageReadFromDiffable(diffablePage) : void 0;
861
+ const rrKey = regKey(session.identity, q.udfPath, q.args);
862
+ this.subscriptions.add({ sessionId: session.sessionId, queryId: q.queryId, udfPath: q.udfPath, args: q.args, tables, readRanges, globalTables, byId, range: page ?? range, resumeKey: rrKey });
863
+ if (globalTables.length > 0) this.fireGlobalSubscribe();
864
+ const wasDiffable = !!(diffableRange || diffablePage || byId);
865
+ this.resumeRegistry.upsert(rrKey, readRanges, tables, session.version.ts, wasDiffable, globalTables);
866
+ this.resumeRegistry.retain(rrKey);
867
+ const json = convexToJson(value);
868
+ if (page && session.supportsQueryDiff) {
869
+ const orderedRows = json.page;
870
+ const { changes, next } = rangeResetChanges(page, orderedRows, session.version.ts);
871
+ this.byIdRowMap.set(subKey2(session.sessionId, q.queryId), next);
872
+ const hash = hashValue(json);
873
+ if (q.resultHash !== void 0 && q.resultHash === hash) {
874
+ modifications.push({ type: "QueryUnchanged", queryId: q.queryId });
875
+ } else {
876
+ modifications.push({
877
+ type: "QueryDiff",
878
+ queryId: q.queryId,
879
+ changes,
880
+ checksum: driftChecksum(next),
881
+ reset: {
882
+ mode: "page",
883
+ orderDir: page.order,
884
+ nextCursor: page.pageMeta.nextCursor,
885
+ hasMore: page.pageMeta.hasMore,
886
+ scanCapped: page.pageMeta.scanCapped
887
+ },
888
+ hash
889
+ });
890
+ }
891
+ } else if (range && session.supportsQueryDiff) {
892
+ const { changes, next } = rangeResetChanges(range, json, session.version.ts);
893
+ this.byIdRowMap.set(subKey2(session.sessionId, q.queryId), next);
894
+ const hash = hashValue(json);
895
+ if (q.resultHash !== void 0 && q.resultHash === hash) {
896
+ modifications.push({ type: "QueryUnchanged", queryId: q.queryId });
897
+ } else {
898
+ modifications.push({
899
+ type: "QueryDiff",
900
+ queryId: q.queryId,
901
+ changes,
902
+ checksum: driftChecksum(next),
903
+ reset: { mode: "range", orderDir: range.order },
904
+ hash
905
+ });
906
+ }
907
+ } else if (byId && session.supportsQueryDiff) {
908
+ const { changes, next } = byIdResetChanges(byId.docId, json, session.version.ts);
909
+ this.byIdRowMap.set(subKey2(session.sessionId, q.queryId), next);
910
+ const hash = hashValue(json);
911
+ if (q.resultHash !== void 0 && q.resultHash === hash) {
912
+ modifications.push({ type: "QueryUnchanged", queryId: q.queryId });
913
+ } else {
914
+ modifications.push({ type: "QueryDiff", queryId: q.queryId, changes, checksum: driftChecksum(next), reset: true, hash });
915
+ }
916
+ } else {
917
+ const hash = hashValue(json);
918
+ if (q.resultHash !== void 0 && q.resultHash === hash) {
919
+ modifications.push({ type: "QueryUnchanged", queryId: q.queryId });
920
+ } else {
921
+ modifications.push({ type: "QueryUpdated", queryId: q.queryId, value: json, hash });
922
+ }
923
+ }
924
+ } catch (e) {
925
+ modifications.push({ type: "QueryFailed", queryId: q.queryId, error: errMessage(e) });
926
+ }
927
+ }
928
+ for (const queryId of msg.remove) {
929
+ const removedSub = this.subscriptions.get(session.sessionId, queryId);
930
+ if (removedSub?.resumeKey) {
931
+ this.resumeRegistry.release(removedSub.resumeKey, Date.now());
932
+ }
933
+ this.subscriptions.remove(session.sessionId, queryId);
934
+ this.byIdRowMap.delete(subKey2(session.sessionId, queryId));
935
+ modifications.push({ type: "QueryRemoved", queryId });
936
+ }
937
+ const start = session.version;
938
+ const end = { querySet: start.querySet + 1, ts: start.ts };
939
+ session.version = end;
940
+ this.send(session, { type: "Transition", startVersion: start, endVersion: end, modifications });
941
+ }
942
+ async handleMutation(session, msg) {
943
+ await this.processMutation(session, msg);
944
+ }
945
+ /**
946
+ * A drained-outbox chunk (verdict §(e)): ONE inbound message carrying N entries. Applied
947
+ * SEQUENTIALLY (`await` each in order) — the client sends only one unacked chunk at a time and
948
+ * relies on per-client FIFO, so units MUST commit in order. One `MutationResponse` is emitted per
949
+ * entry as it settles, EXCEPT when a unit fails TRANSIENTLY (see `processMutation`'s doc comment):
950
+ * that unit still gets its failure response, but the loop then STOPS — the remaining entries get
951
+ * NO response at all, preserving the FIFO drain obligation (a causally-dependent later unit must
952
+ * never apply after an earlier transient/infra failure). The client's one-unacked-chunk-at-a-time
953
+ * protocol resends the whole chunk on the next attempt; per-seq receipts make that resend safe
954
+ * (an already-applied unit replay-acks instead of re-running).
955
+ */
956
+ async handleMutationBatch(session, msg) {
957
+ for (const entry of msg.entries) {
958
+ const outcome = await this.processMutation(session, entry);
959
+ if (outcome === "stop") break;
960
+ }
961
+ }
962
+ /**
963
+ * The per-unit mutation core shared by `Mutation` and `MutationBatch` — threads the durable
964
+ * `(clientId, seq)` down to the OWNER's classification (verdict §(c)), sends the response, and
965
+ * (for a fresh commit only) fans out. A `MutationReplay` return skips `notifyWrites` AND the G4
966
+ * pending-frontier entirely (nothing was written this call — Risk R7): its `commitTs` is the
967
+ * ORIGINAL, long past the current frontier, so arming a frontier or fanning out would be a lie.
968
+ *
969
+ * Returns `"continue" | "stop"` — meaningful only to `handleMutationBatch`'s drain loop (a
970
+ * standalone `Mutation` ignores it). A thrown error is classified via the executor's retryable
971
+ * discipline (`isRetryableError`, `@helipod/errors` — the same classification
972
+ * `handleDedupError`'s dedup path already applies when deciding whether to record a verdict):
973
+ * - TERMINAL (not retryable — a deterministic app error, a coded verdict failure/replay) means the
974
+ * executor already recorded whatever verdict applies; the batch drain CONTINUES past it (a
975
+ * poison unit never blocks the rest — matches the spec's documented mid-batch-continue case).
976
+ * - TRANSIENT (retryable — infra/conflict) means nothing durable happened for this unit; the batch
977
+ * drain STOPS here so a later, causally-dependent unit can never apply out of order relative to
978
+ * it. The remaining units get no response and the client's FIFO resend picks them back up.
979
+ */
980
+ async processMutation(session, unit) {
981
+ const dedup = unit.clientId !== void 0 && unit.seq !== void 0 ? { clientId: unit.clientId, seq: unit.seq } : void 0;
982
+ try {
983
+ const r = await this.executor.runMutation(unit.udfPath, unit.args, session.identity, session.sessionId, dedup);
984
+ if (r.replayed) {
985
+ if (r.verdict === "applied") {
986
+ this.send(session, {
987
+ type: "MutationResponse",
988
+ requestId: unit.requestId,
989
+ success: true,
990
+ replayed: true,
991
+ ts: r.commitTs !== void 0 ? this.mutationResponseTs(r.commitTs) : void 0,
992
+ ...r.valueMissing ? { valueMissing: true } : { value: convexToJson(r.value) }
993
+ });
994
+ } else {
995
+ this.send(session, {
996
+ type: "MutationResponse",
997
+ requestId: unit.requestId,
998
+ success: false,
999
+ error: r.code ?? (r.verdict === "stale" ? "STALE_CLIENT" : "mutation failed"),
1000
+ code: r.code ?? (r.verdict === "stale" ? "STALE_CLIENT" : void 0)
1001
+ });
1002
+ }
1003
+ return "continue";
1004
+ }
1005
+ const { value, tables, writeRanges, commitTs, forwarded } = r;
1006
+ this.send(session, {
1007
+ type: "MutationResponse",
1008
+ requestId: unit.requestId,
1009
+ success: true,
1010
+ value: convexToJson(value),
1011
+ ts: this.mutationResponseTs(commitTs)
1012
+ });
1013
+ this.releaseOriginResponseGate(commitTs);
1014
+ if (forwarded && commitTs > 0) {
1015
+ const prev = this.pendingFrontiers.get(session.sessionId);
1016
+ if (prev === void 0 || commitTs > prev) this.pendingFrontiers.set(session.sessionId, commitTs);
1017
+ }
1018
+ if (this.options.autoNotifyOnMutation !== false) {
1019
+ await this.notifyWrites({ tables, ranges: writeRanges, commitTs }, session.sessionId);
1020
+ }
1021
+ return "continue";
1022
+ } catch (e) {
1023
+ const committedTs = committedTsOfError(e);
1024
+ if (committedTs !== void 0) this.releaseOriginResponseGate(committedTs);
1025
+ this.send(session, {
1026
+ type: "MutationResponse",
1027
+ requestId: unit.requestId,
1028
+ success: false,
1029
+ error: errMessage(e),
1030
+ code: isHelipodError(e) && !isRetryableError(e) ? e.code : void 0
1031
+ });
1032
+ return isRetryableError(e) ? "stop" : "continue";
1033
+ }
1034
+ }
1035
+ /**
1036
+ * The `Connect` resume handshake (verdict §(e)): activated from the reserved no-op. Classifies each
1037
+ * presented `held` seq into `ConnectAck.results`, ack-prunes the `ackedThrough` contiguous
1038
+ * settled-prefix, and stamps the `deploymentId` (same-timeline proof, §(g) hazard 15). `known`
1039
+ * is false when the client presents history the server recognizes NONE of (a swept/foreign timeline
1040
+ * → the client resets). A bare `Connect` (no `clientId`/`held`/`ackedThrough`, or an executor with
1041
+ * no receipts support) stays the pre-Outbox no-op: no ConnectAck is sent, bit-for-bit.
1042
+ */
1043
+ async handleConnect(session, msg) {
1044
+ session.supportsQueryDiff = msg.supportsQueryDiff === true;
1045
+ if (msg.clientId === void 0 && msg.held === void 0 && msg.ackedThrough === void 0) return;
1046
+ if (!this.executor.classifyClientMutation || !this.executor.deploymentId) return;
1047
+ const results = [];
1048
+ let recognizedAny = false;
1049
+ let presentedAny = false;
1050
+ for (const ref of msg.held ?? []) {
1051
+ presentedAny = true;
1052
+ const v = await this.executor.classifyClientMutation(session.identity, ref.clientId, ref.seq);
1053
+ if (v.verdict !== "unknown") recognizedAny = true;
1054
+ results.push(v);
1055
+ }
1056
+ for (const ref of msg.ackedThrough ?? []) {
1057
+ presentedAny = true;
1058
+ if (this.executor.classifyClientMutation) {
1059
+ const v = await this.executor.classifyClientMutation(session.identity, ref.clientId, ref.seq);
1060
+ if (v.verdict !== "unknown") recognizedAny = true;
1061
+ }
1062
+ await this.executor.pruneClientMutations?.(session.identity, ref.clientId, ref.seq);
1063
+ }
1064
+ this.send(session, {
1065
+ type: "ConnectAck",
1066
+ known: presentedAny ? recognizedAny : true,
1067
+ results,
1068
+ deploymentId: this.executor.deploymentId()
1069
+ });
1070
+ }
1071
+ /**
1072
+ * A one-shot request→value call — NOT reactive (an action has no read/write set of its own).
1073
+ * Deliberately does NOT call `notifyWrites`: any mutation the action invoked via
1074
+ * `ctx.runMutation` already fanned out through that mutation's own commit.
1075
+ */
1076
+ async handleAction(session, msg) {
1077
+ try {
1078
+ const { value } = await this.executor.runAction(msg.udfPath, msg.args, session.identity);
1079
+ this.send(session, { type: "ActionResponse", requestId: msg.requestId, success: true, value: convexToJson(value) });
1080
+ } catch (e) {
1081
+ this.send(session, { type: "ActionResponse", requestId: msg.requestId, success: false, error: errMessage(e) });
1082
+ }
1083
+ }
1084
+ /**
1085
+ * Reactive fan-out: recompute subscriptions a write touched and push transitions. Calls are
1086
+ * serialized so per-session version brackets advance monotonically (concurrent notifies
1087
+ * would otherwise reorder and trigger false client resyncs).
1088
+ */
1089
+ notifyWrites(invalidation, originSessionId) {
1090
+ const run = this.notifyTail.then(() => this.doNotifyWrites(invalidation, originSessionId));
1091
+ this.notifyTail = run.catch(() => void 0);
1092
+ return run;
1093
+ }
1094
+ async doNotifyWrites(invalidation, originSessionId) {
1095
+ if (!invalidation.global) {
1096
+ this.resumeRegistry.advanceOnCommit(invalidation.ranges ?? [], invalidation.tables, invalidation.commitTs);
1097
+ }
1098
+ this.resumeRegistry.sweep(Date.now());
1099
+ const affected = this.subscriptions.findAffectedByRanges(invalidation.ranges ?? [], invalidation.tables);
1100
+ const bySession = /* @__PURE__ */ new Map();
1101
+ for (const sub of affected) {
1102
+ if (this.options.excludeOriginFromTransition && sub.sessionId === originSessionId) continue;
1103
+ const list = bySession.get(sub.sessionId) ?? [];
1104
+ list.push(sub);
1105
+ bySession.set(sub.sessionId, list);
1106
+ }
1107
+ const originIsDiffCapable = !!originSessionId && bySession.has(originSessionId) && this.sessions.get(originSessionId)?.supportsQueryDiff === true;
1108
+ for (const [sessionId, subs] of bySession) {
1109
+ if (originIsDiffCapable && sessionId === originSessionId) continue;
1110
+ const session = this.sessions.get(sessionId);
1111
+ if (!session) continue;
1112
+ await this.sendSessionTransition(session, subs, invalidation);
1113
+ }
1114
+ if (originIsDiffCapable) {
1115
+ const originSubs = bySession.get(originSessionId);
1116
+ const originSession = this.sessions.get(originSessionId);
1117
+ const gate = this.originResponseGates.get(invalidation.commitTs);
1118
+ if (gate) await gate.promise;
1119
+ await this.sendSessionTransition(originSession, originSubs, invalidation);
1120
+ }
1121
+ if (!invalidation.global) {
1122
+ this.advanceOriginFrontier(originSessionId, bySession, invalidation.commitTs);
1123
+ this.sweepPendingFrontiers(invalidation.commitTs, bySession);
1124
+ }
1125
+ }
1126
+ /**
1127
+ * DLR 2b — register the response-before-Transition gate for `commitTs`, at COMMIT time. Called
1128
+ * SYNCHRONOUSLY from the runtime's fan-out subscribe callback (which fires inside the commit,
1129
+ * before `runMutation` resolves and thus before this commit's `MutationResponse` can be sent), so
1130
+ * the gate reliably exists before {@link releaseOriginResponseGate} runs — no matter how backed up
1131
+ * the serial fan-out drain is. Public because the decoupled runtime owns the commit-time seam.
1132
+ *
1133
+ * Registers ONLY for a diff-capable LOCAL origin session — exactly the case `doNotifyWrites` parks
1134
+ * on, and exactly the case whose own `processMutation` will release it, so every gate is balanced
1135
+ * (no leak). A no-origin commit, a foreign/absent session, or a diff-incapable session registers
1136
+ * nothing. Idempotent per `commitTs`.
1137
+ */
1138
+ registerOriginResponseGate(commitTs, originSessionId) {
1139
+ if (originSessionId === void 0) return;
1140
+ if (this.sessions.get(originSessionId)?.supportsQueryDiff !== true) return;
1141
+ if (this.originResponseGates.has(commitTs)) return;
1142
+ let resolve;
1143
+ const promise = new Promise((r) => resolve = r);
1144
+ this.originResponseGates.set(commitTs, { promise, resolve, sessionId: originSessionId });
1145
+ }
1146
+ /**
1147
+ * DLR 2b — release (and drop) the response gate for `commitTs`. Called right after the commit's
1148
+ * `MutationResponse` is enqueued, so the parked origin Transition flushes strictly behind it. A
1149
+ * no-op when no gate is registered (a diff-incapable / no-origin commit never registered one) — so
1150
+ * an ordinary commit costs nothing here.
1151
+ */
1152
+ releaseOriginResponseGate(commitTs) {
1153
+ const gate = this.originResponseGates.get(commitTs);
1154
+ if (gate !== void 0) {
1155
+ this.originResponseGates.delete(commitTs);
1156
+ gate.resolve();
1157
+ }
1158
+ }
1159
+ /**
1160
+ * Compute one session's modifications for this commit (by-id / range `QueryDiff` incremental arms,
1161
+ * or the RERUN `QueryUpdated`/`QueryFailed` arm) and send its Transition. Extracted from
1162
+ * `doNotifyWrites`'s per-session loop body (byte-identical logic) so the origin session's own call
1163
+ * can be deferred past the response-ordering macrotask yield while every non-origin session is
1164
+ * computed+sent immediately, with no shared logic duplicated between the two call sites.
1165
+ */
1166
+ async sendSessionTransition(session, subs, invalidation) {
1167
+ const modifications = [];
1168
+ for (const sub of subs) {
1169
+ try {
1170
+ const key = subKey2(sub.sessionId, sub.queryId);
1171
+ if (sub.range && session.supportsQueryDiff && invalidation.writtenDocs) {
1172
+ const subTable = tableOfKeyspaceId(sub.range.keyspace);
1173
+ const wds = invalidation.writtenDocs.filter((w) => tableOfKeyspaceId(w.keyspace) === subTable);
1174
+ const prevMap = this.byIdRowMap.get(key) ?? /* @__PURE__ */ new Map();
1175
+ const { changes, next } = rangeChangesFor(sub.range, prevMap, wds);
1176
+ this.byIdRowMap.set(key, next);
1177
+ modifications.push({ type: "QueryDiff", queryId: sub.queryId, changes, checksum: driftChecksum(next) });
1178
+ continue;
1179
+ }
1180
+ if (sub.byId && session.supportsQueryDiff && invalidation.writtenDocs) {
1181
+ const wd = invalidation.writtenDocs.find(
1182
+ (w) => w.keyspace === sub.byId.keyspace && w.key === sub.byId.key
1183
+ );
1184
+ const prevMap = this.byIdRowMap.get(key) ?? /* @__PURE__ */ new Map();
1185
+ const { changes, next } = byIdChangesFor(sub.byId, prevMap, wd);
1186
+ this.byIdRowMap.set(key, next);
1187
+ modifications.push({ type: "QueryDiff", queryId: sub.queryId, changes, checksum: driftChecksum(next) });
1188
+ continue;
1189
+ }
1190
+ const { value, tables, readRanges, globalTables, diffableRange, diffablePage } = await this.execSub(session, sub.udfPath, sub.args);
1191
+ const byId = classifyByIdRead(value, readRanges) ?? void 0;
1192
+ const range = diffableRange ? rangeReadFromDiffable(diffableRange) : void 0;
1193
+ const page = diffablePage ? pageReadFromDiffable(diffablePage) : void 0;
1194
+ this.subscriptions.add({ ...sub, tables, readRanges, globalTables, byId, range: page ?? range });
1195
+ if (globalTables.length > 0) this.fireGlobalSubscribe();
1196
+ if (sub.resumeKey) {
1197
+ this.resumeRegistry.upsert(sub.resumeKey, readRanges, tables, Number(invalidation.commitTs), !!(page ?? range) || !!byId, globalTables);
1198
+ }
1199
+ if (sub.byId && (!byId || byId.keyspace !== sub.byId.keyspace || byId.key !== sub.byId.key)) {
1200
+ this.byIdRowMap.delete(subKey2(sub.sessionId, sub.queryId));
1201
+ }
1202
+ if (sub.range) {
1203
+ this.byIdRowMap.delete(subKey2(sub.sessionId, sub.queryId));
1204
+ }
1205
+ const json = convexToJson(value);
1206
+ modifications.push({ type: "QueryUpdated", queryId: sub.queryId, value: json, hash: hashValue(json) });
1207
+ } catch (e) {
1208
+ modifications.push({ type: "QueryFailed", queryId: sub.queryId, error: errMessage(e) });
1209
+ }
1210
+ }
1211
+ const start = session.version;
1212
+ const end = { querySet: start.querySet, ts: invalidation.global ? start.ts : invalidation.commitTs };
1213
+ session.version = end;
1214
+ this.send(session, { type: "Transition", startVersion: start, endVersion: end, modifications });
1215
+ }
1216
+ /** Emit a standalone empty ts-advancing Transition — advances `session.version.ts` to `ts` with no
1217
+ * modifications. The one construct that closes a client's optimistic-update gate for a commit that
1218
+ * touched nothing the session subscribes to. Callers guard `ts > session.version.ts` (monotone). */
1219
+ emitEmptyFrontier(session, ts) {
1220
+ const start = session.version;
1221
+ const end = { querySet: start.querySet, ts };
1222
+ session.version = end;
1223
+ this.send(session, { type: "Transition", startVersion: start, endVersion: end, modifications: [] });
1224
+ }
1225
+ /** G4 primary: advance the LOCAL origin session's frontier when its own commit missed all its
1226
+ * subscriptions. A local commit supersedes any stale forwarded fallback entry for that session. */
1227
+ advanceOriginFrontier(originSessionId, bySession, commitTs) {
1228
+ if (!originSessionId || bySession.has(originSessionId)) return;
1229
+ const session = this.sessions.get(originSessionId);
1230
+ if (!session || commitTs <= session.version.ts) return;
1231
+ this.emitEmptyFrontier(session, commitTs);
1232
+ this.pendingFrontiers.delete(originSessionId);
1233
+ }
1234
+ /** G4 fleet fallback: satisfy pending forwarded-mutation frontiers now that the drain reached
1235
+ * `drainTs`. A frontier still above `drainTs` waits for a later drain; one already covered by the
1236
+ * session's own subscription update (in `bySession` this drain, or an earlier ts advance) clears
1237
+ * without a redundant frame; otherwise an empty ts-advance to the frontier is emitted. */
1238
+ sweepPendingFrontiers(drainTs, bySession) {
1239
+ if (this.pendingFrontiers.size === 0) return;
1240
+ for (const [sessionId, frontierTs] of this.pendingFrontiers) {
1241
+ if (frontierTs > drainTs) continue;
1242
+ const session = this.sessions.get(sessionId);
1243
+ if (session && session.version.ts < frontierTs && !bySession.has(sessionId)) {
1244
+ this.emitEmptyFrontier(session, frontierTs);
1245
+ }
1246
+ this.pendingFrontiers.delete(sessionId);
1247
+ }
1248
+ }
1249
+ async handleSetAdminAuth(session, msg) {
1250
+ session.privileged = this.verifyAdmin(msg.key);
1251
+ }
1252
+ async handleSetAuth(session, msg) {
1253
+ session.identity = msg.token;
1254
+ const subs = this.subscriptions.forSession(session.sessionId);
1255
+ const modifications = [];
1256
+ for (const sub of subs) {
1257
+ try {
1258
+ const { value, tables, readRanges, globalTables, diffableRange, diffablePage } = await this.execSub(session, sub.udfPath, sub.args);
1259
+ const byId = classifyByIdRead(value, readRanges) ?? void 0;
1260
+ const range = diffableRange ? rangeReadFromDiffable(diffableRange) : void 0;
1261
+ const page = diffablePage ? pageReadFromDiffable(diffablePage) : void 0;
1262
+ const newResumeKey = regKey(session.identity, sub.udfPath, sub.args);
1263
+ this.resumeRegistry.upsert(newResumeKey, readRanges, tables, session.version.ts, !!(page ?? range) || !!byId, globalTables);
1264
+ if (sub.resumeKey !== newResumeKey) {
1265
+ this.resumeRegistry.retain(newResumeKey);
1266
+ if (sub.resumeKey) this.resumeRegistry.release(sub.resumeKey, Date.now());
1267
+ }
1268
+ this.subscriptions.add({ ...sub, tables, readRanges, globalTables, byId, range: page ?? range, resumeKey: newResumeKey });
1269
+ if (globalTables.length > 0) this.fireGlobalSubscribe();
1270
+ const key = subKey2(session.sessionId, sub.queryId);
1271
+ const json = convexToJson(value);
1272
+ if (byId && session.supportsQueryDiff) {
1273
+ const { changes, next } = byIdResetChanges(byId.docId, json, session.version.ts);
1274
+ this.byIdRowMap.set(key, next);
1275
+ modifications.push({ type: "QueryDiff", queryId: sub.queryId, changes, checksum: driftChecksum(next), reset: true });
1276
+ } else {
1277
+ this.byIdRowMap.delete(key);
1278
+ modifications.push({ type: "QueryUpdated", queryId: sub.queryId, value: json, hash: hashValue(json) });
1279
+ }
1280
+ } catch (e) {
1281
+ modifications.push({ type: "QueryFailed", queryId: sub.queryId, error: errMessage(e) });
1282
+ }
1283
+ }
1284
+ const start = session.version;
1285
+ const end = { querySet: start.querySet + 1, ts: start.ts };
1286
+ session.version = end;
1287
+ this.send(session, { type: "Transition", startVersion: start, endVersion: end, modifications });
1288
+ }
1289
+ /** Ephemeral broadcast (presence/typing) — bypasses the engine entirely. */
1290
+ publishEphemeral(topic, event, fromSessionId) {
1291
+ for (const [sessionId, session] of this.sessions) {
1292
+ if (sessionId === fromSessionId) continue;
1293
+ this.send(session, { type: "Broadcast", topic, event });
1294
+ }
1295
+ }
1296
+ };
1297
+
1298
+ // src/client-reducer.ts
1299
+ function createClientState() {
1300
+ return {
1301
+ version: { ...INITIAL_VERSION },
1302
+ queries: /* @__PURE__ */ new Map(),
1303
+ needsResync: false,
1304
+ mutationResults: /* @__PURE__ */ new Map(),
1305
+ broadcasts: []
1306
+ };
1307
+ }
1308
+ function applyServerMessage(state, msg) {
1309
+ switch (msg.type) {
1310
+ case "Transition": {
1311
+ if (!versionsEqual(msg.startVersion, state.version)) {
1312
+ state.needsResync = true;
1313
+ return;
1314
+ }
1315
+ for (const mod of msg.modifications) {
1316
+ if (mod.type === "QueryUpdated") state.queries.set(mod.queryId, mod.value);
1317
+ else if (mod.type === "QueryRemoved") state.queries.delete(mod.queryId);
1318
+ }
1319
+ state.version = msg.endVersion;
1320
+ return;
1321
+ }
1322
+ case "MutationResponse":
1323
+ state.mutationResults.set(
1324
+ msg.requestId,
1325
+ msg.success ? { success: true, value: msg.value } : { success: false, error: msg.error }
1326
+ );
1327
+ return;
1328
+ case "Broadcast":
1329
+ state.broadcasts.push({ topic: msg.topic, event: msg.event });
1330
+ return;
1331
+ default:
1332
+ return;
1333
+ }
1334
+ }
1335
+ export {
1336
+ INITIAL_VERSION,
1337
+ ResumeRegistry,
1338
+ SessionBackpressureController,
1339
+ SessionHeartbeatController,
1340
+ SubscriptionManager,
1341
+ SyncProtocolHandler,
1342
+ TTL_MS,
1343
+ applyChanges,
1344
+ applyServerMessage,
1345
+ compareStateVersion,
1346
+ createClientState,
1347
+ driftChecksum,
1348
+ encodeServerMessage,
1349
+ isContiguous,
1350
+ parseClientMessage,
1351
+ regKey,
1352
+ versionsEqual
1353
+ };
1354
+ //# sourceMappingURL=index.js.map