@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.d.ts
ADDED
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
import { JSONValue, Value } from '@helipod/values';
|
|
2
|
+
import { SerializedKeyRange } from '@helipod/index-key-codec';
|
|
3
|
+
import { FilterExpr } from '@helipod/query-engine';
|
|
4
|
+
import { WrittenDoc } from '@helipod/transactor';
|
|
5
|
+
import { DiffableRange, DiffablePage } from '@helipod/executor';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The DLR row-diff vocabulary shared by the server (emit) and the client (apply). A DIFFABLE query's
|
|
9
|
+
* materialized value is a keyed `Map<docId, RowVersion>`; `applyChanges` is the ONE apply used on both
|
|
10
|
+
* sides so they cannot drift in how a diff materializes. `driftChecksum` is an order-independent XOR
|
|
11
|
+
* fold over `(key, ts)` — a cheap safety net: the client recomputes it after applying and, on
|
|
12
|
+
* mismatch, scoped-resyncs that one query. See docs/dev/architecture/reactivity-differential-log-tail.md §4.3-4.4.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
type Change = {
|
|
16
|
+
t: "add";
|
|
17
|
+
key: string;
|
|
18
|
+
row: JSONValue;
|
|
19
|
+
ts: number;
|
|
20
|
+
orderKey?: string;
|
|
21
|
+
} | {
|
|
22
|
+
t: "remove";
|
|
23
|
+
key: string;
|
|
24
|
+
} | {
|
|
25
|
+
t: "edit";
|
|
26
|
+
key: string;
|
|
27
|
+
row: JSONValue;
|
|
28
|
+
ts: number;
|
|
29
|
+
orderKey?: string;
|
|
30
|
+
};
|
|
31
|
+
interface RowVersion {
|
|
32
|
+
row: JSONValue;
|
|
33
|
+
ts: number;
|
|
34
|
+
orderKey?: string;
|
|
35
|
+
}
|
|
36
|
+
/** Apply changes to a keyed row-map, copy-on-write. Returns a NEW map (callers rely on a fresh
|
|
37
|
+
* reference to fire listeners). `add`/`edit` set `{row, ts, orderKey}`; `remove` deletes the key. */
|
|
38
|
+
declare function applyChanges(rows: Map<string, RowVersion>, changes: readonly Change[]): Map<string, RowVersion>;
|
|
39
|
+
/** FNV-1a 32-bit of `"<key> <ts> <orderKey>"` per row, XOR-folded to 32 bits. Order-independent so server and
|
|
40
|
+
* client agree regardless of iteration order. Hex string. */
|
|
41
|
+
declare function driftChecksum(rows: Map<string, RowVersion>): string;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The reactive sync protocol — the client↔server message catalog and the version model.
|
|
45
|
+
*
|
|
46
|
+
* State is **version-bracketed**: every `Transition` advances `startVersion → endVersion`,
|
|
47
|
+
* and a client applies one only if its `startVersion` matches the client's current version.
|
|
48
|
+
* A missed frame leaves a gap the client detects and resyncs from — so dropping a frame
|
|
49
|
+
* (backpressure) degrades to a resync, never to silent divergence. The `ServerMessage` union
|
|
50
|
+
* is versioned-by-shape and deliberately extensible (e.g. the non-commit `Broadcast` kind for
|
|
51
|
+
* the ephemeral path) so the wire can later gain a binary delta encoding.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
interface StateVersion {
|
|
55
|
+
/** Bumped when the set of subscribed queries changes. */
|
|
56
|
+
querySet: number;
|
|
57
|
+
/** The latest commit timestamp reflected (0 = none). */
|
|
58
|
+
ts: number;
|
|
59
|
+
}
|
|
60
|
+
declare const INITIAL_VERSION: StateVersion;
|
|
61
|
+
declare function versionsEqual(a: StateVersion, b: StateVersion): boolean;
|
|
62
|
+
declare function compareStateVersion(a: StateVersion, b: StateVersion): -1 | 0 | 1;
|
|
63
|
+
/** Two brackets are contiguous when the next starts exactly where the previous ended. */
|
|
64
|
+
declare function isContiguous(prevEnd: StateVersion, nextStart: StateVersion): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* `resultHash` (subscription resume, design 2025-11-28): the client's last-known server-minted
|
|
67
|
+
* fingerprint for this query, echoed back on resubscribe. Present only when the subscription was
|
|
68
|
+
* previously `answered` with a defined value; absent on a first subscribe, a prior `QueryFailed`,
|
|
69
|
+
* or an old client that predates this field — all of which fall through to today's full send.
|
|
70
|
+
*/
|
|
71
|
+
interface QueryRequest {
|
|
72
|
+
queryId: number;
|
|
73
|
+
udfPath: string;
|
|
74
|
+
args: JSONValue;
|
|
75
|
+
resultHash?: string;
|
|
76
|
+
/**
|
|
77
|
+
* DLR Stage 3 — the client's `maxObservedTs` at resume time; lets the server skip the re-run
|
|
78
|
+
* when nothing touched the query's read-set since. Present only on a resume resubscribe;
|
|
79
|
+
* absent on a fresh subscribe.
|
|
80
|
+
*/
|
|
81
|
+
sinceTs?: number;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The durable per-tab client identity for a resend-safe mutation (Receipted Outbox, verdict §(b)/(e)).
|
|
85
|
+
* `clientId` is minted once per tab-session; `seq` is a per-tab monotone counter. The `(identity,
|
|
86
|
+
* clientId, seq)` triple is the write-once dedup key; `identity` is the session's ambient token,
|
|
87
|
+
* supplied server-side (never trusted from the client). Both fields absent → today's unconditional
|
|
88
|
+
* path, bit-for-bit — a mutation with no `clientId` writes no receipt and reads no classification.
|
|
89
|
+
*/
|
|
90
|
+
interface ClientMutationRef {
|
|
91
|
+
clientId: string;
|
|
92
|
+
seq: number;
|
|
93
|
+
}
|
|
94
|
+
/** One entry of a {@link MutationBatch} — the same shape a standalone `Mutation` carries. */
|
|
95
|
+
interface MutationBatchEntry {
|
|
96
|
+
requestId: string;
|
|
97
|
+
udfPath: string;
|
|
98
|
+
args: JSONValue;
|
|
99
|
+
clientId?: string;
|
|
100
|
+
seq?: number;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* A per-seq verdict as it travels on the wire (`ConnectAck.results`, verdict §(e)). `verdict`
|
|
104
|
+
* distinguishes a replayed success (`applied`), a replayed terminal failure (`failed`), a
|
|
105
|
+
* loudly-disowned pruned/holed seq (`stale`), and a never-seen seq the client must (re)send
|
|
106
|
+
* (`unknown`). `commitTs`/`value`/`valueMissing`/`code` mirror {@link MutationResponse}'s replay shape.
|
|
107
|
+
*/
|
|
108
|
+
interface ClientMutationVerdict {
|
|
109
|
+
clientId: string;
|
|
110
|
+
seq: number;
|
|
111
|
+
verdict: "applied" | "failed" | "stale" | "unknown";
|
|
112
|
+
commitTs?: number;
|
|
113
|
+
value?: JSONValue;
|
|
114
|
+
valueMissing?: true;
|
|
115
|
+
code?: string;
|
|
116
|
+
}
|
|
117
|
+
type ClientMessage = {
|
|
118
|
+
type: "Connect";
|
|
119
|
+
sessionId: string;
|
|
120
|
+
clientId?: string;
|
|
121
|
+
held?: ClientMutationRef[];
|
|
122
|
+
ackedThrough?: ClientMutationRef[];
|
|
123
|
+
supportsQueryDiff?: true;
|
|
124
|
+
} | {
|
|
125
|
+
type: "ModifyQuerySet";
|
|
126
|
+
add: QueryRequest[];
|
|
127
|
+
remove: number[];
|
|
128
|
+
} | {
|
|
129
|
+
type: "Mutation";
|
|
130
|
+
requestId: string;
|
|
131
|
+
udfPath: string;
|
|
132
|
+
args: JSONValue;
|
|
133
|
+
clientId?: string;
|
|
134
|
+
seq?: number;
|
|
135
|
+
} | {
|
|
136
|
+
type: "MutationBatch";
|
|
137
|
+
entries: MutationBatchEntry[];
|
|
138
|
+
} | {
|
|
139
|
+
type: "Action";
|
|
140
|
+
requestId: string;
|
|
141
|
+
udfPath: string;
|
|
142
|
+
args: JSONValue;
|
|
143
|
+
} | {
|
|
144
|
+
type: "EphemeralPublish";
|
|
145
|
+
topic: string;
|
|
146
|
+
event: JSONValue;
|
|
147
|
+
} | {
|
|
148
|
+
type: "SetAuth";
|
|
149
|
+
token: string | null;
|
|
150
|
+
} | {
|
|
151
|
+
type: "SetAdminAuth";
|
|
152
|
+
key: string;
|
|
153
|
+
};
|
|
154
|
+
type StateModification = {
|
|
155
|
+
type: "QueryUpdated";
|
|
156
|
+
queryId: number;
|
|
157
|
+
value: JSONValue;
|
|
158
|
+
hash?: string;
|
|
159
|
+
} | {
|
|
160
|
+
type: "QueryFailed";
|
|
161
|
+
queryId: number;
|
|
162
|
+
error: string;
|
|
163
|
+
} | {
|
|
164
|
+
type: "QueryRemoved";
|
|
165
|
+
queryId: number;
|
|
166
|
+
} | {
|
|
167
|
+
type: "QueryUnchanged";
|
|
168
|
+
queryId: number;
|
|
169
|
+
} | {
|
|
170
|
+
type: "QueryDiff";
|
|
171
|
+
queryId: number;
|
|
172
|
+
changes: Change[];
|
|
173
|
+
checksum: string;
|
|
174
|
+
reset?: true | {
|
|
175
|
+
mode: "byid" | "range";
|
|
176
|
+
orderDir?: "asc" | "desc";
|
|
177
|
+
} | {
|
|
178
|
+
mode: "page";
|
|
179
|
+
orderDir: "asc" | "desc";
|
|
180
|
+
nextCursor: string | null;
|
|
181
|
+
hasMore: boolean;
|
|
182
|
+
scanCapped: boolean;
|
|
183
|
+
};
|
|
184
|
+
hash?: string;
|
|
185
|
+
};
|
|
186
|
+
type ServerMessage = {
|
|
187
|
+
type: "Transition";
|
|
188
|
+
startVersion: StateVersion;
|
|
189
|
+
endVersion: StateVersion;
|
|
190
|
+
modifications: StateModification[];
|
|
191
|
+
} | {
|
|
192
|
+
type: "MutationResponse";
|
|
193
|
+
requestId: string;
|
|
194
|
+
success: true;
|
|
195
|
+
value?: JSONValue;
|
|
196
|
+
ts?: number;
|
|
197
|
+
replayed?: true;
|
|
198
|
+
valueMissing?: true;
|
|
199
|
+
} | {
|
|
200
|
+
type: "MutationResponse";
|
|
201
|
+
requestId: string;
|
|
202
|
+
success: false;
|
|
203
|
+
error: string;
|
|
204
|
+
code?: string;
|
|
205
|
+
} | {
|
|
206
|
+
type: "ActionResponse";
|
|
207
|
+
requestId: string;
|
|
208
|
+
success: true;
|
|
209
|
+
value: JSONValue;
|
|
210
|
+
} | {
|
|
211
|
+
type: "ActionResponse";
|
|
212
|
+
requestId: string;
|
|
213
|
+
success: false;
|
|
214
|
+
error: string;
|
|
215
|
+
} | {
|
|
216
|
+
type: "ConnectAck";
|
|
217
|
+
known: boolean;
|
|
218
|
+
results: ClientMutationVerdict[];
|
|
219
|
+
deploymentId: string;
|
|
220
|
+
} | {
|
|
221
|
+
type: "Broadcast";
|
|
222
|
+
topic: string;
|
|
223
|
+
event: JSONValue;
|
|
224
|
+
} | {
|
|
225
|
+
type: "FatalError";
|
|
226
|
+
message: string;
|
|
227
|
+
} | {
|
|
228
|
+
type: "Ping";
|
|
229
|
+
};
|
|
230
|
+
declare function parseClientMessage(raw: string): ClientMessage;
|
|
231
|
+
declare function encodeServerMessage(msg: ServerMessage): string;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Classify a subscription as DIFFABLE_BYID (a single `db.get(id)`) vs RERUN, from its recorded read
|
|
235
|
+
* set + result. A by-id read records EXACTLY one point range in a table's primary keyspace
|
|
236
|
+
* (`table:<enc>`, NOT `index:...`), and returns a single document object or `null`. Anything else —
|
|
237
|
+
* multiple ranges, an index/collect read, a span range, an array result — is RERUN (safe fallback).
|
|
238
|
+
*/
|
|
239
|
+
|
|
240
|
+
interface ByIdRead {
|
|
241
|
+
keyspace: string;
|
|
242
|
+
/** base64 of the point-range start bytes (== the doc's primary-key bytes). */
|
|
243
|
+
key: string;
|
|
244
|
+
/** the public document id (the diff Change.key), taken from the returned doc's `_id`. */
|
|
245
|
+
docId: string;
|
|
246
|
+
}
|
|
247
|
+
/** A page's fixed metadata, as returned to the guest by `db.query(...).paginate()` — see
|
|
248
|
+
* `DiffablePage`'s doc comment in `packages/executor/src/executor.ts`. */
|
|
249
|
+
interface PageMeta {
|
|
250
|
+
nextCursor: string | null;
|
|
251
|
+
hasMore: boolean;
|
|
252
|
+
scanCapped: boolean;
|
|
253
|
+
}
|
|
254
|
+
interface RangeRead {
|
|
255
|
+
keyspace: string;
|
|
256
|
+
bounds: SerializedKeyRange;
|
|
257
|
+
filters: FilterExpr[];
|
|
258
|
+
order: "asc" | "desc";
|
|
259
|
+
fields: string[];
|
|
260
|
+
/** Present iff this range is a page (DLR Stage 2c) — the page's own fixed metadata. */
|
|
261
|
+
pageMeta?: PageMeta;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
type MatchMode = "table" | "range";
|
|
265
|
+
interface Subscription {
|
|
266
|
+
sessionId: string;
|
|
267
|
+
queryId: number;
|
|
268
|
+
udfPath: string;
|
|
269
|
+
args: JSONValue;
|
|
270
|
+
/** Tables this subscription's read set touched (table-level match key / fallback). */
|
|
271
|
+
tables: string[];
|
|
272
|
+
/** Precise read ranges (range-level match key — surgical invalidation). */
|
|
273
|
+
readRanges: readonly SerializedKeyRange[];
|
|
274
|
+
/**
|
|
275
|
+
* M2c: global (D1) tables this subscription's read set touched (a `.global()` read produces no
|
|
276
|
+
* `readRanges` entry, so these are NOT already covered by `tables`/`readRanges` above). Field +
|
|
277
|
+
* threading only — NOT yet indexed/matched here; Task 4 adds the dedicated `byGlobalTable` index,
|
|
278
|
+
* `findAffectedByRanges` matching, and a `subscribedGlobalTables()` accessor that read it.
|
|
279
|
+
*/
|
|
280
|
+
globalTables?: string[];
|
|
281
|
+
/** DIFFABLE_BYID marker + the by-id read descriptor; absent ⇒ RERUN. Set at subscribe (classify). */
|
|
282
|
+
byId?: ByIdRead;
|
|
283
|
+
/** DIFFABLE_RANGE marker + the range read descriptor; absent ⇒ RERUN. Set at subscribe (classify). */
|
|
284
|
+
range?: RangeRead;
|
|
285
|
+
/**
|
|
286
|
+
* DLR Stage 3: the resume-registry key this sub was registered under, captured at subscribe time.
|
|
287
|
+
* Release MUST use this stored key — never a key re-derived from `session.identity` at teardown,
|
|
288
|
+
* because `SetAuth` can mutate `session.identity` in place after subscribe, which would otherwise
|
|
289
|
+
* release a different key than `upsert` created (a silent no-op → permanent registry leak).
|
|
290
|
+
*/
|
|
291
|
+
resumeKey?: string;
|
|
292
|
+
}
|
|
293
|
+
declare class SubscriptionManager {
|
|
294
|
+
private readonly byKey;
|
|
295
|
+
private readonly byTable;
|
|
296
|
+
private readonly byRange;
|
|
297
|
+
private readonly tableFallbackKeys;
|
|
298
|
+
private readonly deserializedRanges;
|
|
299
|
+
/**
|
|
300
|
+
* M2c: index from global (D1) table name -> subscription keys whose read set touched it, via
|
|
301
|
+
* `Subscription.globalTables`. Populated UNCONDITIONALLY in `add` (independent of
|
|
302
|
+
* `readRanges`/`tableFallbackKeys`) so a MIXED subscription — one with both local `readRanges`
|
|
303
|
+
* and a global-table read — still lands here. Matched by a THIRD, ungated loop in
|
|
304
|
+
* `findAffectedByRanges`; the existing table-fallback loop is gated on `tableFallbackKeys`
|
|
305
|
+
* precisely because it must NOT fire for a sub with non-empty `readRanges`, but global-table
|
|
306
|
+
* reads have no `readRanges` entry of their own, so that gate would wrongly exclude a mixed sub.
|
|
307
|
+
*/
|
|
308
|
+
private readonly byGlobalTable;
|
|
309
|
+
add(sub: Subscription): void;
|
|
310
|
+
private removeKey;
|
|
311
|
+
remove(sessionId: string, queryId: number): void;
|
|
312
|
+
removeSession(sessionId: string): void;
|
|
313
|
+
get(sessionId: string, queryId: number): Subscription | undefined;
|
|
314
|
+
/**
|
|
315
|
+
* Subscriptions whose read set intersects the given write ranges (surgical invalidation),
|
|
316
|
+
* unioned with table-fallback subscriptions (empty readRanges) touched by the write tables.
|
|
317
|
+
* Same result set as the retired linear scan, computed in O(log N + k) per write range.
|
|
318
|
+
*/
|
|
319
|
+
findAffectedByRanges(writeRanges: readonly SerializedKeyRange[], writeTables: readonly string[]): Subscription[];
|
|
320
|
+
/** Subscriptions whose read set touched any of the given tables (deduped). */
|
|
321
|
+
findAffectedByTables(tables: readonly string[]): Subscription[];
|
|
322
|
+
/** All subscriptions for a session (e.g. to re-run them when identity changes). */
|
|
323
|
+
forSession(sessionId: string): Subscription[];
|
|
324
|
+
get size(): number;
|
|
325
|
+
/** Global (D1) table names with at least one live subscriber (M2c). */
|
|
326
|
+
subscribedGlobalTables(): string[];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The compute-saving half of reconnect resume (DLR Stage 3): for every LIVE query
|
|
331
|
+
* (identity+path+args), tracks the read set it last executed with and the timestamp through
|
|
332
|
+
* which its result is known-current (`lastInvalidatedTs`). A commit advances that timestamp for
|
|
333
|
+
* every registry entry whose ranges/tables intersect the write — using the SAME range-indexed
|
|
334
|
+
* matcher as `SubscriptionManager` (an `IntervalIndex` keyed by keyspace plus a `byTable`
|
|
335
|
+
* fallback) so this stays O(log N + k), not a linear scan.
|
|
336
|
+
*
|
|
337
|
+
* Entries are TTL-retained across disconnect: a query with zero live subscribers is not evicted
|
|
338
|
+
* immediately (a reconnect within `TTL_MS` should still be able to resume it), but it MUST stay
|
|
339
|
+
* indexed the whole time it's retained — a write landing during that "gap" still has to advance
|
|
340
|
+
* `lastInvalidatedTs`, or a resuming client would wrongly believe its stale result is current.
|
|
341
|
+
*/
|
|
342
|
+
|
|
343
|
+
declare const TTL_MS = 60000;
|
|
344
|
+
declare function regKey(identity: string | null, path: string, argsJson: JSONValue): string;
|
|
345
|
+
interface ResumeLookup {
|
|
346
|
+
readRanges: readonly SerializedKeyRange[];
|
|
347
|
+
tables: readonly string[];
|
|
348
|
+
globalTables: readonly string[];
|
|
349
|
+
lastInvalidatedTs: number;
|
|
350
|
+
wasDiffable: boolean;
|
|
351
|
+
}
|
|
352
|
+
declare class ResumeRegistry {
|
|
353
|
+
private readonly entries;
|
|
354
|
+
private readonly byTable;
|
|
355
|
+
private readonly byRange;
|
|
356
|
+
private readonly tableFallbackKeys;
|
|
357
|
+
private readonly deserializedRanges;
|
|
358
|
+
upsert(key: string, readRanges: readonly SerializedKeyRange[], tables: readonly string[], atTs: number, wasDiffable: boolean, globalTables?: readonly string[]): void;
|
|
359
|
+
private index;
|
|
360
|
+
private unindex;
|
|
361
|
+
/**
|
|
362
|
+
* Advances `lastInvalidatedTs` for every entry whose read set intersects the write — including
|
|
363
|
+
* entries with refCount 0 that are only TTL-retained (they remain indexed until swept).
|
|
364
|
+
*/
|
|
365
|
+
advanceOnCommit(writtenRanges: readonly SerializedKeyRange[], writtenTables: readonly string[], commitTs: number): void;
|
|
366
|
+
lookup(key: string): ResumeLookup | undefined;
|
|
367
|
+
retain(key: string): void;
|
|
368
|
+
release(key: string, nowMs: number): void;
|
|
369
|
+
/** Evicts entries with no live subscribers whose TTL has elapsed. Removes them from both indexes. */
|
|
370
|
+
sweep(nowMs: number): void;
|
|
371
|
+
/** @internal test/debug only — a live entry's current refCount, or undefined if no such entry. */
|
|
372
|
+
__refCount(key: string): number | undefined;
|
|
373
|
+
/** @internal test/debug only — a live entry's pending TTL expiry, or undefined (not pending / no
|
|
374
|
+
* such entry). */
|
|
375
|
+
__expiresAtMs(key: string): number | undefined;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Per-session flow-control controllers — the server half of Foundation seam 6 (fleet hardening).
|
|
380
|
+
*
|
|
381
|
+
* A reactive fan-out can push faster than a client can read. Two failure modes matter:
|
|
382
|
+
* - **A slow reader** whose OS send buffer fills — without a cap, queued frames grow unbounded and
|
|
383
|
+
* exhaust server memory. `SessionBackpressureController` bounds that: it becomes the SINGLE
|
|
384
|
+
* outbound chokepoint for a session, sending straight through when the socket has room, queueing
|
|
385
|
+
* (up to a frame cap) when it doesn't, and DROPPING frames once the queue is full or the client
|
|
386
|
+
* has been backpressured for too long. Dropped frames are safe — the client resyncs from its last
|
|
387
|
+
* acknowledged version — so we favour dropping over stalling the whole node. `MutationResponse`/
|
|
388
|
+
* `ActionResponse` frames are the one exception: they carry `undroppable: true` and are never
|
|
389
|
+
* dropped by cap or timeout (they only queue behind the cap; the queue's timeout-abandon path
|
|
390
|
+
* also spares them) — a dropped response has no version bracket and no retransmit, so losing one
|
|
391
|
+
* would strand a client-side mutation as permanently "inflight" instead of self-healing.
|
|
392
|
+
* But "never dropped" cannot mean "never bounded" — a client that floods mutations into its own
|
|
393
|
+
* deliberately-slow-reading socket would otherwise grow the undroppable queue without limit and
|
|
394
|
+
* exhaust server memory, the exact resource-exhaustion hole the droppable cap exists to close.
|
|
395
|
+
* So undroppable frames get their OWN cap (`maxUndroppableQueuedFrames`, counted separately from
|
|
396
|
+
* `maxQueuedFrames` — a session's droppable-Transition backlog never affects how much undroppable-
|
|
397
|
+
* response headroom it has, and vice versa). Crucially, exceeding that cap must NOT silently drop
|
|
398
|
+
* the frame — that would corrupt exactly the "inflight" invariant this exemption exists to
|
|
399
|
+
* protect. Instead it TERMINATES the session (`onOverflow`, wired by the handler to the same
|
|
400
|
+
* reap-and-close path a dead heartbeat uses). A closed transport is protocol-safe: the client's
|
|
401
|
+
* own close/reconnect handling turns every in-flight request into an explicit unknown-outcome
|
|
402
|
+
* error, which is the honest outcome here — not a silent gap the client believes never happened.
|
|
403
|
+
* - **A dead-but-not-closed connection** (half-open TCP: the peer vanished, no FIN/RST). Nothing
|
|
404
|
+
* reads, nothing errors; the session lingers forever holding subscriptions. `SessionHeartbeat-
|
|
405
|
+
* Controller` reaps it via transport-level ping/pong liveness — NOT inbound-message silence (an
|
|
406
|
+
* idle-but-healthy client sends nothing yet must never be reaped).
|
|
407
|
+
*
|
|
408
|
+
* Both are transport-agnostic: they talk only to `SyncWebSocket`. A socket that cannot ping (the
|
|
409
|
+
* in-process loopback — `bufferedAmount` is always 0, there is no peer to die) is transparently
|
|
410
|
+
* exempt from heartbeat, and its sends always pass straight through the backpressure controller, so
|
|
411
|
+
* loopback behaviour is byte-identical to before these controllers existed.
|
|
412
|
+
*/
|
|
413
|
+
|
|
414
|
+
interface BackpressureOptions {
|
|
415
|
+
/** Above this `bufferedAmount`, frames queue instead of sending. Default 1 MiB. */
|
|
416
|
+
highWaterBytes?: number;
|
|
417
|
+
/** Queue depth past which new frames are dropped (drop-newest). Default 200. */
|
|
418
|
+
maxQueuedFrames?: number;
|
|
419
|
+
/** Sustained-backpressure duration after which the queue is abandoned to drops. Default 30s. */
|
|
420
|
+
slowClientTimeoutMs?: number;
|
|
421
|
+
/**
|
|
422
|
+
* Cap on queued undroppable (MutationResponse/ActionResponse) frames, counted SEPARATELY from
|
|
423
|
+
* `maxQueuedFrames` (a session's droppable backlog never eats into this budget or vice versa).
|
|
424
|
+
* Defaults to `maxQueuedFrames`'s own (effective, post-default) value — same order of magnitude
|
|
425
|
+
* headroom, no new tuning knob to reason about by default. Exceeding it does not drop the frame
|
|
426
|
+
* (see the class doc) — it terminates the session via `onOverflow`.
|
|
427
|
+
*/
|
|
428
|
+
maxUndroppableQueuedFrames?: number;
|
|
429
|
+
}
|
|
430
|
+
declare class SessionBackpressureController {
|
|
431
|
+
private readonly socket;
|
|
432
|
+
private readonly now;
|
|
433
|
+
/**
|
|
434
|
+
* Fires exactly once when the undroppable queue overflows its cap. The controller only
|
|
435
|
+
* decides "this session must die" — it has no session registry to tear down itself, so it
|
|
436
|
+
* hands off to whatever the owner wires here (the handler reuses the same reap-and-close path
|
|
437
|
+
* a dead heartbeat uses). Defaults to a no-op so standalone/unit use of this class doesn't
|
|
438
|
+
* require wiring one up.
|
|
439
|
+
*/
|
|
440
|
+
private readonly onOverflow;
|
|
441
|
+
private readonly highWaterBytes;
|
|
442
|
+
private readonly maxQueuedFrames;
|
|
443
|
+
private readonly slowClientTimeoutMs;
|
|
444
|
+
private readonly maxUndroppableQueuedFrames;
|
|
445
|
+
private readonly queue;
|
|
446
|
+
private _droppedFrames;
|
|
447
|
+
/** Count of undroppable frames currently sitting in `queue` — the separate overflow budget. */
|
|
448
|
+
private undroppableQueuedCount;
|
|
449
|
+
/** True once `onOverflow` has fired, so a dying session can't fire it twice. */
|
|
450
|
+
private overflowed;
|
|
451
|
+
/** Wall-clock ms at which the current backpressure episode began, or null if not backpressured. */
|
|
452
|
+
private backpressureSince;
|
|
453
|
+
/** True once any frame has been dropped in the current episode; resets on full drain. */
|
|
454
|
+
private _droppedThisEpisode;
|
|
455
|
+
constructor(socket: SyncWebSocket, opts?: BackpressureOptions, now?: () => number,
|
|
456
|
+
/**
|
|
457
|
+
* Fires exactly once when the undroppable queue overflows its cap. The controller only
|
|
458
|
+
* decides "this session must die" — it has no session registry to tear down itself, so it
|
|
459
|
+
* hands off to whatever the owner wires here (the handler reuses the same reap-and-close path
|
|
460
|
+
* a dead heartbeat uses). Defaults to a no-op so standalone/unit use of this class doesn't
|
|
461
|
+
* require wiring one up.
|
|
462
|
+
*/
|
|
463
|
+
onOverflow?: () => void);
|
|
464
|
+
get droppedFrames(): number;
|
|
465
|
+
/** True once anything was dropped since the last fully-drained state (the per-episode warn flag). */
|
|
466
|
+
get droppedThisEpisode(): boolean;
|
|
467
|
+
/**
|
|
468
|
+
* The ONLY way frames leave a session. Sends now, queues, or drops per the class contract.
|
|
469
|
+
* `undroppable` (default false) exempts a frame from BOTH drop paths below — the cap check
|
|
470
|
+
* and the sustained-backpressure abandon — so it only ever queues or sends, never vanishes.
|
|
471
|
+
*/
|
|
472
|
+
send(data: string, undroppable?: boolean): void;
|
|
473
|
+
/**
|
|
474
|
+
* Deliver as many queued frames as the socket buffer will take. Called before each send and on a
|
|
475
|
+
* periodic sweep, so a client that recovers (or goes terminally slow) without new traffic still
|
|
476
|
+
* gets its queue drained (or abandoned). Resets the episode once fully drained.
|
|
477
|
+
*/
|
|
478
|
+
flush(): void;
|
|
479
|
+
/** Abandon the queue to drops — EXCEPT undroppable frames, which stay queued for a later flush. */
|
|
480
|
+
private dropQueue;
|
|
481
|
+
private countDrop;
|
|
482
|
+
/**
|
|
483
|
+
* The undroppable queue exceeded its cap. Fires `onOverflow` exactly once (a session that's
|
|
484
|
+
* already dying doesn't need a second kill signal) with a distinct, greppable log reason —
|
|
485
|
+
* deliberately NOT reusing the backpressure-drop warning text, since this is a different failure
|
|
486
|
+
* mode (session termination, not a dropped frame) that ops needs to be able to tell apart.
|
|
487
|
+
*/
|
|
488
|
+
private overflow;
|
|
489
|
+
private markEpisodeDropped;
|
|
490
|
+
}
|
|
491
|
+
interface HeartbeatOptions {
|
|
492
|
+
/** How often to send a transport-level ping. Default 30s. */
|
|
493
|
+
pingIntervalMs?: number;
|
|
494
|
+
/** Consecutive unanswered pings before the session is declared dead. Default 2. */
|
|
495
|
+
missedPongLimit?: number;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Transport-level ping/pong liveness for one session. Every `pingIntervalMs` it sends a ping and
|
|
499
|
+
* increments a miss counter; a pong (or any inbound activity via `noteActivity`) resets it to zero.
|
|
500
|
+
* After `missedPongLimit` consecutive unanswered pings the session is declared dead and `onDead`
|
|
501
|
+
* fires exactly once. A socket without a `ping` capability (loopback) is exempt: `start()` is a
|
|
502
|
+
* no-op, so its session is never reaped.
|
|
503
|
+
*/
|
|
504
|
+
declare class SessionHeartbeatController {
|
|
505
|
+
private readonly socket;
|
|
506
|
+
private readonly onDead;
|
|
507
|
+
private readonly pingIntervalMs;
|
|
508
|
+
private readonly missedPongLimit;
|
|
509
|
+
private timer;
|
|
510
|
+
private missed;
|
|
511
|
+
private dead;
|
|
512
|
+
constructor(socket: SyncWebSocket, onDead: () => void, opts?: HeartbeatOptions);
|
|
513
|
+
/** Begin pinging. No-op when the socket cannot ping (loopback exemption) or already started. */
|
|
514
|
+
start(): void;
|
|
515
|
+
stop(): void;
|
|
516
|
+
/** Any inbound message is liveness credit — resets the consecutive-miss counter. */
|
|
517
|
+
noteActivity(): void;
|
|
518
|
+
private tick;
|
|
519
|
+
private fireDead;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** The minimal socket the handler needs (abstract — WS, Durable Object, or loopback). */
|
|
523
|
+
interface SyncWebSocket {
|
|
524
|
+
send(data: string): void;
|
|
525
|
+
readonly bufferedAmount: number;
|
|
526
|
+
close(): void;
|
|
527
|
+
/**
|
|
528
|
+
* Send a transport-level ping; invoke `onPong` when the matching pong arrives. OPTIONAL — a
|
|
529
|
+
* socket that omits it (the in-process loopback, which has no peer to die) is exempt from
|
|
530
|
+
* heartbeat reaping. Real WebSocket transports implement it.
|
|
531
|
+
*/
|
|
532
|
+
ping?(onPong: () => void): void;
|
|
533
|
+
}
|
|
534
|
+
/** Today's fresh-run mutation result (a real commit happened), tagged so the handler discriminates
|
|
535
|
+
* it from a {@link MutationReplay}. */
|
|
536
|
+
interface MutationRan {
|
|
537
|
+
replayed?: false;
|
|
538
|
+
value: Value;
|
|
539
|
+
tables: string[];
|
|
540
|
+
writeRanges: readonly SerializedKeyRange[];
|
|
541
|
+
commitTs: number;
|
|
542
|
+
forwarded?: boolean;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* A replay of a prior verdict (Receipted Outbox, verdict §(c)) — NO commit happened on this call.
|
|
546
|
+
* The classification at the OWNER (`runMutation`'s `dedup` path) hit a recorded verdict (or the
|
|
547
|
+
* floor), so the mutation is NOT re-run. The handler must therefore skip `notifyWrites` AND the G4
|
|
548
|
+
* pending-frontier (nothing was written this call — verdict §(c) Risk R7).
|
|
549
|
+
*/
|
|
550
|
+
interface MutationReplay {
|
|
551
|
+
replayed: true;
|
|
552
|
+
verdict: "applied" | "failed" | "stale";
|
|
553
|
+
/** The ORIGINAL commitTs for an `applied`/`failed` record (keeps the client gate sound); absent
|
|
554
|
+
* for `stale` (no commit ever happened). */
|
|
555
|
+
commitTs?: number;
|
|
556
|
+
/** Present only for `applied` with a recorded return value. */
|
|
557
|
+
value?: Value;
|
|
558
|
+
/** `applied` whose value was never recorded (crash-window) or exceeded the 64KB cap. */
|
|
559
|
+
valueMissing?: true;
|
|
560
|
+
/** The terminal verdict code for `failed` (the recorded error code) or `"STALE_CLIENT"` for `stale`. */
|
|
561
|
+
code?: string;
|
|
562
|
+
}
|
|
563
|
+
type RunMutationResult = MutationRan | MutationReplay;
|
|
564
|
+
/** Runs UDFs for the sync tier. Backed by the executor; returns table sets + precise read ranges for matching. */
|
|
565
|
+
interface SyncUdfExecutor {
|
|
566
|
+
runQuery(udfPath: string, args: JSONValue, identity?: string | null): Promise<{
|
|
567
|
+
value: Value;
|
|
568
|
+
tables: string[];
|
|
569
|
+
readRanges: readonly SerializedKeyRange[];
|
|
570
|
+
globalTables: string[];
|
|
571
|
+
diffableRange?: DiffableRange;
|
|
572
|
+
diffablePage?: DiffablePage;
|
|
573
|
+
}>;
|
|
574
|
+
/**
|
|
575
|
+
* `origin` (G4, client-sync verdict §(d) item 2): the committing session's id, threaded onto the
|
|
576
|
+
* commit's `OplogDelta.origin` so the fan-out can advance THAT session's own `version.ts` past its
|
|
577
|
+
* commit even when it touched nothing the session subscribes to. `forwarded` (fleet): true when
|
|
578
|
+
* the mutation committed on ANOTHER node (no local oplog) — its origin tag couldn't ride this
|
|
579
|
+
* node's local fan-out, so the handler advances the origin frontier via a drain-gated fallback.
|
|
580
|
+
*
|
|
581
|
+
* `dedup` (Receipted Outbox, verdict §(c)): the durable `(clientId, seq)` — absent = today's
|
|
582
|
+
* unconditional path, bit-for-bit (no classification read, no receipt write). Present → the OWNER's
|
|
583
|
+
* `runMutation` impl classifies: a recorded/floored verdict short-circuits to a {@link MutationReplay}
|
|
584
|
+
* (no commit); a miss runs the mutation with the dedup key rideng the commit meta (the receipts
|
|
585
|
+
* guard writes the `applied` receipt atomically). The handler only threads `dedup` down and
|
|
586
|
+
* interprets the discriminated return — it NEVER reads the classification store itself (it runs on
|
|
587
|
+
* any node, incl. a fleet follower; the read must run where the commit runs — verdict §(c) repair 3).
|
|
588
|
+
*/
|
|
589
|
+
runMutation(udfPath: string, args: JSONValue, identity?: string | null, origin?: string, dedup?: ClientMutationRef): Promise<RunMutationResult>;
|
|
590
|
+
runAdminQuery(udfPath: string, args: JSONValue): Promise<{
|
|
591
|
+
value: Value;
|
|
592
|
+
tables: string[];
|
|
593
|
+
readRanges: readonly SerializedKeyRange[];
|
|
594
|
+
globalTables: string[];
|
|
595
|
+
diffableRange?: DiffableRange;
|
|
596
|
+
diffablePage?: DiffablePage;
|
|
597
|
+
}>;
|
|
598
|
+
/** One-shot, non-reactive: an action has no read/write set of its own to fan out. */
|
|
599
|
+
runAction(udfPath: string, args: JSONValue, identity?: string | null): Promise<{
|
|
600
|
+
value: Value;
|
|
601
|
+
}>;
|
|
602
|
+
/**
|
|
603
|
+
* Classify a presented `(identity, clientId, seq)` for the `Connect` resume handshake (verdict
|
|
604
|
+
* §(e)) — the read-only sibling of `runMutation`'s dedup path. Returns the recorded verdict, or
|
|
605
|
+
* `"stale"` (below the floor, no record), or `"unknown"` (never seen — the client should resend).
|
|
606
|
+
* Optional: an executor without receipts support (or an old one) omits it → `Connect` degrades to
|
|
607
|
+
* `known: false` with empty results.
|
|
608
|
+
*/
|
|
609
|
+
classifyClientMutation?(identity: string | null, clientId: string, seq: number): Promise<ClientMutationVerdict>;
|
|
610
|
+
/** Ack-prune the contiguous settled prefix `seq <= ackedThrough` for `(identity, clientId)` on a
|
|
611
|
+
* `Connect` (verdict §(c) Retention). Optional (same reason as `classifyClientMutation`). */
|
|
612
|
+
pruneClientMutations?(identity: string | null, clientId: string, ackedThrough: number): Promise<void>;
|
|
613
|
+
/** The deployment-id stamp for `ConnectAck` (verdict §(g) hazard 15 — same-timeline proof). */
|
|
614
|
+
deploymentId?(): string;
|
|
615
|
+
}
|
|
616
|
+
/** A committed write's invalidation — the transactor→sync fan-out payload (Tier 2: from a stream). */
|
|
617
|
+
interface WriteInvalidation {
|
|
618
|
+
tables: string[];
|
|
619
|
+
/** Precise write ranges for surgical (range-level) invalidation. */
|
|
620
|
+
ranges: readonly SerializedKeyRange[];
|
|
621
|
+
commitTs: number;
|
|
622
|
+
/** Written docs for local row-diffing (§DLR 2a). Absent → affected DIFFABLE subs fall back to RERUN. */
|
|
623
|
+
writtenDocs?: WrittenDoc[];
|
|
624
|
+
/**
|
|
625
|
+
* M2c Critical fix: true for a GLOBAL (D1-backed) table invalidation, sourced from
|
|
626
|
+
* `GlobalReactivityPoller` rather than the local MVCC commit path. Global reactivity has its OWN
|
|
627
|
+
* clock (D1's `_global_versions` counter) — `commitTs` on a global invalidation is a harmless
|
|
628
|
+
* placeholder (`0`), never an oracle-issued local timestamp. A `global` invalidation is
|
|
629
|
+
* FRONTIER-NEUTRAL: it must never advance `session.version.ts` (the client-facing local-ts
|
|
630
|
+
* frontier) or the local-ts `ResumeRegistry` — mixing the two clock domains on that one shared
|
|
631
|
+
* scalar silently breaks local optimistic-update gating and local reconnect-resume for any
|
|
632
|
+
* session with both a local and a global subscription (see `doNotifyWrites`, `sendSessionTransition`,
|
|
633
|
+
* and `doModifyQuerySet`'s `globalTables.length > 0` reconnect bypass). Absent/false = a normal
|
|
634
|
+
* local commit — byte-identical to pre-M2c behavior.
|
|
635
|
+
*/
|
|
636
|
+
global?: boolean;
|
|
637
|
+
}
|
|
638
|
+
interface SyncProtocolHandlerOptions {
|
|
639
|
+
/** Exclude the mutating session from the reactive transition (it has the MutationResponse). */
|
|
640
|
+
excludeOriginFromTransition?: boolean;
|
|
641
|
+
/**
|
|
642
|
+
* Whether a mutation handled here triggers `notifyWrites` inline (default true). Set false
|
|
643
|
+
* when an external write-fan-out drives invalidation (so commits via OTHER paths — e.g. HTTP
|
|
644
|
+
* — also push, and there's no double-notify).
|
|
645
|
+
*/
|
|
646
|
+
autoNotifyOnMutation?: boolean;
|
|
647
|
+
/** Validate an admin key presented via `SetAdminAuth`. Defaults to `() => false` (no admin). */
|
|
648
|
+
verifyAdmin?: (key: string) => boolean;
|
|
649
|
+
/** Per-session outbound flow control (queue caps, slow-client drops). Defaults apply if omitted. */
|
|
650
|
+
backpressure?: BackpressureOptions;
|
|
651
|
+
/** Per-session ping/pong liveness reaping. Defaults apply if omitted. */
|
|
652
|
+
heartbeat?: HeartbeatOptions;
|
|
653
|
+
/**
|
|
654
|
+
* Disarm the handler's process-shaped background timers: the periodic `setInterval` flush/resume
|
|
655
|
+
* sweep AND every per-session heartbeat ping. Defaults to `false` — the long-lived process host
|
|
656
|
+
* (`Bun.serve`/`node:http`) is byte-for-byte unchanged.
|
|
657
|
+
*
|
|
658
|
+
* WHY (the Cloudflare Durable Object host, Slice 3): a DO **hibernates after ~seconds idle** to
|
|
659
|
+
* scale to zero, keeping its WebSockets alive while discarding in-memory state. On a DO these
|
|
660
|
+
* timers are actively harmful, not merely useless: (a) a `setInterval` sweep does not keep a DO
|
|
661
|
+
* alive and is silently lost on hibernation — dead weight; (b) an app-level `socket.ping` heartbeat
|
|
662
|
+
* would **wake the DO on every ping**, destroying the scale-to-zero economics that are the entire
|
|
663
|
+
* point of the DO host. Keepalive on a DO instead moves to the runtime-level
|
|
664
|
+
* `setWebSocketAutoResponse` (a ping/pong the runtime answers WITHOUT waking the object). So the DO
|
|
665
|
+
* host constructs the handler with this set; the process host never does. Additive + off by default
|
|
666
|
+
* so nothing but the DO host observes any change. See
|
|
667
|
+
* `docs/superpowers/specs/2026-03-20-do-host-slice3-design.md` §8.1.
|
|
668
|
+
*/
|
|
669
|
+
disableBackgroundTimers?: boolean;
|
|
670
|
+
}
|
|
671
|
+
declare class SyncProtocolHandler {
|
|
672
|
+
private readonly executor;
|
|
673
|
+
private readonly options;
|
|
674
|
+
private readonly sessions;
|
|
675
|
+
private readonly subscriptions;
|
|
676
|
+
private notifyTail;
|
|
677
|
+
/**
|
|
678
|
+
* G4 fleet fallback (client-sync verdict §(d) item 2): sessionId → the commitTs of a FORWARDED
|
|
679
|
+
* mutation whose origin tag couldn't ride this (forwarding) node's local fan-out. Satisfied with
|
|
680
|
+
* an empty ts-advancing Transition once the drain processes a commit at-or-above it (gated on the
|
|
681
|
+
* drain's last-processed commitTs — see `sweepPendingFrontiers`). Holds at most one entry per
|
|
682
|
+
* in-flight forwarded mutation per session; cleared on satisfy or disconnect, so the sweep it
|
|
683
|
+
* drives stays tiny (usually empty on a single-node deployment, where nothing is ever forwarded).
|
|
684
|
+
*/
|
|
685
|
+
private readonly pendingFrontiers;
|
|
686
|
+
/**
|
|
687
|
+
* DLR 2a CommitDiffer state: `${sessionId} ${queryId}` -> the current materialized 0-or-1-row map
|
|
688
|
+
* for a DIFFABLE_BYID sub whose client is diff-capable. EPHEMERAL — reseeded on every subscribe
|
|
689
|
+
* (`doModifyQuerySet`'s reset), updated on every incremental diff (`doNotifyWrites`), and dropped on
|
|
690
|
+
* unsubscribe/disconnect. NOT a durable CVR: if lost (process restart, or a sub falling back to the
|
|
691
|
+
* RERUN/QueryUpdated path for one turn) the drift checksum's client-side mismatch check is the
|
|
692
|
+
* backstop that resyncs the one affected query — see `change.ts`'s `driftChecksum` doc comment.
|
|
693
|
+
*/
|
|
694
|
+
private readonly byIdRowMap;
|
|
695
|
+
/**
|
|
696
|
+
* DLR 2b — the response-before-Transition gate (replaces the fragile, timer-starvable
|
|
697
|
+
* `setTimeout(0)`). `commitTs` → a one-shot latch a diff-capable origin's OWN reactive Transition
|
|
698
|
+
* parks on inside `doNotifyWrites`, released once `processMutation` has actually enqueued that
|
|
699
|
+
* commit's `MutationResponse` onto the session's outbound queue.
|
|
700
|
+
*
|
|
701
|
+
* WHY commit-time registration (via {@link registerOriginResponseGate}, called from the runtime's
|
|
702
|
+
* fan-out subscribe callback) rather than lazily inside `doNotifyWrites`: the fan-out drain is
|
|
703
|
+
* SERIAL, so under load `doNotifyWrites` for a commit can run long after that commit's response was
|
|
704
|
+
* already sent (the drain is backed up behind a flood). Registering the gate inside `doNotifyWrites`
|
|
705
|
+
* would then happen AFTER the release — the release would find no gate (no-op), and the late gate
|
|
706
|
+
* would park FOREVER, wedging the whole `notifyTail` (the backpressure-flood regression). The
|
|
707
|
+
* subscribe callback instead fires SYNCHRONOUSLY inside the commit (before `runMutation` resolves,
|
|
708
|
+
* hence before the response can be sent), so the gate always exists before its release, whatever the
|
|
709
|
+
* drain backlog.
|
|
710
|
+
*
|
|
711
|
+
* WHY a microtask latch, not `setTimeout(0)`: the release runs as `processMutation` resumes after
|
|
712
|
+
* `await runMutation` — a MICROTASK, which a tight `await`-loop of mutations (`for (…) await
|
|
713
|
+
* client.mutation(…)`) drains between every iteration. The old timer sat in Node's TIMER phase,
|
|
714
|
+
* which that same loop STARVES; because the yield sat ON the single `notifyTail`, a starved timer
|
|
715
|
+
* stalled the entire fan-out chain. A microtask cannot be starved and cannot stall the tail.
|
|
716
|
+
*
|
|
717
|
+
* Scoped to a diff-capable LOCAL origin session (see `registerOriginResponseGate`), so every gate
|
|
718
|
+
* created here is balanced by exactly one release from that session's own `processMutation` — on
|
|
719
|
+
* EVERY post-commit outcome: the success path releases inline, and a commit-then-throw (or any
|
|
720
|
+
* throw after the commit) releases from its catch via the `committedTs` the executor stamps on the
|
|
721
|
+
* error (see `releaseOriginResponseGate`/`committedTsOfError`). Entries are transient: one per
|
|
722
|
+
* in-flight diff-capable-origin commit, created at commit and dropped on release (or on
|
|
723
|
+
* `disconnect`, which resolves+drops any still-pending gate for the vanishing session so a
|
|
724
|
+
* mid-flight teardown can never strand a parked `doNotifyWrites`). The `sessionId` is retained so
|
|
725
|
+
* that disconnect backstop can find a session's gates in this commitTs-keyed map.
|
|
726
|
+
*/
|
|
727
|
+
private readonly originResponseGates;
|
|
728
|
+
/**
|
|
729
|
+
* DLR Stage 3: the compute-saving half of reconnect resume (see `resume-registry.ts`'s doc
|
|
730
|
+
* comment). Populated on every subscribe (`doModifyQuerySet`), advanced on every commit
|
|
731
|
+
* (`doNotifyWrites`, unconditionally — independent of `bySession`, so an entry with zero live
|
|
732
|
+
* subscribers still advances during its TTL-retained "gap"), and retain/release-tracked across
|
|
733
|
+
* subscribe/unsubscribe/disconnect. Not yet CONSULTED anywhere (that's a later task) — this task
|
|
734
|
+
* only keeps it correctly populated.
|
|
735
|
+
*/
|
|
736
|
+
private readonly resumeRegistry;
|
|
737
|
+
/**
|
|
738
|
+
* M2c fix: callbacks registered via {@link onGlobalSubscribe}, fired whenever a subscription is
|
|
739
|
+
* (re-)registered with a non-empty `globalTables` read set — from `doModifyQuerySet` (fresh
|
|
740
|
+
* subscribe, or a resume-skip carrying a retained global-table read set), from `handleSetAuth`'s
|
|
741
|
+
* re-exec (an identity flip can make a query newly read a global table), and from
|
|
742
|
+
* `sendSessionTransition`'s live-re-run (a data/identity-dependent query can shift onto a global
|
|
743
|
+
* table across a RERUN refresh). See `DriverContext.onGlobalSubscribe`'s doc comment
|
|
744
|
+
* (`@helipod/component`) for why this exists — closing the busy-DO-late-subscribe gap in the
|
|
745
|
+
* M2c global-reactivity poller.
|
|
746
|
+
*/
|
|
747
|
+
private readonly globalSubscribeListeners;
|
|
748
|
+
private readonly verifyAdmin;
|
|
749
|
+
/** Periodic drain sweep — drains recovered clients and abandons terminally-slow queues. */
|
|
750
|
+
private sweepTimer;
|
|
751
|
+
constructor(executor: SyncUdfExecutor, options?: SyncProtocolHandlerOptions);
|
|
752
|
+
connect(sessionId: string, socket: SyncWebSocket): void;
|
|
753
|
+
disconnect(sessionId: string): void;
|
|
754
|
+
/** Drop every `byIdRowMap` entry for a session (disconnect/reap) — ephemeral per-sub state, never
|
|
755
|
+
* durable, so there's nothing to persist on the way out. */
|
|
756
|
+
private clearByIdRowMapForSession;
|
|
757
|
+
/** Reap a session whose heartbeat went dead: close the socket, then tear down like a disconnect. */
|
|
758
|
+
private reap;
|
|
759
|
+
/** Stop the background sweep. Call on shutdown; sessions must already be disconnected. */
|
|
760
|
+
dispose(): void;
|
|
761
|
+
/** @internal test/debug only — the live ResumeRegistry (DLR Stage 3), for tests to assert
|
|
762
|
+
* population/advance/retain-release wiring without duplicating its internals. */
|
|
763
|
+
get __resumeRegistry(): ResumeRegistry;
|
|
764
|
+
/** @internal test/debug only — the live SubscriptionManager (M2c), for tests to assert
|
|
765
|
+
* registration wiring (e.g. `globalTables`) without duplicating its internals. */
|
|
766
|
+
get __subscriptions(): SubscriptionManager;
|
|
767
|
+
/**
|
|
768
|
+
* M2c Task 6: global (D1-backed) table names with at least one live subscriber right now —
|
|
769
|
+
* delegates to `SubscriptionManager.subscribedGlobalTables()`. This is the ONLY thing a
|
|
770
|
+
* `GlobalReactivityPoller` needs from the sync tier to decide which tables are worth polling D1
|
|
771
|
+
* for (its other dependency, `notifyWrites`, already exists on this class). A public method
|
|
772
|
+
* (unlike `__subscriptions` above) because it is a real runtime dependency wired onto
|
|
773
|
+
* `DriverContext`, not a test-only escape hatch.
|
|
774
|
+
*/
|
|
775
|
+
subscribedGlobalTables(): string[];
|
|
776
|
+
/**
|
|
777
|
+
* M2c fix: register `cb` to fire whenever a subscription is (re-)registered with a non-empty
|
|
778
|
+
* global-table read set — a fresh `doModifyQuerySet` subscribe/resume, a `handleSetAuth` re-exec,
|
|
779
|
+
* or a `sendSessionTransition` live-re-run (see the `globalSubscribeListeners` field doc for all
|
|
780
|
+
* three sites). A driver (e.g. `GlobalReactivityPollerDriver`) uses this to arm itself on any of
|
|
781
|
+
* these late/renewed subscribes, rather than relying solely on a boot-time force-arm that a busy
|
|
782
|
+
* (never-hibernating) DO may never repeat. Not unsubscribed — callers are drivers with the same
|
|
783
|
+
* lifetime as this handler.
|
|
784
|
+
*/
|
|
785
|
+
onGlobalSubscribe(cb: () => void): void;
|
|
786
|
+
/** Fire every registered {@link onGlobalSubscribe} listener. Swallows listener throws (never let a
|
|
787
|
+
* driver's own bug break subscription registration). */
|
|
788
|
+
private fireGlobalSubscribe;
|
|
789
|
+
private send;
|
|
790
|
+
/**
|
|
791
|
+
* `MutationResponse.ts` (W1) must be the mutation's real commitTs — a client-side optimistic-
|
|
792
|
+
* update gate treats it as an ack signal, and a `0` (or absent) commitTs there would either
|
|
793
|
+
* false-close the gate immediately or wedge a pending layer forever. `commitTs` SHOULD always
|
|
794
|
+
* be a positive integer for a committed mutation; the one known way it can leak as `<= 0` is
|
|
795
|
+
* the `?? 0n` fallback for a forwarded-fleet-write whose owner commitTs didn't make it back
|
|
796
|
+
* (`runtime-embedded/src/runtime.ts`). This codebase has no existing dev/prod split (no
|
|
797
|
+
* `NODE_ENV`/`__DEV__` convention anywhere in `packages/`), so this is unconditional: log
|
|
798
|
+
* loudly every time, and never put a lying `0` on the wire — omit `ts` instead, which is
|
|
799
|
+
* exactly the pre-W1 wire shape every client already knows how to handle.
|
|
800
|
+
*/
|
|
801
|
+
private mutationResponseTs;
|
|
802
|
+
handleMessage(sessionId: string, raw: string): Promise<void>;
|
|
803
|
+
/** Run a subscription's query — privileged for _admin:* on a privileged session; else identity-scoped. */
|
|
804
|
+
private execSub;
|
|
805
|
+
/**
|
|
806
|
+
* G1 hardening (client-sync verdict §(d) item 3): a query-set change is SERIALIZED with the
|
|
807
|
+
* reactive fan-out on the same `notifyTail`, per handler. The shipped code ran MQS inline while
|
|
808
|
+
* `notifyWrites` ran on the tail, so a concurrent invalidation could deliver a NEWER value and
|
|
809
|
+
* then MQS deliver an OLDER one under contiguous brackets — a silent base regression (with
|
|
810
|
+
* optimistic layers, "your own committed write vanishes"). Enqueuing MQS on the tail makes the two
|
|
811
|
+
* strictly ordered: the enqueued unit reads `session.version` at EXECUTION time (inside
|
|
812
|
+
* `doModifyQuerySet`), so its bracket chains contiguously off whatever notify ran just before it.
|
|
813
|
+
* `execSub`→`runQuery` never re-enters this tail (it's a pure engine read), so there is no
|
|
814
|
+
* deadlock — subscribe just waits behind any pending notifies (the accepted latency cost).
|
|
815
|
+
*/
|
|
816
|
+
private handleModifyQuerySet;
|
|
817
|
+
private doModifyQuerySet;
|
|
818
|
+
private handleMutation;
|
|
819
|
+
/**
|
|
820
|
+
* A drained-outbox chunk (verdict §(e)): ONE inbound message carrying N entries. Applied
|
|
821
|
+
* SEQUENTIALLY (`await` each in order) — the client sends only one unacked chunk at a time and
|
|
822
|
+
* relies on per-client FIFO, so units MUST commit in order. One `MutationResponse` is emitted per
|
|
823
|
+
* entry as it settles, EXCEPT when a unit fails TRANSIENTLY (see `processMutation`'s doc comment):
|
|
824
|
+
* that unit still gets its failure response, but the loop then STOPS — the remaining entries get
|
|
825
|
+
* NO response at all, preserving the FIFO drain obligation (a causally-dependent later unit must
|
|
826
|
+
* never apply after an earlier transient/infra failure). The client's one-unacked-chunk-at-a-time
|
|
827
|
+
* protocol resends the whole chunk on the next attempt; per-seq receipts make that resend safe
|
|
828
|
+
* (an already-applied unit replay-acks instead of re-running).
|
|
829
|
+
*/
|
|
830
|
+
private handleMutationBatch;
|
|
831
|
+
/**
|
|
832
|
+
* The per-unit mutation core shared by `Mutation` and `MutationBatch` — threads the durable
|
|
833
|
+
* `(clientId, seq)` down to the OWNER's classification (verdict §(c)), sends the response, and
|
|
834
|
+
* (for a fresh commit only) fans out. A `MutationReplay` return skips `notifyWrites` AND the G4
|
|
835
|
+
* pending-frontier entirely (nothing was written this call — Risk R7): its `commitTs` is the
|
|
836
|
+
* ORIGINAL, long past the current frontier, so arming a frontier or fanning out would be a lie.
|
|
837
|
+
*
|
|
838
|
+
* Returns `"continue" | "stop"` — meaningful only to `handleMutationBatch`'s drain loop (a
|
|
839
|
+
* standalone `Mutation` ignores it). A thrown error is classified via the executor's retryable
|
|
840
|
+
* discipline (`isRetryableError`, `@helipod/errors` — the same classification
|
|
841
|
+
* `handleDedupError`'s dedup path already applies when deciding whether to record a verdict):
|
|
842
|
+
* - TERMINAL (not retryable — a deterministic app error, a coded verdict failure/replay) means the
|
|
843
|
+
* executor already recorded whatever verdict applies; the batch drain CONTINUES past it (a
|
|
844
|
+
* poison unit never blocks the rest — matches the spec's documented mid-batch-continue case).
|
|
845
|
+
* - TRANSIENT (retryable — infra/conflict) means nothing durable happened for this unit; the batch
|
|
846
|
+
* drain STOPS here so a later, causally-dependent unit can never apply out of order relative to
|
|
847
|
+
* it. The remaining units get no response and the client's FIFO resend picks them back up.
|
|
848
|
+
*/
|
|
849
|
+
private processMutation;
|
|
850
|
+
/**
|
|
851
|
+
* The `Connect` resume handshake (verdict §(e)): activated from the reserved no-op. Classifies each
|
|
852
|
+
* presented `held` seq into `ConnectAck.results`, ack-prunes the `ackedThrough` contiguous
|
|
853
|
+
* settled-prefix, and stamps the `deploymentId` (same-timeline proof, §(g) hazard 15). `known`
|
|
854
|
+
* is false when the client presents history the server recognizes NONE of (a swept/foreign timeline
|
|
855
|
+
* → the client resets). A bare `Connect` (no `clientId`/`held`/`ackedThrough`, or an executor with
|
|
856
|
+
* no receipts support) stays the pre-Outbox no-op: no ConnectAck is sent, bit-for-bit.
|
|
857
|
+
*/
|
|
858
|
+
private handleConnect;
|
|
859
|
+
/**
|
|
860
|
+
* A one-shot request→value call — NOT reactive (an action has no read/write set of its own).
|
|
861
|
+
* Deliberately does NOT call `notifyWrites`: any mutation the action invoked via
|
|
862
|
+
* `ctx.runMutation` already fanned out through that mutation's own commit.
|
|
863
|
+
*/
|
|
864
|
+
private handleAction;
|
|
865
|
+
/**
|
|
866
|
+
* Reactive fan-out: recompute subscriptions a write touched and push transitions. Calls are
|
|
867
|
+
* serialized so per-session version brackets advance monotonically (concurrent notifies
|
|
868
|
+
* would otherwise reorder and trigger false client resyncs).
|
|
869
|
+
*/
|
|
870
|
+
notifyWrites(invalidation: WriteInvalidation, originSessionId?: string): Promise<void>;
|
|
871
|
+
private doNotifyWrites;
|
|
872
|
+
/**
|
|
873
|
+
* DLR 2b — register the response-before-Transition gate for `commitTs`, at COMMIT time. Called
|
|
874
|
+
* SYNCHRONOUSLY from the runtime's fan-out subscribe callback (which fires inside the commit,
|
|
875
|
+
* before `runMutation` resolves and thus before this commit's `MutationResponse` can be sent), so
|
|
876
|
+
* the gate reliably exists before {@link releaseOriginResponseGate} runs — no matter how backed up
|
|
877
|
+
* the serial fan-out drain is. Public because the decoupled runtime owns the commit-time seam.
|
|
878
|
+
*
|
|
879
|
+
* Registers ONLY for a diff-capable LOCAL origin session — exactly the case `doNotifyWrites` parks
|
|
880
|
+
* on, and exactly the case whose own `processMutation` will release it, so every gate is balanced
|
|
881
|
+
* (no leak). A no-origin commit, a foreign/absent session, or a diff-incapable session registers
|
|
882
|
+
* nothing. Idempotent per `commitTs`.
|
|
883
|
+
*/
|
|
884
|
+
registerOriginResponseGate(commitTs: number, originSessionId: string | undefined): void;
|
|
885
|
+
/**
|
|
886
|
+
* DLR 2b — release (and drop) the response gate for `commitTs`. Called right after the commit's
|
|
887
|
+
* `MutationResponse` is enqueued, so the parked origin Transition flushes strictly behind it. A
|
|
888
|
+
* no-op when no gate is registered (a diff-incapable / no-origin commit never registered one) — so
|
|
889
|
+
* an ordinary commit costs nothing here.
|
|
890
|
+
*/
|
|
891
|
+
private releaseOriginResponseGate;
|
|
892
|
+
/**
|
|
893
|
+
* Compute one session's modifications for this commit (by-id / range `QueryDiff` incremental arms,
|
|
894
|
+
* or the RERUN `QueryUpdated`/`QueryFailed` arm) and send its Transition. Extracted from
|
|
895
|
+
* `doNotifyWrites`'s per-session loop body (byte-identical logic) so the origin session's own call
|
|
896
|
+
* can be deferred past the response-ordering macrotask yield while every non-origin session is
|
|
897
|
+
* computed+sent immediately, with no shared logic duplicated between the two call sites.
|
|
898
|
+
*/
|
|
899
|
+
private sendSessionTransition;
|
|
900
|
+
/** Emit a standalone empty ts-advancing Transition — advances `session.version.ts` to `ts` with no
|
|
901
|
+
* modifications. The one construct that closes a client's optimistic-update gate for a commit that
|
|
902
|
+
* touched nothing the session subscribes to. Callers guard `ts > session.version.ts` (monotone). */
|
|
903
|
+
private emitEmptyFrontier;
|
|
904
|
+
/** G4 primary: advance the LOCAL origin session's frontier when its own commit missed all its
|
|
905
|
+
* subscriptions. A local commit supersedes any stale forwarded fallback entry for that session. */
|
|
906
|
+
private advanceOriginFrontier;
|
|
907
|
+
/** G4 fleet fallback: satisfy pending forwarded-mutation frontiers now that the drain reached
|
|
908
|
+
* `drainTs`. A frontier still above `drainTs` waits for a later drain; one already covered by the
|
|
909
|
+
* session's own subscription update (in `bySession` this drain, or an earlier ts advance) clears
|
|
910
|
+
* without a redundant frame; otherwise an empty ts-advance to the frontier is emitted. */
|
|
911
|
+
private sweepPendingFrontiers;
|
|
912
|
+
private handleSetAdminAuth;
|
|
913
|
+
private handleSetAuth;
|
|
914
|
+
/** Ephemeral broadcast (presence/typing) — bypasses the engine entirely. */
|
|
915
|
+
publishEphemeral(topic: string, event: JSONValue, fromSessionId?: string): void;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* The client-side reducer: applies `ServerMessage`s to local state. It enforces the
|
|
920
|
+
* version-bracket contract — a `Transition` is applied only if its `startVersion` matches the
|
|
921
|
+
* client's current version; otherwise a frame was missed and the client must **resync from
|
|
922
|
+
* scratch**. This is what makes server-side frame drops (backpressure) safe. The real client
|
|
923
|
+
* SDK (M10) builds on this.
|
|
924
|
+
*/
|
|
925
|
+
|
|
926
|
+
interface MutationOutcome {
|
|
927
|
+
success: boolean;
|
|
928
|
+
value?: JSONValue;
|
|
929
|
+
error?: string;
|
|
930
|
+
}
|
|
931
|
+
interface SyncClientState {
|
|
932
|
+
version: StateVersion;
|
|
933
|
+
queries: Map<number, JSONValue>;
|
|
934
|
+
needsResync: boolean;
|
|
935
|
+
mutationResults: Map<string, MutationOutcome>;
|
|
936
|
+
broadcasts: Array<{
|
|
937
|
+
topic: string;
|
|
938
|
+
event: JSONValue;
|
|
939
|
+
}>;
|
|
940
|
+
}
|
|
941
|
+
declare function createClientState(): SyncClientState;
|
|
942
|
+
declare function applyServerMessage(state: SyncClientState, msg: ServerMessage): void;
|
|
943
|
+
|
|
944
|
+
export { type BackpressureOptions, type Change, type ClientMessage, type ClientMutationRef, type ClientMutationVerdict, type HeartbeatOptions, INITIAL_VERSION, type MatchMode, type MutationBatchEntry, type MutationOutcome, type MutationRan, type MutationReplay, type QueryRequest, type ResumeLookup, ResumeRegistry, type RowVersion, type RunMutationResult, type ServerMessage, SessionBackpressureController, SessionHeartbeatController, type StateModification, type StateVersion, type Subscription, SubscriptionManager, type SyncClientState, SyncProtocolHandler, type SyncProtocolHandlerOptions, type SyncUdfExecutor, type SyncWebSocket, TTL_MS, type WriteInvalidation, applyChanges, applyServerMessage, compareStateVersion, createClientState, driftChecksum, encodeServerMessage, isContiguous, parseClientMessage, regKey, versionsEqual };
|