@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,50 @@
1
+ import type { NormalizedEvent, NormalizedOp, NormalizedTableSchema } from "@rindle/client";
2
+ /** A `tables` set's content fingerprint — FNV-1a 64 over the canonical, length-prefixed byte
3
+ * stream of `rindle-replica::normalize_protocol::normalized_fp` (PK resolved to column NAMES),
4
+ * as 16-char lowercase hex (=== the Rust hex). `tables` MUST be sorted by name (the server's
5
+ * `NormalizedPublisher` guarantees it) so the fingerprint is order-stable. */
6
+ export declare function normalizedFp(tables: NormalizedTableSchema[]): string;
7
+ /** The slim normalized handshake (§3), sent once before any {@link NormalizedBatch}. */
8
+ export interface NormalizedHello {
9
+ epoch: number;
10
+ comparatorVersion: number;
11
+ tables: NormalizedTableSchema[];
12
+ normalizedFp: string;
13
+ }
14
+ /** One committed transaction's normalized ops (or the seq-0 hydrate snapshot). `cv` (the
15
+ * global commit version the frame reflects) is stamped by optimistic-protocol servers and
16
+ * rides through to the `NormalizedEvent` (OPTIMISTIC-WRITES-DESIGN.md §8.3). */
17
+ export interface NormalizedBatch {
18
+ epoch: number;
19
+ seq: number;
20
+ normalizedFp: string;
21
+ ops: NormalizedOp[];
22
+ cv?: number;
23
+ }
24
+ /** Receiver side: validates a normalized frame stream (comparator at `hello`; per batch —
25
+ * epoch match, fingerprint match, strict in-order seq) and emits clean `NormalizedEvent`s.
26
+ * It does NOT fold (the `NormalizedSync` layer does). Mirrors the flat {@link Subscriber}. */
27
+ export declare class NormalizedSubscriber {
28
+ readonly epoch: number;
29
+ readonly normalizedFp: string;
30
+ private readonly emit;
31
+ private phase;
32
+ private lastSeq;
33
+ /**
34
+ * @param hello the server's normalized handshake.
35
+ * @param emit clean-event sink (the `NormalizedSync` fold).
36
+ * @param clientTables the CLIENT's own typed per-table schemas (all tables). When given,
37
+ * each table the hello advertises is validated by NAME against it (column order + PK
38
+ * indices). The hello's `tables` is a per-query SUBSET (the query's table tree), so this
39
+ * checks each advertised table rather than one global fingerprint. A mismatch (routine
40
+ * deployment / schema skew) is rejected here — without it, positional rows aligned to the
41
+ * SERVER's column order are stored verbatim under the CLIENT's order, silently swapping
42
+ * cells and mis-keying the refcount/GC (CRIT#4 / §3 "drift ⇒ re-subscribe").
43
+ */
44
+ constructor(hello: NormalizedHello, emit: (ev: NormalizedEvent) => void, clientTables?: NormalizedTableSchema[]);
45
+ /** Apply one normalized batch (or the seq-0 snapshot). Returns `"duplicate"` for an
46
+ * already-applied seq; throws {@link ProtocolError} on a gap / epoch / fp mismatch (the
47
+ * caller re-hydrates under a new epoch). */
48
+ apply(batch: NormalizedBatch): "applied" | "duplicate";
49
+ }
50
+ //# sourceMappingURL=normalized.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"normalized.d.ts","sourceRoot":"","sources":["../src/normalized.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAO3F;;;+EAG+E;AAC/E,wBAAgB,YAAY,CAAC,MAAM,EAAE,qBAAqB,EAAE,GAAG,MAAM,CAWpE;AA0DD,wFAAwF;AACxF,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,iBAAiB,EAAE,MAAM,CAAC;IAC1B,MAAM,EAAE,qBAAqB,EAAE,CAAC;IAChC,YAAY,EAAE,MAAM,CAAC;CACtB;AAED;;iFAEiF;AACjF,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,EAAE,MAAM,CAAC;IACrB,GAAG,EAAE,YAAY,EAAE,CAAC;IACpB,EAAE,CAAC,EAAE,MAAM,CAAC;CACb;AAID;;+FAE+F;AAC/F,qBAAa,oBAAoB;IAC/B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAgC;IACrD,OAAO,CAAC,KAAK,CAAmC;IAChD,OAAO,CAAC,OAAO,CAAK;IAEpB;;;;;;;;;;OAUG;gBAED,KAAK,EAAE,eAAe,EACtB,IAAI,EAAE,CAAC,EAAE,EAAE,eAAe,KAAK,IAAI,EACnC,YAAY,CAAC,EAAE,qBAAqB,EAAE;IAwBxC;;iDAE6C;IAC7C,KAAK,CAAC,KAAK,EAAE,eAAe,GAAG,SAAS,GAAG,WAAW;CAyBvD"}
@@ -0,0 +1,146 @@
1
+ // The NORMALIZED subscription protocol — the wire contract shared by the server
2
+ // (`@rindle/server`, which relays the native engine's already-stamped frames) and the client
3
+ // (`RemoteNormalizedSource`, which validates via {@link NormalizedSubscriber}). The path-free
4
+ // twin of `./protocol.ts`: same epoch/seq/gap envelope, normalized payloads (table-tagged
5
+ // `NormalizedOp`s), and a slim per-table-schema hello (NORMALIZED-CHANGES-DESIGN.md §3).
6
+ //
7
+ // As with the flat path, the Subscriber does ONLY validation/sequencing and hands clean
8
+ // `NormalizedEvent`s up — the `NormalizedSync` layer (in `@rindle/normalized`) folds them.
9
+ import { COMPARATOR_VERSION } from "@rindle/client";
10
+ import { Fnv } from "./protocol.js";
11
+ import { ProtocolError } from "./protocol.js";
12
+ // ----------------------------- normalized fingerprint -----------------------------
13
+ /** A `tables` set's content fingerprint — FNV-1a 64 over the canonical, length-prefixed byte
14
+ * stream of `rindle-replica::normalize_protocol::normalized_fp` (PK resolved to column NAMES),
15
+ * as 16-char lowercase hex (=== the Rust hex). `tables` MUST be sorted by name (the server's
16
+ * `NormalizedPublisher` guarantees it) so the fingerprint is order-stable. */
17
+ export function normalizedFp(tables) {
18
+ const f = new Fnv();
19
+ f.u32(tables.length);
20
+ for (const t of tables) {
21
+ f.s(t.name);
22
+ f.u32(t.columns.length);
23
+ for (const c of t.columns)
24
+ f.s(c);
25
+ f.u32(t.primaryKey.length);
26
+ for (const pk of t.primaryKey)
27
+ f.s(t.columns[pk]); // PK by NAME
28
+ }
29
+ return f.h.toString(16).padStart(16, "0");
30
+ }
31
+ /** Is `sub` a subsequence of `sup` by NAME — every entry of `sub` present in `sup`, in order?
32
+ * Accepts a projection (server ⊆ client) or an expansion (client ⊆ server); a rename/reorder
33
+ * satisfies neither direction. */
34
+ function isSubsequenceByName(sub, sup) {
35
+ let i = 0;
36
+ for (const name of sup)
37
+ if (i < sub.length && sub[i] === name)
38
+ i++;
39
+ return i === sub.length;
40
+ }
41
+ /** Validate the tables a `hello` advertises against the CLIENT's own typed schema, by name
42
+ * (column order + PK indices). `hello.tables` is the query's table subtree, so each one must
43
+ * be column-compatible with a client table. Throws {@link ProtocolError} `"schema-mismatch"`
44
+ * on the first unknown table / column-order skew / PK skew — the §3 "drift ⇒ re-subscribe"
45
+ * guard (CRIT#4). */
46
+ function validateAgainstClientSchema(serverTables, clientTables) {
47
+ const byName = new Map(clientTables.map((t) => [t.name, t]));
48
+ const eq = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
49
+ for (const st of serverTables) {
50
+ const ct = byName.get(st.name);
51
+ if (!ct) {
52
+ throw new ProtocolError("schema-mismatch", `server advertises table "${st.name}" not in the client schema`);
53
+ }
54
+ // The server may advertise FEWER columns than the client (a projected query —
55
+ // PROJECTION-SUPPORT-DESIGN.md §5.2/§7) OR MORE (an EXPANDED server table mid an
56
+ // `expand-then-contract` migration — the client drops the columns it doesn't yet have).
57
+ // Both are safe: the client maps every advertised column to a base position BY NAME, so
58
+ // the relative order is preserved either way. Accept when one column list is a SUBSEQUENCE
59
+ // of the other by name — a narrowing (projection) or a widening (expand) — while still
60
+ // rejecting a genuine skew, where NEITHER is a subsequence of the other: a renamed column
61
+ // or a reordered one (the CRIT#4 guard). A server with more columns than the client is no
62
+ // longer drift; without this, `expand-then-contract` is impossible.
63
+ if (!isSubsequenceByName(st.columns, ct.columns) && !isSubsequenceByName(ct.columns, st.columns)) {
64
+ throw new ProtocolError("schema-mismatch", `column drift on "${st.name}": server [${st.columns}] is neither a projection nor an expansion of client [${ct.columns}] (column order skew)`);
65
+ }
66
+ // PK compared by NAME (the server forces the PK into every projection, so it is always
67
+ // present): resolve each side's PK indices to names and require equality.
68
+ const serverPk = st.primaryKey.map((i) => st.columns[i]);
69
+ const clientPk = ct.primaryKey.map((i) => ct.columns[i]);
70
+ if (!eq(serverPk, clientPk)) {
71
+ throw new ProtocolError("schema-mismatch", `primary-key drift on "${st.name}": client [${clientPk}] != server [${serverPk}]`);
72
+ }
73
+ }
74
+ }
75
+ // ----------------------------- Subscriber (client side) -----------------------------
76
+ /** Receiver side: validates a normalized frame stream (comparator at `hello`; per batch —
77
+ * epoch match, fingerprint match, strict in-order seq) and emits clean `NormalizedEvent`s.
78
+ * It does NOT fold (the `NormalizedSync` layer does). Mirrors the flat {@link Subscriber}. */
79
+ export class NormalizedSubscriber {
80
+ epoch;
81
+ normalizedFp;
82
+ emit;
83
+ phase = "snapshot";
84
+ lastSeq = 0;
85
+ /**
86
+ * @param hello the server's normalized handshake.
87
+ * @param emit clean-event sink (the `NormalizedSync` fold).
88
+ * @param clientTables the CLIENT's own typed per-table schemas (all tables). When given,
89
+ * each table the hello advertises is validated by NAME against it (column order + PK
90
+ * indices). The hello's `tables` is a per-query SUBSET (the query's table tree), so this
91
+ * checks each advertised table rather than one global fingerprint. A mismatch (routine
92
+ * deployment / schema skew) is rejected here — without it, positional rows aligned to the
93
+ * SERVER's column order are stored verbatim under the CLIENT's order, silently swapping
94
+ * cells and mis-keying the refcount/GC (CRIT#4 / §3 "drift ⇒ re-subscribe").
95
+ */
96
+ constructor(hello, emit, clientTables) {
97
+ this.emit = emit;
98
+ if (hello.comparatorVersion !== COMPARATOR_VERSION) {
99
+ throw new ProtocolError("comparator-mismatch", `comparator version ${hello.comparatorVersion} != ${COMPARATOR_VERSION}`);
100
+ }
101
+ const computed = normalizedFp(hello.tables);
102
+ if (computed !== hello.normalizedFp) {
103
+ throw new ProtocolError("schema-mismatch", `advertised fp ${hello.normalizedFp} != computed ${computed}`);
104
+ }
105
+ if (clientTables)
106
+ validateAgainstClientSchema(hello.tables, clientTables);
107
+ this.epoch = hello.epoch;
108
+ this.normalizedFp = hello.normalizedFp;
109
+ emit({
110
+ type: "hello",
111
+ tables: hello.tables,
112
+ comparatorVersion: hello.comparatorVersion,
113
+ normalizedFp: hello.normalizedFp,
114
+ });
115
+ }
116
+ /** Apply one normalized batch (or the seq-0 snapshot). Returns `"duplicate"` for an
117
+ * already-applied seq; throws {@link ProtocolError} on a gap / epoch / fp mismatch (the
118
+ * caller re-hydrates under a new epoch). */
119
+ apply(batch) {
120
+ if (batch.epoch !== this.epoch) {
121
+ throw new ProtocolError("epoch-mismatch", `expected epoch ${this.epoch}, got ${batch.epoch}`);
122
+ }
123
+ if (batch.normalizedFp !== this.normalizedFp) {
124
+ throw new ProtocolError("schema-mismatch", `expected fp ${this.normalizedFp}, got ${batch.normalizedFp}`);
125
+ }
126
+ if (this.phase === "snapshot") {
127
+ if (batch.seq === 0) {
128
+ this.phase = "live";
129
+ this.lastSeq = 0;
130
+ this.emit({ type: "snapshot", ops: batch.ops, cv: batch.cv });
131
+ return "applied";
132
+ }
133
+ throw new ProtocolError("gap", `expected the seq-0 snapshot, got seq ${batch.seq}`);
134
+ }
135
+ const expected = this.lastSeq + 1;
136
+ if (batch.seq < expected)
137
+ return "duplicate";
138
+ if (batch.seq > expected) {
139
+ throw new ProtocolError("gap", `expected seq ${expected}, got ${batch.seq}`);
140
+ }
141
+ this.lastSeq = batch.seq;
142
+ this.emit({ type: "batch", ops: batch.ops, cv: batch.cv });
143
+ return "applied";
144
+ }
145
+ }
146
+ //# sourceMappingURL=normalized.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"normalized.js","sourceRoot":"","sources":["../src/normalized.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,108 @@
1
+ import type { MutationEnvelope, NormalizedEvent, NormalizedTableSchema, OptimisticSource, ProgressFrame, QueryId, RemoteQuery } from "@rindle/client";
2
+ import { type MutationEnvelopeSender, type SubscribeResolver } from "./subscribe.ts";
3
+ import type { Transport } from "./transport.ts";
4
+ /** Build a transport to a follower's public ws endpoint (READ-ROUTER-DESIGN.md §2.3). Default
5
+ * `(endpoint) => new WsTransport(endpoint)`. */
6
+ export type TransportFactory = (endpoint: string) => Transport;
7
+ /** How the source obtains its transport:
8
+ * - a pre-built {@link Transport} (or `{ transport }`) — FIXED: no endpoint migration, `wsEndpoint`
9
+ * on leases is ignored (in-process / tests / a single static daemon);
10
+ * - `{ factory, endpoint? }` — REPLACEABLE: transports are built on demand. An initial `endpoint`
11
+ * (a static `wsUrl` or an SSR-injected bootstrap) opens eagerly; otherwise the first lease's
12
+ * `wsEndpoint` opens it lazily, and a later lease naming a DIFFERENT endpoint migrates the whole
13
+ * session there. */
14
+ export type RemoteOptimisticConnection = Transport | {
15
+ transport: Transport;
16
+ } | {
17
+ factory: TransportFactory;
18
+ endpoint?: string;
19
+ };
20
+ export interface RemoteOptimisticSourceOptions {
21
+ /** Resolve the upstream subscribe target. Defaults to embedded-server `{name,args}`. */
22
+ resolveSubscribe?: SubscribeResolver;
23
+ /** Override named-mutator delivery, e.g. POST envelopes to the app API server. */
24
+ pushMutation?: MutationEnvelopeSender;
25
+ }
26
+ export declare class RemoteOptimisticSource implements OptimisticSource {
27
+ /** The CURRENT transport (undefined in pure-lazy mode until the first lease opens one). */
28
+ private transport;
29
+ /** The ws endpoint the current transport points at (undefined for a fixed transport). */
30
+ private currentEndpoint;
31
+ /** Builds a transport for an endpoint; undefined ⇒ fixed transport (no migration). */
32
+ private readonly transportFactory;
33
+ private readonly clientID;
34
+ private readonly resolveSubscribe;
35
+ private readonly pushMutationSender?;
36
+ private handler;
37
+ private progressHandler;
38
+ private restartHandler;
39
+ private readonly subs;
40
+ /** Queries whose subscribe is waiting for a transport to exist — endpoint-less subscribes issued
41
+ * in pure-lazy mode before any lease opens a transport (the lmid system query is registered by
42
+ * the backend at construction). Flushed when a transport comes up. */
43
+ private readonly deferred;
44
+ /** The daemon's boot id (from each `nhello`); a change means it restarted. */
45
+ private lastBootId;
46
+ /** The client's own typed per-table schemas, for hello validation (CRIT#4); set by the backend. */
47
+ private clientTables;
48
+ /** Once true (set by {@link close}), in-flight lease resolutions are inert — they must not open a
49
+ * new transport or send after teardown. */
50
+ private closed;
51
+ /** True once any lease has carried a routed `wsEndpoint`. Gates onDown re-leasing so a single
52
+ * UNROUTED daemon keeps its pre-router behavior (recover via reconnect→resync only), not an extra
53
+ * lease POST during an outage. */
54
+ private sawRoutedEndpoint;
55
+ /** Bumped at the start of every re-subscribe-all pass. A migrate triggered mid-pass starts a new
56
+ * pass (higher generation); the outer pass then aborts instead of re-subscribing queries twice. */
57
+ private resubscribeGen;
58
+ constructor(connection: RemoteOptimisticConnection, clientID: string, opts?: RemoteOptimisticSourceOptions);
59
+ /** Wire a transport's handlers (no `init`). */
60
+ private attach;
61
+ /** Make `transport` the current one, announce identity, and (re)subscribe anything deferred. */
62
+ private bringUp;
63
+ /** Build + bring up a fresh transport to `endpoint` (replaceable mode only). */
64
+ private openEndpoint;
65
+ /** Migrate the whole session to a new follower (§2.3): build the new transport, tear the old one
66
+ * down, and re-subscribe EVERY active query there (re-leasing — the old tokens are
67
+ * follower-local and invalid on the new node). */
68
+ private migrate;
69
+ /** Re-subscribe every live query on the current transport (each re-resolves its lease). A
70
+ * re-subscribe can synchronously trigger a `migrate` (lease names a new endpoint), whose own
71
+ * re-subscribe pass supersedes this one — the generation check then aborts this pass so a query
72
+ * is never re-subscribed (and re-leased) twice. */
73
+ private resubscribeAll;
74
+ /** Flush subscribes deferred until a transport existed (e.g. the lmid query in pure-lazy mode). */
75
+ private flushDeferred;
76
+ /** The current follower's ws is sustainedly down — re-lease every query. The router returns a
77
+ * (possibly new) `wsEndpoint`: a changed one migrates the session; an unchanged one re-subscribes
78
+ * over the reconnecting transport (READ-ROUTER-DESIGN.md §3). No-op for an UNROUTED daemon (no
79
+ * lease ever carried a `wsEndpoint`) — there is nowhere to move, so we keep the pre-router
80
+ * behavior and let the transport's own reconnect→resync recover. */
81
+ private onDown;
82
+ /** Tear down the current transport and make any in-flight lease resolution inert (a late lease
83
+ * must NOT open a new transport after the consumer closed the client). */
84
+ close(): void;
85
+ /** Register a handler fired when the DAEMON restarts (a new boot id) — the backend resets its
86
+ * `cv` watermark so the new daemon's reset `cv` sequence is accepted instead of dropped. */
87
+ onRestart(handler: () => void): void;
88
+ expectClientSchema(tables: NormalizedTableSchema[]): void;
89
+ registerQuery(qid: QueryId, remote: RemoteQuery): void;
90
+ unregisterQuery(qid: QueryId): void;
91
+ pushMutation(envelope: MutationEnvelope): Promise<void>;
92
+ onNormalized(handler: (qid: QueryId, ev: NormalizedEvent) => void): void;
93
+ onProgress(handler: (frame: ProgressFrame) => void): void;
94
+ private subscribe;
95
+ private onServerMsg;
96
+ /** On reconnect: re-announce identity and re-subscribe every live query (each re-resolves its
97
+ * lease, so a restarted daemon re-materializes + re-leases on the fly). */
98
+ private resync;
99
+ /** Track the daemon's boot id; a change (after the first) means it restarted — fire onRestart. */
100
+ private observeBootId;
101
+ private openSubscriber;
102
+ private applyBatch;
103
+ }
104
+ /** Convenience: a `RemoteOptimisticSource` over a ws URL or a custom transport. A URL becomes a
105
+ * replaceable connection seeded at that endpoint (so a routed lease can still migrate it); a
106
+ * pre-built transport stays fixed. */
107
+ export declare function createRemoteOptimisticSource(urlOrTransport: string | Transport, clientID: string, opts?: RemoteOptimisticSourceOptions): RemoteOptimisticSource;
108
+ //# sourceMappingURL=optimistic-source.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"optimistic-source.d.ts","sourceRoot":"","sources":["../src/optimistic-source.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,317 @@
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
+ import { LMID_QUERY_NAME } from "@rindle/client";
16
+ import { NormalizedSubscriber } from "./normalized.js";
17
+ import { ProtocolError } from "./protocol.js";
18
+ import { defaultSubscribeTarget, isThenable, subscribeMessage, } from "./subscribe.js";
19
+ import { WsTransport } from "./transport.js";
20
+ export class RemoteOptimisticSource {
21
+ /** The CURRENT transport (undefined in pure-lazy mode until the first lease opens one). */
22
+ transport;
23
+ /** The ws endpoint the current transport points at (undefined for a fixed transport). */
24
+ currentEndpoint;
25
+ /** Builds a transport for an endpoint; undefined ⇒ fixed transport (no migration). */
26
+ transportFactory;
27
+ clientID;
28
+ resolveSubscribe;
29
+ pushMutationSender;
30
+ handler = () => { };
31
+ progressHandler = () => { };
32
+ restartHandler = () => { };
33
+ subs = new Map();
34
+ /** Queries whose subscribe is waiting for a transport to exist — endpoint-less subscribes issued
35
+ * in pure-lazy mode before any lease opens a transport (the lmid system query is registered by
36
+ * the backend at construction). Flushed when a transport comes up. */
37
+ deferred = new Set();
38
+ /** The daemon's boot id (from each `nhello`); a change means it restarted. */
39
+ lastBootId;
40
+ /** The client's own typed per-table schemas, for hello validation (CRIT#4); set by the backend. */
41
+ clientTables;
42
+ /** Once true (set by {@link close}), in-flight lease resolutions are inert — they must not open a
43
+ * new transport or send after teardown. */
44
+ closed = false;
45
+ /** True once any lease has carried a routed `wsEndpoint`. Gates onDown re-leasing so a single
46
+ * UNROUTED daemon keeps its pre-router behavior (recover via reconnect→resync only), not an extra
47
+ * lease POST during an outage. */
48
+ sawRoutedEndpoint = false;
49
+ /** Bumped at the start of every re-subscribe-all pass. A migrate triggered mid-pass starts a new
50
+ * pass (higher generation); the outer pass then aborts instead of re-subscribing queries twice. */
51
+ resubscribeGen = 0;
52
+ constructor(connection, clientID, opts = {}) {
53
+ this.clientID = clientID;
54
+ this.resolveSubscribe = opts.resolveSubscribe ?? defaultSubscribeTarget;
55
+ this.pushMutationSender = opts.pushMutation;
56
+ if (isTransport(connection)) {
57
+ this.transportFactory = undefined;
58
+ this.bringUp(connection, undefined);
59
+ }
60
+ else if ("transport" in connection) {
61
+ this.transportFactory = undefined;
62
+ this.bringUp(connection.transport, undefined);
63
+ }
64
+ else {
65
+ this.transportFactory = connection.factory;
66
+ if (connection.endpoint !== undefined)
67
+ this.openEndpoint(connection.endpoint);
68
+ }
69
+ }
70
+ /** Wire a transport's handlers (no `init`). */
71
+ attach(transport) {
72
+ transport.onMessage((msg) => this.onServerMsg(msg));
73
+ // Heal a dropped/restarted connection (same endpoint): on reconnect, replay init + re-subscribe.
74
+ transport.onReconnect?.(() => this.resync());
75
+ // Sustained outage on this endpoint: re-lease (the router may move us off a dead follower, §3).
76
+ transport.onDown?.(() => this.onDown());
77
+ }
78
+ /** Make `transport` the current one, announce identity, and (re)subscribe anything deferred. */
79
+ bringUp(transport, endpoint) {
80
+ this.transport = transport;
81
+ this.currentEndpoint = endpoint;
82
+ this.attach(transport);
83
+ transport.send({ t: "init", clientID: this.clientID });
84
+ this.flushDeferred();
85
+ }
86
+ /** Build + bring up a fresh transport to `endpoint` (replaceable mode only). */
87
+ openEndpoint(endpoint) {
88
+ if (!this.transportFactory)
89
+ return;
90
+ this.bringUp(this.transportFactory(endpoint), endpoint);
91
+ }
92
+ /** Migrate the whole session to a new follower (§2.3): build the new transport, tear the old one
93
+ * down, and re-subscribe EVERY active query there (re-leasing — the old tokens are
94
+ * follower-local and invalid on the new node). */
95
+ migrate(endpoint) {
96
+ const old = this.transport;
97
+ this.openEndpoint(endpoint);
98
+ old?.close();
99
+ this.resubscribeAll();
100
+ }
101
+ /** Re-subscribe every live query on the current transport (each re-resolves its lease). A
102
+ * re-subscribe can synchronously trigger a `migrate` (lease names a new endpoint), whose own
103
+ * re-subscribe pass supersedes this one — the generation check then aborts this pass so a query
104
+ * is never re-subscribed (and re-leased) twice. */
105
+ resubscribeAll() {
106
+ const gen = ++this.resubscribeGen;
107
+ for (const [qid, s] of this.subs) {
108
+ if (this.resubscribeGen !== gen)
109
+ return; // a nested migrate/resubscribe took over — stop
110
+ s.subscriber = null;
111
+ s.resubscribing = true;
112
+ this.subscribe(qid, s.remote);
113
+ }
114
+ }
115
+ /** Flush subscribes deferred until a transport existed (e.g. the lmid query in pure-lazy mode). */
116
+ flushDeferred() {
117
+ if (this.deferred.size === 0)
118
+ return;
119
+ const qids = [...this.deferred];
120
+ this.deferred.clear();
121
+ for (const qid of qids) {
122
+ const s = this.subs.get(qid);
123
+ if (s)
124
+ this.subscribe(qid, s.remote);
125
+ }
126
+ }
127
+ /** The current follower's ws is sustainedly down — re-lease every query. The router returns a
128
+ * (possibly new) `wsEndpoint`: a changed one migrates the session; an unchanged one re-subscribes
129
+ * over the reconnecting transport (READ-ROUTER-DESIGN.md §3). No-op for an UNROUTED daemon (no
130
+ * lease ever carried a `wsEndpoint`) — there is nowhere to move, so we keep the pre-router
131
+ * behavior and let the transport's own reconnect→resync recover. */
132
+ onDown() {
133
+ if (this.closed || !this.sawRoutedEndpoint)
134
+ return;
135
+ this.resubscribeAll();
136
+ }
137
+ /** Tear down the current transport and make any in-flight lease resolution inert (a late lease
138
+ * must NOT open a new transport after the consumer closed the client). */
139
+ close() {
140
+ this.closed = true;
141
+ this.transport?.close();
142
+ this.subs.clear();
143
+ this.deferred.clear();
144
+ }
145
+ /** Register a handler fired when the DAEMON restarts (a new boot id) — the backend resets its
146
+ * `cv` watermark so the new daemon's reset `cv` sequence is accepted instead of dropped. */
147
+ onRestart(handler) {
148
+ this.restartHandler = handler;
149
+ }
150
+ expectClientSchema(tables) {
151
+ this.clientTables = tables;
152
+ }
153
+ registerQuery(qid, remote) {
154
+ this.subs.set(qid, { remote, subscriber: null, epoch: 0, resubscribing: false, subscribeTicket: 0 });
155
+ this.subscribe(qid, remote);
156
+ }
157
+ unregisterQuery(qid) {
158
+ this.subs.delete(qid);
159
+ this.deferred.delete(qid);
160
+ this.transport?.send({ t: "unsubscribe", queryId: qid });
161
+ }
162
+ pushMutation(envelope) {
163
+ if (this.pushMutationSender)
164
+ return Promise.resolve(this.pushMutationSender(envelope));
165
+ this.transport?.send({ t: "pushMutation", envelope });
166
+ return Promise.resolve();
167
+ }
168
+ onNormalized(handler) {
169
+ this.handler = handler;
170
+ }
171
+ onProgress(handler) {
172
+ this.progressHandler = handler;
173
+ }
174
+ // --- internals ---------------------------------------------------------------
175
+ subscribe(qid, remote) {
176
+ const s = this.subs.get(qid);
177
+ if (!s)
178
+ return;
179
+ const request = { queryId: qid, remote, mode: "normalized" };
180
+ const ticket = ++s.subscribeTicket;
181
+ const send = (target) => {
182
+ if (this.closed)
183
+ return; // a lease that resolved after close() must not (re)open anything
184
+ const cur = this.subs.get(qid);
185
+ if (cur !== s || cur.subscribeTicket !== ticket)
186
+ return;
187
+ const endpoint = "leaseToken" in target ? target.wsEndpoint : undefined;
188
+ if (endpoint !== undefined)
189
+ this.sawRoutedEndpoint = true;
190
+ if (this.transport) {
191
+ if (endpoint !== undefined && endpoint !== this.currentEndpoint && this.transportFactory) {
192
+ // The router placed this key on a DIFFERENT follower — migrate the whole session there.
193
+ // `migrate` re-subscribes every query (incl. this one) over the new transport, so return.
194
+ this.migrate(endpoint);
195
+ return;
196
+ }
197
+ this.transport.send(subscribeMessage(request, target));
198
+ return;
199
+ }
200
+ // No transport yet (pure-lazy): the first lease naming an endpoint opens it.
201
+ if (endpoint !== undefined && this.transportFactory) {
202
+ this.openEndpoint(endpoint);
203
+ this.transport.send(subscribeMessage(request, target));
204
+ return;
205
+ }
206
+ // Endpoint-less with no transport (the lmid system query before the first lease): defer until
207
+ // a transport comes up, then re-run this subscribe.
208
+ this.deferred.add(qid);
209
+ };
210
+ const fail = (err) => {
211
+ const cur = this.subs.get(qid);
212
+ if (cur !== s || cur.subscribeTicket !== ticket)
213
+ return;
214
+ s.resubscribing = false;
215
+ console.error(`[rindle-remote] optimistic query ${qid} subscribe resolution failed: ${String(err?.message ?? err)}`);
216
+ };
217
+ try {
218
+ // The reserved lmid system query is part of the optimistic WIRE contract, not an app
219
+ // query: the server resolves it from the connection's own `init` identity. It must
220
+ // never route through the app's subscribe resolver (the API server has no such named
221
+ // query, and a lease for it would be meaningless).
222
+ const target = remote.name === LMID_QUERY_NAME ? defaultSubscribeTarget(request) : this.resolveSubscribe(request);
223
+ if (isThenable(target))
224
+ void target.then(send, fail);
225
+ else
226
+ send(target);
227
+ }
228
+ catch (err) {
229
+ fail(err);
230
+ }
231
+ }
232
+ onServerMsg(msg) {
233
+ if (msg.t === "progress") {
234
+ this.progressHandler(msg.frame);
235
+ return;
236
+ }
237
+ if (msg.t === "queryError") {
238
+ this.subs.delete(msg.queryId);
239
+ console.error(`[rindle-remote] optimistic query ${msg.queryId} subscription rejected: ${msg.message}`);
240
+ return;
241
+ }
242
+ if (msg.t !== "nhello" && msg.t !== "nbatch")
243
+ return;
244
+ // Restart detection rides every nhello (connection-level) and runs BEFORE this query's
245
+ // snapshot buffers, so the backend's reset clears stale state ahead of the fresh hydrate.
246
+ if (msg.t === "nhello")
247
+ this.observeBootId(msg.bootId);
248
+ const s = this.subs.get(msg.queryId);
249
+ if (!s)
250
+ return; // unsubscribed / unknown query
251
+ if (msg.t === "nhello")
252
+ this.openSubscriber(msg.queryId, s, msg.hello);
253
+ else
254
+ this.applyBatch(msg.queryId, s, msg.batch);
255
+ }
256
+ /** On reconnect: re-announce identity and re-subscribe every live query (each re-resolves its
257
+ * lease, so a restarted daemon re-materializes + re-leases on the fly). */
258
+ resync() {
259
+ if (this.closed)
260
+ return;
261
+ this.transport?.send({ t: "init", clientID: this.clientID });
262
+ this.resubscribeAll();
263
+ }
264
+ /** Track the daemon's boot id; a change (after the first) means it restarted — fire onRestart. */
265
+ observeBootId(bootId) {
266
+ if (!bootId)
267
+ return;
268
+ if (this.lastBootId !== undefined && bootId !== this.lastBootId)
269
+ this.restartHandler();
270
+ this.lastBootId = bootId;
271
+ }
272
+ openSubscriber(qid, s, hello) {
273
+ try {
274
+ s.subscriber = new NormalizedSubscriber(hello, (ev) => this.handler(qid, ev), this.clientTables);
275
+ s.epoch = hello.epoch;
276
+ s.resubscribing = false;
277
+ }
278
+ catch (e) {
279
+ // A comparator/fp mismatch at hello is unrecoverable (a code-contract divergence).
280
+ s.subscriber = null;
281
+ console.error(`[rindle-remote] optimistic query ${qid} subscription rejected: ${e.message}`);
282
+ }
283
+ }
284
+ applyBatch(qid, s, batch) {
285
+ if (!s.subscriber)
286
+ return; // no hello yet (or mid re-hydrate)
287
+ if (batch.epoch < s.epoch)
288
+ return; // a stale batch from a superseded epoch — drop
289
+ try {
290
+ s.subscriber.apply(batch);
291
+ }
292
+ catch (e) {
293
+ if (!(e instanceof ProtocolError))
294
+ throw e;
295
+ if (s.resubscribing)
296
+ return; // already recovering
297
+ // Gap / drift → re-hydrate under a new epoch; the fresh snapshot arrives `cv`-stamped
298
+ // and releases (re-hydrating the footprint) at the server's accompanying progress frame.
299
+ s.resubscribing = true;
300
+ s.subscriber = null;
301
+ this.subscribe(qid, s.remote);
302
+ }
303
+ }
304
+ }
305
+ /** Convenience: a `RemoteOptimisticSource` over a ws URL or a custom transport. A URL becomes a
306
+ * replaceable connection seeded at that endpoint (so a routed lease can still migrate it); a
307
+ * pre-built transport stays fixed. */
308
+ export function createRemoteOptimisticSource(urlOrTransport, clientID, opts = {}) {
309
+ const connection = typeof urlOrTransport === "string"
310
+ ? { factory: (endpoint) => new WsTransport(endpoint), endpoint: urlOrTransport }
311
+ : urlOrTransport;
312
+ return new RemoteOptimisticSource(connection, clientID, opts);
313
+ }
314
+ function isTransport(value) {
315
+ return typeof value.send === "function" && typeof value.onMessage === "function";
316
+ }
317
+ //# sourceMappingURL=optimistic-source.js.map