@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.
- package/LICENSE +201 -0
- package/README.md +119 -0
- package/dist/backend.d.ts +31 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +136 -0
- package/dist/backend.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/mutation-queue.d.ts +26 -0
- package/dist/mutation-queue.d.ts.map +1 -0
- package/dist/mutation-queue.js +62 -0
- package/dist/mutation-queue.js.map +1 -0
- package/dist/normalized.d.ts +50 -0
- package/dist/normalized.d.ts.map +1 -0
- package/dist/normalized.js +146 -0
- package/dist/normalized.js.map +1 -0
- package/dist/optimistic-source.d.ts +108 -0
- package/dist/optimistic-source.d.ts.map +1 -0
- package/dist/optimistic-source.js +317 -0
- package/dist/optimistic-source.js.map +1 -0
- package/dist/protocol.d.ts +134 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +178 -0
- package/dist/protocol.js.map +1 -0
- package/dist/remote-source.d.ts +31 -0
- package/dist/remote-source.d.ts.map +1 -0
- package/dist/remote-source.js +133 -0
- package/dist/remote-source.js.map +1 -0
- package/dist/subscribe.d.ts +26 -0
- package/dist/subscribe.d.ts.map +1 -0
- package/dist/subscribe.js +14 -0
- package/dist/subscribe.js.map +1 -0
- package/dist/transport.d.ts +53 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +105 -0
- package/dist/transport.js.map +1 -0
- package/package.json +39 -0
- package/src/backend.ts +163 -0
- package/src/index.ts +40 -0
- package/src/mutation-queue.ts +96 -0
- package/src/normalized.ts +188 -0
- package/src/optimistic-source.ts +374 -0
- package/src/protocol.ts +252 -0
- package/src/remote-source.ts +171 -0
- package/src/subscribe.ts +40 -0
- 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
|