@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.d.ts +944 -0
- package/dist/index.js +1354 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
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
|