@rindle/remote 0.1.0-rc.5

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.
Files changed (48) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +119 -0
  3. package/dist/backend.d.ts +31 -0
  4. package/dist/backend.d.ts.map +1 -0
  5. package/dist/backend.js +136 -0
  6. package/dist/backend.js.map +1 -0
  7. package/dist/index.d.ts +17 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +16 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/mutation-queue.d.ts +26 -0
  12. package/dist/mutation-queue.d.ts.map +1 -0
  13. package/dist/mutation-queue.js +62 -0
  14. package/dist/mutation-queue.js.map +1 -0
  15. package/dist/normalized.d.ts +50 -0
  16. package/dist/normalized.d.ts.map +1 -0
  17. package/dist/normalized.js +146 -0
  18. package/dist/normalized.js.map +1 -0
  19. package/dist/optimistic-source.d.ts +108 -0
  20. package/dist/optimistic-source.d.ts.map +1 -0
  21. package/dist/optimistic-source.js +317 -0
  22. package/dist/optimistic-source.js.map +1 -0
  23. package/dist/protocol.d.ts +134 -0
  24. package/dist/protocol.d.ts.map +1 -0
  25. package/dist/protocol.js +178 -0
  26. package/dist/protocol.js.map +1 -0
  27. package/dist/remote-source.d.ts +31 -0
  28. package/dist/remote-source.d.ts.map +1 -0
  29. package/dist/remote-source.js +133 -0
  30. package/dist/remote-source.js.map +1 -0
  31. package/dist/subscribe.d.ts +26 -0
  32. package/dist/subscribe.d.ts.map +1 -0
  33. package/dist/subscribe.js +14 -0
  34. package/dist/subscribe.js.map +1 -0
  35. package/dist/transport.d.ts +53 -0
  36. package/dist/transport.d.ts.map +1 -0
  37. package/dist/transport.js +105 -0
  38. package/dist/transport.js.map +1 -0
  39. package/package.json +39 -0
  40. package/src/backend.ts +163 -0
  41. package/src/index.ts +40 -0
  42. package/src/mutation-queue.ts +96 -0
  43. package/src/normalized.ts +188 -0
  44. package/src/optimistic-source.ts +374 -0
  45. package/src/protocol.ts +252 -0
  46. package/src/remote-source.ts +171 -0
  47. package/src/subscribe.ts +40 -0
  48. package/src/transport.ts +127 -0
