@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.
@@ -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 };