@@ -0,0 +1,374 @@
1
+ // RemoteOptimisticSource: an `OptimisticSource` over a network transport — the ws sibling of
2
+ // the in-process native source (OPTIMISTIC-WRITES-DESIGN.md §8). The optimistic protocol is
3
+ // the normalized subscription stream with `cv`-stamped frames, plus two extras:
4
+ //
5
+ // - upstream: `init` (the connection's stable clientID, sent once) and `pushMutation`
6
+ // (one named-mutator envelope, §8.1) — confirmation rides the progress frames, so
7
+ // `pushMutation` resolves on send;
8
+ // - downstream: connection-level `progress` frames `{cvMin}` (§8.6; mutation confirmation is DATA — the lmid system query),
9
+ // relayed verbatim to the `OptimisticBackend` (which buffers data frames by `cv` and
10
+ // releases all `cv ≤ cvMin` as one coherent step, §8.5).
11
+ //
12
+ // Per-query validation is the ordinary `NormalizedSubscriber` (epoch/fp/seq); on a gap it
13
+ // re-subscribes, and the server re-registers under a NEW epoch and replies with a fresh
14
+ // `cv`-stamped snapshot + a progress frame that releases it.
15
+
16
+ import { LMID_QUERY_NAME } from "@rindle/client";
17
+ import type {
18
+ MutationEnvelope,
19
+ NormalizedEvent,
20
+ NormalizedTableSchema,
21
+ OptimisticSource,
22
+ ProgressFrame,
23
+ QueryId,
24
+ RemoteQuery,
25
+ } from "@rindle/client";
26
+
27
+ import { NormalizedSubscriber } from "./normalized.ts";
28
+ import type { NormalizedBatch, NormalizedHello } from "./normalized.ts";
29
+ import { ProtocolError } from "./protocol.ts";
30
+ import type { ServerMsg } from "./protocol.ts";
31
+ import {
32
+ defaultSubscribeTarget,
33
+ isThenable,
34
+ subscribeMessage,
35
+ type MutationEnvelopeSender,
36
+ type SubscribeResolver,
37
+ type SubscribeTarget,
38
+ } from "./subscribe.ts";
39
+ import { WsTransport } from "./transport.ts";
40
+ import type { Transport } from "./transport.ts";
41
+
42
+ interface QState {
43
+ remote: RemoteQuery;
44
+ subscriber: NormalizedSubscriber | null;
45
+ /** The epoch of the current subscription (0 before the first hello). */
46
+ epoch: number;
47
+ /** True between sending a re-subscribe and receiving its hello (so a second gap is ignored). */
48
+ resubscribing: boolean;
49
+ /** Monotonic token that cancels stale async lease resolutions. */
50
+ subscribeTicket: number;
51
+ }
52
+
53
+ /** Build a transport to a follower's public ws endpoint (READ-ROUTER-DESIGN.md §2.3). Default
54
+ * `(endpoint) => new WsTransport(endpoint)`. */
55
+ export type TransportFactory = (endpoint: string) => Transport;
56
+
57
+ /** How the source obtains its transport:
58
+ * - a pre-built {@link Transport} (or `{ transport }`) — FIXED: no endpoint migration, `wsEndpoint`
59
+ * on leases is ignored (in-process / tests / a single static daemon);
60
+ * - `{ factory, endpoint? }` — REPLACEABLE: transports are built on demand. An initial `endpoint`
61
+ * (a static `wsUrl` or an SSR-injected bootstrap) opens eagerly; otherwise the first lease's
62
+ * `wsEndpoint` opens it lazily, and a later lease naming a DIFFERENT endpoint migrates the whole
63
+ * session there. */
64
+ export type RemoteOptimisticConnection =
65
+ | Transport
66
+ | { transport: Transport }
67
+ | { factory: TransportFactory; endpoint?: string };
68
+
69
+ export interface RemoteOptimisticSourceOptions {
70
+ /** Resolve the upstream subscribe target. Defaults to embedded-server `{name,args}`. */
71
+ resolveSubscribe?: SubscribeResolver;
72
+ /** Override named-mutator delivery, e.g. POST envelopes to the app API server. */
73
+ pushMutation?: MutationEnvelopeSender;
74
+ }
75
+
76
+ export class RemoteOptimisticSource implements OptimisticSource {
77
+ /** The CURRENT transport (undefined in pure-lazy mode until the first lease opens one). */
78
+ private transport: Transport | undefined;
79
+ /** The ws endpoint the current transport points at (undefined for a fixed transport). */
80
+ private currentEndpoint: string | undefined;
81
+ /** Builds a transport for an endpoint; undefined ⇒ fixed transport (no migration). */
82
+ private readonly transportFactory: TransportFactory | undefined;
83
+ private readonly clientID: string;
84
+ private readonly resolveSubscribe: SubscribeResolver;
85
+ private readonly pushMutationSender?: MutationEnvelopeSender;
86
+ private handler: (qid: QueryId, ev: NormalizedEvent) => void = () => {};
87
+ private progressHandler: (frame: ProgressFrame) => void = () => {};
88
+ private restartHandler: () => void = () => {};
89
+ private readonly subs = new Map<QueryId, QState>();
90
+ /** Queries whose subscribe is waiting for a transport to exist — endpoint-less subscribes issued
91
+ * in pure-lazy mode before any lease opens a transport (the lmid system query is registered by
92
+ * the backend at construction). Flushed when a transport comes up. */
93
+ private readonly deferred = new Set<QueryId>();
94
+ /** The daemon's boot id (from each `nhello`); a change means it restarted. */
95
+ private lastBootId: string | undefined;
96
+ /** The client's own typed per-table schemas, for hello validation (CRIT#4); set by the backend. */
97
+ private clientTables: NormalizedTableSchema[] | undefined;
98
+ /** Once true (set by {@link close}), in-flight lease resolutions are inert — they must not open a
99
+ * new transport or send after teardown. */
100
+ private closed = false;
101
+ /** True once any lease has carried a routed `wsEndpoint`. Gates onDown re-leasing so a single
102
+ * UNROUTED daemon keeps its pre-router behavior (recover via reconnect→resync only), not an extra
103
+ * lease POST during an outage. */
104
+ private sawRoutedEndpoint = false;
105
+ /** Bumped at the start of every re-subscribe-all pass. A migrate triggered mid-pass starts a new
106
+ * pass (higher generation); the outer pass then aborts instead of re-subscribing queries twice. */
107
+ private resubscribeGen = 0;
108
+
109
+ constructor(connection: RemoteOptimisticConnection, clientID: string, opts: RemoteOptimisticSourceOptions = {}) {
110
+ this.clientID = clientID;
111
+ this.resolveSubscribe = opts.resolveSubscribe ?? defaultSubscribeTarget;
112
+ this.pushMutationSender = opts.pushMutation;
113
+ if (isTransport(connection)) {
114
+ this.transportFactory = undefined;
115
+ this.bringUp(connection, undefined);
116
+ } else if ("transport" in connection) {
117
+ this.transportFactory = undefined;
118
+ this.bringUp(connection.transport, undefined);
119
+ } else {
120
+ this.transportFactory = connection.factory;
121
+ if (connection.endpoint !== undefined) this.openEndpoint(connection.endpoint);
122
+ }
123
+ }
124
+
125
+ /** Wire a transport's handlers (no `init`). */
126
+ private attach(transport: Transport): void {
127
+ transport.onMessage((msg) => this.onServerMsg(msg));
128
+ // Heal a dropped/restarted connection (same endpoint): on reconnect, replay init + re-subscribe.
129
+ transport.onReconnect?.(() => this.resync());
130
+ // Sustained outage on this endpoint: re-lease (the router may move us off a dead follower, §3).
131
+ transport.onDown?.(() => this.onDown());
132
+ }
133
+
134
+ /** Make `transport` the current one, announce identity, and (re)subscribe anything deferred. */
135
+ private bringUp(transport: Transport, endpoint: string | undefined): void {
136
+ this.transport = transport;
137
+ this.currentEndpoint = endpoint;
138
+ this.attach(transport);
139
+ transport.send({ t: "init", clientID: this.clientID });
140
+ this.flushDeferred();
141
+ }
142
+
143
+ /** Build + bring up a fresh transport to `endpoint` (replaceable mode only). */
144
+ private openEndpoint(endpoint: string): void {
145
+ if (!this.transportFactory) return;
146
+ this.bringUp(this.transportFactory(endpoint), endpoint);
147
+ }
148
+
149
+ /** Migrate the whole session to a new follower (§2.3): build the new transport, tear the old one
150
+ * down, and re-subscribe EVERY active query there (re-leasing — the old tokens are
151
+ * follower-local and invalid on the new node). */
152
+ private migrate(endpoint: string): void {
153
+ const old = this.transport;
154
+ this.openEndpoint(endpoint);
155
+ old?.close();
156
+ this.resubscribeAll();
157
+ }
158
+
159
+ /** Re-subscribe every live query on the current transport (each re-resolves its lease). A
160
+ * re-subscribe can synchronously trigger a `migrate` (lease names a new endpoint), whose own
161
+ * re-subscribe pass supersedes this one — the generation check then aborts this pass so a query
162
+ * is never re-subscribed (and re-leased) twice. */
163
+ private resubscribeAll(): void {
164
+ const gen = ++this.resubscribeGen;
165
+ for (const [qid, s] of this.subs) {
166
+ if (this.resubscribeGen !== gen) return; // a nested migrate/resubscribe took over — stop
167
+ s.subscriber = null;
168
+ s.resubscribing = true;
169
+ this.subscribe(qid, s.remote);
170
+ }
171
+ }
172
+
173
+ /** Flush subscribes deferred until a transport existed (e.g. the lmid query in pure-lazy mode). */
174
+ private flushDeferred(): void {
175
+ if (this.deferred.size === 0) return;
176
+ const qids = [...this.deferred];
177
+ this.deferred.clear();
178
+ for (const qid of qids) {
179
+ const s = this.subs.get(qid);
180
+ if (s) this.subscribe(qid, s.remote);
181
+ }
182
+ }
183
+
184
+ /** The current follower's ws is sustainedly down — re-lease every query. The router returns a
185
+ * (possibly new) `wsEndpoint`: a changed one migrates the session; an unchanged one re-subscribes
186
+ * over the reconnecting transport (READ-ROUTER-DESIGN.md §3). No-op for an UNROUTED daemon (no
187
+ * lease ever carried a `wsEndpoint`) — there is nowhere to move, so we keep the pre-router
188
+ * behavior and let the transport's own reconnect→resync recover. */
189
+ private onDown(): void {
190
+ if (this.closed || !this.sawRoutedEndpoint) return;
191
+ this.resubscribeAll();
192
+ }
193
+
194
+ /** Tear down the current transport and make any in-flight lease resolution inert (a late lease
195
+ * must NOT open a new transport after the consumer closed the client). */
196
+ close(): void {
197
+ this.closed = true;
198
+ this.transport?.close();
199
+ this.subs.clear();
200
+ this.deferred.clear();
201
+ }
202
+
203
+ /** Register a handler fired when the DAEMON restarts (a new boot id) — the backend resets its
204
+ * `cv` watermark so the new daemon's reset `cv` sequence is accepted instead of dropped. */
205
+ onRestart(handler: () => void): void {
206
+ this.restartHandler = handler;
207
+ }
208
+
209
+ expectClientSchema(tables: NormalizedTableSchema[]): void {
210
+ this.clientTables = tables;
211
+ }
212
+
213
+ registerQuery(qid: QueryId, remote: RemoteQuery): void {
214
+ this.subs.set(qid, { remote, subscriber: null, epoch: 0, resubscribing: false, subscribeTicket: 0 });
215
+ this.subscribe(qid, remote);
216
+ }
217
+
218
+ unregisterQuery(qid: QueryId): void {
219
+ this.subs.delete(qid);
220
+ this.deferred.delete(qid);
221
+ this.transport?.send({ t: "unsubscribe", queryId: qid });
222
+ }
223
+
224
+ pushMutation(envelope: MutationEnvelope): Promise<void> {
225
+ if (this.pushMutationSender) return Promise.resolve(this.pushMutationSender(envelope));
226
+ this.transport?.send({ t: "pushMutation", envelope });
227
+ return Promise.resolve();
228
+ }
229
+
230
+ onNormalized(handler: (qid: QueryId, ev: NormalizedEvent) => void): void {
231
+ this.handler = handler;
232
+ }
233
+
234
+ onProgress(handler: (frame: ProgressFrame) => void): void {
235
+ this.progressHandler = handler;
236
+ }
237
+
238
+ // --- internals ---------------------------------------------------------------
239
+
240
+ private subscribe(qid: QueryId, remote: RemoteQuery): void {
241
+ const s = this.subs.get(qid);
242
+ if (!s) return;
243
+ const request = { queryId: qid, remote, mode: "normalized" as const };
244
+ const ticket = ++s.subscribeTicket;
245
+ const send = (target: SubscribeTarget) => {
246
+ if (this.closed) return; // a lease that resolved after close() must not (re)open anything
247
+ const cur = this.subs.get(qid);
248
+ if (cur !== s || cur.subscribeTicket !== ticket) return;
249
+ const endpoint = "leaseToken" in target ? target.wsEndpoint : undefined;
250
+ if (endpoint !== undefined) this.sawRoutedEndpoint = true;
251
+ if (this.transport) {
252
+ if (endpoint !== undefined && endpoint !== this.currentEndpoint && this.transportFactory) {
253
+ // The router placed this key on a DIFFERENT follower — migrate the whole session there.
254
+ // `migrate` re-subscribes every query (incl. this one) over the new transport, so return.
255
+ this.migrate(endpoint);
256
+ return;
257
+ }
258
+ this.transport.send(subscribeMessage(request, target));
259
+ return;
260
+ }
261
+ // No transport yet (pure-lazy): the first lease naming an endpoint opens it.
262
+ if (endpoint !== undefined && this.transportFactory) {
263
+ this.openEndpoint(endpoint);
264
+ this.transport!.send(subscribeMessage(request, target));
265
+ return;
266
+ }
267
+ // Endpoint-less with no transport (the lmid system query before the first lease): defer until
268
+ // a transport comes up, then re-run this subscribe.
269
+ this.deferred.add(qid);
270
+ };
271
+ const fail = (err: unknown) => {
272
+ const cur = this.subs.get(qid);
273
+ if (cur !== s || cur.subscribeTicket !== ticket) return;
274
+ s.resubscribing = false;
275
+ console.error(
276
+ `[rindle-remote] optimistic query ${qid} subscribe resolution failed: ${String((err as Error)?.message ?? err)}`,
277
+ );
278
+ };
279
+ try {
280
+ // The reserved lmid system query is part of the optimistic WIRE contract, not an app
281
+ // query: the server resolves it from the connection's own `init` identity. It must
282
+ // never route through the app's subscribe resolver (the API server has no such named
283
+ // query, and a lease for it would be meaningless).
284
+ const target =
285
+ remote.name === LMID_QUERY_NAME ? defaultSubscribeTarget(request) : this.resolveSubscribe(request);
286
+ if (isThenable(target)) void target.then(send, fail);
287
+ else send(target);
288
+ } catch (err) {
289
+ fail(err);
290
+ }
291
+ }
292
+
293
+ private onServerMsg(msg: ServerMsg): void {
294
+ if (msg.t === "progress") {
295
+ this.progressHandler(msg.frame);
296
+ return;
297
+ }
298
+ if (msg.t === "queryError") {
299
+ this.subs.delete(msg.queryId);
300
+ console.error(`[rindle-remote] optimistic query ${msg.queryId} subscription rejected: ${msg.message}`);
301
+ return;
302
+ }
303
+ if (msg.t !== "nhello" && msg.t !== "nbatch") return;
304
+ // Restart detection rides every nhello (connection-level) and runs BEFORE this query's
305
+ // snapshot buffers, so the backend's reset clears stale state ahead of the fresh hydrate.
306
+ if (msg.t === "nhello") this.observeBootId(msg.bootId);
307
+ const s = this.subs.get(msg.queryId);
308
+ if (!s) return; // unsubscribed / unknown query
309
+ if (msg.t === "nhello") this.openSubscriber(msg.queryId, s, msg.hello);
310
+ else this.applyBatch(msg.queryId, s, msg.batch);
311
+ }
312
+
313
+ /** On reconnect: re-announce identity and re-subscribe every live query (each re-resolves its
314
+ * lease, so a restarted daemon re-materializes + re-leases on the fly). */
315
+ private resync(): void {
316
+ if (this.closed) return;
317
+ this.transport?.send({ t: "init", clientID: this.clientID });
318
+ this.resubscribeAll();
319
+ }
320
+
321
+ /** Track the daemon's boot id; a change (after the first) means it restarted — fire onRestart. */
322
+ private observeBootId(bootId: string | undefined): void {
323
+ if (!bootId) return;
324
+ if (this.lastBootId !== undefined && bootId !== this.lastBootId) this.restartHandler();
325
+ this.lastBootId = bootId;
326
+ }
327
+
328
+ private openSubscriber(qid: QueryId, s: QState, hello: NormalizedHello): void {
329
+ try {
330
+ s.subscriber = new NormalizedSubscriber(hello, (ev) => this.handler(qid, ev), this.clientTables);
331
+ s.epoch = hello.epoch;
332
+ s.resubscribing = false;
333
+ } catch (e) {
334
+ // A comparator/fp mismatch at hello is unrecoverable (a code-contract divergence).
335
+ s.subscriber = null;
336
+ console.error(`[rindle-remote] optimistic query ${qid} subscription rejected: ${(e as Error).message}`);
337
+ }
338
+ }
339
+
340
+ private applyBatch(qid: QueryId, s: QState, batch: NormalizedBatch): void {
341
+ if (!s.subscriber) return; // no hello yet (or mid re-hydrate)
342
+ if (batch.epoch < s.epoch) return; // a stale batch from a superseded epoch — drop
343
+ try {
344
+ s.subscriber.apply(batch);
345
+ } catch (e) {
346
+ if (!(e instanceof ProtocolError)) throw e;
347
+ if (s.resubscribing) return; // already recovering
348
+ // Gap / drift → re-hydrate under a new epoch; the fresh snapshot arrives `cv`-stamped
349
+ // and releases (re-hydrating the footprint) at the server's accompanying progress frame.
350
+ s.resubscribing = true;
351
+ s.subscriber = null;
352
+ this.subscribe(qid, s.remote);
353
+ }
354
+ }
355
+ }
356
+
357
+ /** Convenience: a `RemoteOptimisticSource` over a ws URL or a custom transport. A URL becomes a
358
+ * replaceable connection seeded at that endpoint (so a routed lease can still migrate it); a
359
+ * pre-built transport stays fixed. */
360
+ export function createRemoteOptimisticSource(
361
+ urlOrTransport: string | Transport,
362
+ clientID: string,
363
+ opts: RemoteOptimisticSourceOptions = {},
364
+ ): RemoteOptimisticSource {
365
+ const connection: RemoteOptimisticConnection =
366
+ typeof urlOrTransport === "string"
367
+ ? { factory: (endpoint) => new WsTransport(endpoint), endpoint: urlOrTransport }
368
+ : urlOrTransport;
369
+ return new RemoteOptimisticSource(connection, clientID, opts);
370
+ }
371
+
372
+ function isTransport(value: RemoteOptimisticConnection): value is Transport {
373
+ return typeof (value as Transport).send === "function" && typeof (value as Transport).onMessage === "function";
374
+ }
@@ -0,0 +1,252 @@
1
+ // The flat-change subscription protocol, ported from `src/flat_protocol.rs` + the
2
+ // `schema_fp` fingerprint from `src/wire_schema.rs`. This is the wire contract shared by the
3
+ // server (`@rindle/server`, which frames via {@link Publisher}) and the client RemoteBackend
4
+ // (which validates via {@link Subscriber}).
5
+ //
6
+ // The §2.2 split (WASM-CLIENT-DESIGN.md): the Rust `Subscriber` owns a `Receiver` and does
7
+ // validation *and* folding. Here we keep ONLY validation/sequencing in {@link Subscriber} and
8
+ // hand the clean `hello`/`snapshot`/`batch` `ChangeEvent`s up to the core `ArrayView` to fold —
9
+ // so one `ArrayView` serves both the local and remote backends.
10
+
11
+ import { COMPARATOR_VERSION } from "@rindle/client";
12
+ import type { ChangeEvent, FlatChange, Mutation, MutationEnvelope, ProgressFrame, WireSchema } from "@rindle/client";
13
+ import type { NormalizedBatch, NormalizedHello } from "./normalized.ts";
14
+
15
+ export { COMPARATOR_VERSION };
16
+
17
+ // ----------------------------- schema fingerprint (FNV-1a 64) -----------------------------
18
+
19
+ const enc = new TextEncoder();
20
+ const FNV_OFFSET = 0xcbf29ce484222325n;
21
+ const FNV_PRIME = 0x00000100000001b3n;
22
+ const MASK = 0xffffffffffffffffn;
23
+
24
+ export class Fnv {
25
+ h = FNV_OFFSET;
26
+ private byte(b: number): void {
27
+ this.h = ((this.h ^ BigInt(b & 0xff)) * FNV_PRIME) & MASK;
28
+ }
29
+ u8(v: number): void {
30
+ this.byte(v);
31
+ }
32
+ /** A `u32` little-endian (matching Rust `v.to_le_bytes()`). */
33
+ u32(v: number): void {
34
+ this.byte(v & 0xff);
35
+ this.byte((v >>> 8) & 0xff);
36
+ this.byte((v >>> 16) & 0xff);
37
+ this.byte((v >>> 24) & 0xff);
38
+ }
39
+ /** A length-prefixed string: `u32(byteLen)` then the UTF-8 bytes (disambiguates joins). */
40
+ s(str: string): void {
41
+ const bytes = enc.encode(str);
42
+ this.u32(bytes.length);
43
+ for (const b of bytes) this.byte(b);
44
+ }
45
+ }
46
+
47
+ function hashLevel(f: Fnv, ws: WireSchema): void {
48
+ f.u8(0x53); // 'S'
49
+ f.u32(ws.columns.length);
50
+ for (const c of ws.columns) f.s(c);
51
+ // PK + sort resolved to column NAMES (semantic identity, independent of indices).
52
+ f.u32(ws.primaryKey.length);
53
+ for (const pk of ws.primaryKey) f.s(ws.columns[pk]);
54
+ f.u32(ws.sort.length);
55
+ for (const [c, asc] of ws.sort) {
56
+ f.s(ws.columns[c]);
57
+ f.u8(asc ? 1 : 0);
58
+ }
59
+ f.u8(ws.singular ? 1 : 0);
60
+ f.u32(ws.relationships.length);
61
+ for (const r of ws.relationships) {
62
+ f.s(r.name);
63
+ if (r.child) {
64
+ f.u8(1);
65
+ hashLevel(f, r.child);
66
+ // Scalar-projection marker (REDUCE-DESIGN.md §9): a presence byte, then — when present
67
+ // — the projected column's NAME in the child schema (index-independent, matching
68
+ // PK/sort). The empty-identity value is NOT hashed (a bare cell can't reproduce its
69
+ // type variant). Must match `hash_level` in `src/wire_schema.rs`.
70
+ if (r.project) {
71
+ f.u8(1);
72
+ f.s(r.child.columns[r.project.col]);
73
+ } else {
74
+ f.u8(0);
75
+ }
76
+ } else {
77
+ f.u8(0);
78
+ }
79
+ }
80
+ }
81
+
82
+ /** A `WireSchema`'s content fingerprint — FNV-1a 64 over the canonical, length-prefixed byte
83
+ * stream of `src/wire_schema.rs`, rendered as 16-char lowercase hex (=== Rust `SchemaFp`
84
+ * `Display`). A string (not a JS number) so the full 64 bits survive JSON without precision loss. */
85
+ export function schemaFp(ws: WireSchema): string {
86
+ const f = new Fnv();
87
+ hashLevel(f, ws);
88
+ return f.h.toString(16).padStart(16, "0");
89
+ }
90
+
91
+ // ----------------------------- the wire frames -----------------------------
92
+
93
+ /** The subscription handshake, sent once before any {@link Batch}. */
94
+ export interface Hello {
95
+ epoch: number;
96
+ comparatorVersion: number;
97
+ schema: WireSchema;
98
+ schemaFp: string;
99
+ }
100
+
101
+ /** One transaction's flat changes (or the seq-0 hydrate snapshot). `events` apply in order. */
102
+ export interface Batch {
103
+ epoch: number;
104
+ seq: number;
105
+ schemaFp: string;
106
+ events: FlatChange[];
107
+ }
108
+
109
+ /** The multiplexed wire messages (many queries over one connection, tagged by `queryId`).
110
+ * `subscribe.mode` selects the serializer (default flat); a normalized subscription gets
111
+ * `nhello`/`nbatch` back instead of `hello`/`batch` (NORMALIZED-CHANGES-DESIGN.md §6).
112
+ * Embedded servers receive `{name,args}`. Daemon/serverless deployments can instead receive an
113
+ * opaque `leaseToken` that the app API server minted after auth + named-query resolution.
114
+ *
115
+ * The OPTIMISTIC path (OPTIMISTIC-WRITES-DESIGN.md §8) adds `init` (the connection
116
+ * identifies its stable clientID, so progress frames can carry that client's `lmid`) and
117
+ * `pushMutation` (one named-mutator envelope up); the server answers with the normalized
118
+ * frames (`cv`-stamped) plus connection-level `progress` frames. */
119
+ export type SubscribeClientMsg =
120
+ | { t: "subscribe"; queryId: number; name: string; args: unknown; mode?: "flat" | "normalized" }
121
+ | { t: "subscribe"; queryId: number; leaseToken: string; mode?: "flat" | "normalized" };
122
+
123
+ export type ClientMsg =
124
+ | { t: "init"; clientID: string }
125
+ | SubscribeClientMsg
126
+ | { t: "unsubscribe"; queryId: number }
127
+ | { t: "mutate"; mutations: Mutation[] }
128
+ | { t: "pushMutation"; envelope: MutationEnvelope };
129
+
130
+ export type ServerMsg =
131
+ | { t: "hello"; queryId: number; hello: Hello }
132
+ | { t: "batch"; queryId: number; batch: Batch }
133
+ // `bootId` is the daemon's per-process id (same on every frame of one connection). A change
134
+ // across (re)connections means the daemon restarted — it keeps no durable lease/materialization
135
+ // or `cv` state — so the client must force a clean re-hydrate (reset its `cv` watermark).
136
+ | { t: "nhello"; queryId: number; hello: NormalizedHello; bootId?: string }
137
+ | { t: "nbatch"; queryId: number; batch: NormalizedBatch }
138
+ | { t: "queryError"; queryId: number; message: string }
139
+ | { t: "progress"; frame: ProgressFrame }
140
+ // A per-connection error reply: the server could not process this client's message (bad
141
+ // table/AST, a commit/derive failure). Sent INSTEAD of crashing the process — the connection
142
+ // stays open and every other connection is unaffected. Existing clients ignore unknown `t`.
143
+ | { t: "error"; queryId?: number; message: string };
144
+
145
+ // ----------------------------- Publisher (server side) -----------------------------
146
+
147
+ /** Sender side: stamps batches with the subscription `epoch` + `schemaFp` and drives the
148
+ * gap-free seq. The hydrate snapshot is seq 0; increments are seq 1, 2, …. An empty
149
+ * transaction emits no batch and consumes no seq (so a gap always means a lost batch). */
150
+ export class Publisher {
151
+ readonly epoch: number;
152
+ readonly schema: WireSchema;
153
+ readonly schemaFp: string;
154
+ private nextSeq = 0;
155
+
156
+ constructor(epoch: number, schema: WireSchema) {
157
+ this.epoch = epoch;
158
+ this.schema = schema;
159
+ this.schemaFp = schemaFp(schema);
160
+ }
161
+
162
+ hello(): Hello {
163
+ return { epoch: this.epoch, comparatorVersion: COMPARATOR_VERSION, schema: this.schema, schemaFp: this.schemaFp };
164
+ }
165
+
166
+ /** The hydrate snapshot (seq 0). Always emitted, even when empty, so the receiver learns it. */
167
+ snapshot(events: FlatChange[]): Batch {
168
+ return this.emit(events);
169
+ }
170
+
171
+ /** One transaction's events — `null` for an empty transaction (no batch, no seq consumed). */
172
+ commit(events: FlatChange[]): Batch | null {
173
+ return events.length ? this.emit(events) : null;
174
+ }
175
+
176
+ private emit(events: FlatChange[]): Batch {
177
+ const seq = this.nextSeq++;
178
+ return { epoch: this.epoch, seq, schemaFp: this.schemaFp, events };
179
+ }
180
+ }
181
+
182
+ // ----------------------------- Subscriber (client side) -----------------------------
183
+
184
+ export type ProtocolErrorKind = "comparator-mismatch" | "epoch-mismatch" | "schema-mismatch" | "gap";
185
+
186
+ /** A protocol violation. All but a duplicate (handled silently) are unrecoverable for the
187
+ * current subscription — the RemoteBackend re-hydrates under a new epoch. */
188
+ export class ProtocolError extends Error {
189
+ readonly kind: ProtocolErrorKind;
190
+ constructor(kind: ProtocolErrorKind, message: string) {
191
+ super(message);
192
+ this.kind = kind;
193
+ this.name = "ProtocolError";
194
+ }
195
+ }
196
+
197
+ /** Receiver side: validates a frame stream (comparator at `hello`; per batch — epoch match,
198
+ * schema-fp match, strict in-order seq) and emits the clean `hello`/`snapshot`/`batch`
199
+ * `ChangeEvent`s. It does NOT fold (the core `ArrayView` does) — the §2.2 split. */
200
+ export class Subscriber {
201
+ readonly epoch: number;
202
+ readonly schemaFp: string;
203
+ private readonly emit: (ev: ChangeEvent) => void;
204
+ private phase: "snapshot" | "live" = "snapshot";
205
+ private lastSeq = 0;
206
+
207
+ constructor(hello: Hello, emit: (ev: ChangeEvent) => void) {
208
+ this.emit = emit;
209
+ if (hello.comparatorVersion !== COMPARATOR_VERSION) {
210
+ throw new ProtocolError(
211
+ "comparator-mismatch",
212
+ `comparator version ${hello.comparatorVersion} != ${COMPARATOR_VERSION}`,
213
+ );
214
+ }
215
+ const computed = schemaFp(hello.schema);
216
+ if (computed !== hello.schemaFp) {
217
+ throw new ProtocolError("schema-mismatch", `advertised fp ${hello.schemaFp} != computed ${computed}`);
218
+ }
219
+ this.epoch = hello.epoch;
220
+ this.schemaFp = hello.schemaFp;
221
+ emit({ type: "hello", schema: hello.schema, comparatorVersion: hello.comparatorVersion });
222
+ }
223
+
224
+ /** Apply one incremental batch (or the seq-0 snapshot). Returns `"duplicate"` for an
225
+ * already-applied seq (discarded — rc ops are not idempotent); throws {@link ProtocolError}
226
+ * on a gap / epoch / schema mismatch (the caller re-hydrates). */
227
+ apply(batch: Batch): "applied" | "duplicate" {
228
+ if (batch.epoch !== this.epoch) {
229
+ throw new ProtocolError("epoch-mismatch", `expected epoch ${this.epoch}, got ${batch.epoch}`);
230
+ }
231
+ if (batch.schemaFp !== this.schemaFp) {
232
+ throw new ProtocolError("schema-mismatch", `expected fp ${this.schemaFp}, got ${batch.schemaFp}`);
233
+ }
234
+ if (this.phase === "snapshot") {
235
+ if (batch.seq === 0) {
236
+ this.phase = "live";
237
+ this.lastSeq = 0;
238
+ this.emit({ type: "snapshot", adds: batch.events, last: true });
239
+ return "applied";
240
+ }
241
+ throw new ProtocolError("gap", `expected the seq-0 snapshot, got seq ${batch.seq}`);
242
+ }
243
+ const expected = this.lastSeq + 1;
244
+ if (batch.seq < expected) return "duplicate";
245
+ if (batch.seq > expected) {
246
+ throw new ProtocolError("gap", `expected seq ${expected}, got ${batch.seq}`);
247
+ }
248
+ this.lastSeq = batch.seq;
249
+ this.emit({ type: "batch", events: batch.events });
250
+ return "applied";
251
+ }
252
+ }