@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,171 @@
1
+ // RemoteNormalizedSource: a `NormalizedSource` over a network transport — the ws sibling of
2
+ // the in-process native source (NORMALIZED-CHANGES-DESIGN.md §7). It subscribes in normalized
3
+ // mode, owns the epoch/seq/gap protocol (via {@link NormalizedSubscriber}), and emits clean
4
+ // `NormalizedEvent`s upward — so `@rindle/normalized`'s `NormalizedBackend` drives the local
5
+ // engine identically whether the footprint stream comes from in-process or over the wire.
6
+ //
7
+ // On a gap (or epoch/fp drift) it re-subscribes; the server re-registers the query under a NEW
8
+ // epoch and replies with a fresh hello + snapshot, and `NormalizedSync` diffs the new footprint
9
+ // against the old (the set-analogue re-hydrate, §5.3).
10
+
11
+ import type {
12
+ Mutation,
13
+ NormalizedEvent,
14
+ NormalizedSource,
15
+ NormalizedTableSchema,
16
+ QueryId,
17
+ RemoteQuery,
18
+ } from "@rindle/client";
19
+
20
+ import { NormalizedSubscriber } from "./normalized.ts";
21
+ import type { NormalizedBatch, NormalizedHello } from "./normalized.ts";
22
+ import { ProtocolError } from "./protocol.ts";
23
+ import type { ServerMsg } from "./protocol.ts";
24
+ import {
25
+ defaultSubscribeTarget,
26
+ isThenable,
27
+ subscribeMessage,
28
+ type RawMutationSender,
29
+ type SubscribeResolver,
30
+ type SubscribeTarget,
31
+ } from "./subscribe.ts";
32
+ import { WsTransport } from "./transport.ts";
33
+ import type { Transport } from "./transport.ts";
34
+
35
+ interface QState {
36
+ remote: RemoteQuery;
37
+ subscriber: NormalizedSubscriber | null;
38
+ /** The epoch of the current subscription (0 before the first hello). */
39
+ epoch: number;
40
+ /** True between sending a re-subscribe and receiving its hello (so a second gap is ignored). */
41
+ resubscribing: boolean;
42
+ /** Monotonic token that cancels stale async lease resolutions. */
43
+ subscribeTicket: number;
44
+ }
45
+
46
+ export interface RemoteNormalizedSourceOptions {
47
+ /** Resolve the upstream subscribe target. Defaults to embedded-server `{name,args}`. */
48
+ resolveSubscribe?: SubscribeResolver;
49
+ /** Override raw authoritative writes, e.g. POST them to an app API server. */
50
+ sendMutation?: RawMutationSender;
51
+ }
52
+
53
+ export class RemoteNormalizedSource implements NormalizedSource {
54
+ private readonly transport: Transport;
55
+ private readonly resolveSubscribe: SubscribeResolver;
56
+ private readonly sendMutation?: RawMutationSender;
57
+ private handler: (qid: QueryId, ev: NormalizedEvent) => void = () => {};
58
+ private readonly subs = new Map<QueryId, QState>();
59
+ /** The client's own typed per-table schemas, for hello validation (CRIT#4); set by the backend. */
60
+ private clientTables: NormalizedTableSchema[] | undefined;
61
+
62
+ constructor(transport: Transport, opts: RemoteNormalizedSourceOptions = {}) {
63
+ this.transport = transport;
64
+ this.resolveSubscribe = opts.resolveSubscribe ?? defaultSubscribeTarget;
65
+ this.sendMutation = opts.sendMutation;
66
+ this.transport.onMessage((msg) => this.onServerMsg(msg));
67
+ }
68
+
69
+ expectClientSchema(tables: NormalizedTableSchema[]): void {
70
+ this.clientTables = tables;
71
+ }
72
+
73
+ registerQuery(qid: QueryId, remote: RemoteQuery): void {
74
+ this.subs.set(qid, { remote, subscriber: null, epoch: 0, resubscribing: false, subscribeTicket: 0 });
75
+ this.subscribe(qid, remote);
76
+ }
77
+
78
+ unregisterQuery(qid: QueryId): void {
79
+ this.subs.delete(qid);
80
+ this.transport.send({ t: "unsubscribe", queryId: qid });
81
+ }
82
+
83
+ mutate(mutations: Mutation[]): Promise<void> {
84
+ if (this.sendMutation) return Promise.resolve(this.sendMutation(mutations));
85
+ this.transport.send({ t: "mutate", mutations });
86
+ return Promise.resolve();
87
+ }
88
+
89
+ onNormalized(handler: (qid: QueryId, ev: NormalizedEvent) => void): void {
90
+ this.handler = handler;
91
+ }
92
+
93
+ // --- internals ---------------------------------------------------------------
94
+
95
+ private subscribe(qid: QueryId, remote: RemoteQuery): void {
96
+ const s = this.subs.get(qid);
97
+ if (!s) return;
98
+ const request = { queryId: qid, remote, mode: "normalized" as const };
99
+ const ticket = ++s.subscribeTicket;
100
+ const send = (target: SubscribeTarget) => {
101
+ const cur = this.subs.get(qid);
102
+ if (cur !== s || cur.subscribeTicket !== ticket) return;
103
+ this.transport.send(subscribeMessage(request, target));
104
+ };
105
+ const fail = (err: unknown) => {
106
+ const cur = this.subs.get(qid);
107
+ if (cur !== s || cur.subscribeTicket !== ticket) return;
108
+ s.resubscribing = false;
109
+ console.error(
110
+ `[rindle-remote] normalized query ${qid} subscribe resolution failed: ${String((err as Error)?.message ?? err)}`,
111
+ );
112
+ };
113
+ try {
114
+ const target = this.resolveSubscribe(request);
115
+ if (isThenable(target)) void target.then(send, fail);
116
+ else send(target);
117
+ } catch (err) {
118
+ fail(err);
119
+ }
120
+ }
121
+
122
+ private onServerMsg(msg: ServerMsg): void {
123
+ if (msg.t === "queryError") {
124
+ this.subs.delete(msg.queryId);
125
+ console.error(`[rindle-remote] normalized query ${msg.queryId} subscription rejected: ${msg.message}`);
126
+ return;
127
+ }
128
+ // This source is normalized-only; it sees `nhello`/`nbatch` (flat frames are ignored).
129
+ if (msg.t !== "nhello" && msg.t !== "nbatch") return;
130
+ const s = this.subs.get(msg.queryId);
131
+ if (!s) return; // unsubscribed / unknown query
132
+ if (msg.t === "nhello") this.openSubscriber(msg.queryId, s, msg.hello);
133
+ else this.applyBatch(msg.queryId, s, msg.batch);
134
+ }
135
+
136
+ private openSubscriber(qid: QueryId, s: QState, hello: NormalizedHello): void {
137
+ try {
138
+ s.subscriber = new NormalizedSubscriber(hello, (ev) => this.handler(qid, ev), this.clientTables);
139
+ s.epoch = hello.epoch;
140
+ s.resubscribing = false;
141
+ } catch (e) {
142
+ // A comparator/fp mismatch at hello is unrecoverable (a code-contract divergence).
143
+ s.subscriber = null;
144
+ console.error(`[rindle-remote] normalized query ${qid} subscription rejected: ${(e as Error).message}`);
145
+ }
146
+ }
147
+
148
+ private applyBatch(qid: QueryId, s: QState, batch: NormalizedBatch): void {
149
+ if (!s.subscriber) return; // no hello yet (or mid re-hydrate)
150
+ if (batch.epoch < s.epoch) return; // a stale batch from a superseded epoch — drop
151
+ try {
152
+ s.subscriber.apply(batch);
153
+ } catch (e) {
154
+ if (!(e instanceof ProtocolError)) throw e;
155
+ if (s.resubscribing) return; // already recovering
156
+ // Gap / drift → re-hydrate under a new epoch (the server bumps it on re-subscribe).
157
+ s.resubscribing = true;
158
+ s.subscriber = null;
159
+ this.subscribe(qid, s.remote);
160
+ }
161
+ }
162
+ }
163
+
164
+ /** Convenience: a `RemoteNormalizedSource` over a ws URL or a custom transport. */
165
+ export function createRemoteNormalizedSource(
166
+ urlOrTransport: string | Transport,
167
+ opts: RemoteNormalizedSourceOptions = {},
168
+ ): RemoteNormalizedSource {
169
+ const transport = typeof urlOrTransport === "string" ? new WsTransport(urlOrTransport) : urlOrTransport;
170
+ return new RemoteNormalizedSource(transport, opts);
171
+ }
@@ -0,0 +1,40 @@
1
+ import type { Mutation, MutationEnvelope, QueryId, RemoteQuery } from "@rindle/client";
2
+
3
+ import type { SubscribeClientMsg } from "./protocol.ts";
4
+
5
+ export type SubscribeMode = "flat" | "normalized";
6
+
7
+ export interface SubscribeRequest {
8
+ queryId: QueryId;
9
+ remote: RemoteQuery;
10
+ mode: SubscribeMode;
11
+ }
12
+
13
+ export type SubscribeTarget =
14
+ | { name: string; args: unknown; leaseToken?: never; wsEndpoint?: never }
15
+ // A lease may carry the follower's public ws endpoint (READ-ROUTER-DESIGN.md §2.3): the source
16
+ // connects (or migrates) its transport there before subscribing. Client-side routing only — it is
17
+ // NOT put on the wire `subscribe` frame.
18
+ | { leaseToken: string; wsEndpoint?: string; name?: never; args?: never };
19
+
20
+ export type SubscribeResolver = (request: SubscribeRequest) => SubscribeTarget | PromiseLike<SubscribeTarget>;
21
+
22
+ export type RawMutationSender = (mutations: Mutation[]) => void | PromiseLike<void>;
23
+
24
+ export type MutationEnvelopeSender = (envelope: MutationEnvelope) => void | PromiseLike<void>;
25
+
26
+ export function defaultSubscribeTarget(request: SubscribeRequest): SubscribeTarget {
27
+ return { name: request.remote.name, args: request.remote.args };
28
+ }
29
+
30
+ export function subscribeMessage(request: SubscribeRequest, target: SubscribeTarget): SubscribeClientMsg {
31
+ const mode = request.mode === "normalized" ? { mode: request.mode } : {};
32
+ if (target.leaseToken !== undefined) {
33
+ return { t: "subscribe", queryId: request.queryId, leaseToken: target.leaseToken, ...mode };
34
+ }
35
+ return { t: "subscribe", queryId: request.queryId, name: target.name, args: target.args, ...mode };
36
+ }
37
+
38
+ export function isThenable<T>(value: T | PromiseLike<T>): value is PromiseLike<T> {
39
+ return value != null && typeof (value as { then?: unknown }).then === "function";
40
+ }
@@ -0,0 +1,127 @@
1
+ // The transport seam: the RemoteBackend talks JSON messages through a `Transport`, so the
2
+ // wire (ws / sse / http) is swappable + mockable. The default {@link WsTransport} uses the
3
+ // global `WebSocket` (Node 22+ and browsers both provide it — zero dependency).
4
+
5
+ import type { ClientMsg, ServerMsg } from "./protocol.ts";
6
+
7
+ export interface Transport {
8
+ /** Send a message up to the server. */
9
+ send(msg: ClientMsg): void;
10
+ /** Register the single handler for incoming server messages. */
11
+ onMessage(handler: (msg: ServerMsg) => void): void;
12
+ /** Register a handler fired after the connection is RE-established (not the first open) — the
13
+ * source uses it to re-`init` + re-subscribe so a dropped/restarted daemon heals. Optional:
14
+ * transports without reconnect (mocks, in-process) may omit it. */
15
+ onReconnect?(handler: () => void): void;
16
+ /** Register a handler fired when the connection is SUSTAINEDLY down — repeated reconnects to the
17
+ * same endpoint have failed (a dead/removed follower, READ-ROUTER-DESIGN.md §3). The source uses
18
+ * it to re-lease: the router returns a (possibly new) `wsEndpoint`, and a changed one migrates the
19
+ * whole session off the dead node. Optional: transports without failover (mocks, in-process,
20
+ * fixed endpoints) may omit it. */
21
+ onDown?(handler: () => void): void;
22
+ /** Tear down the connection. */
23
+ close(): void;
24
+ }
25
+
26
+ /** A `Transport` over a `WebSocket` (text JSON frames). Messages sent before the socket
27
+ * opens are buffered and flushed on open (so `registerQuery`/`mutate` can be called eagerly).
28
+ * Reconnects with capped exponential backoff: if the socket drops (e.g. the daemon restarted)
29
+ * it reopens and fires `onReconnect` so the source rebuilds its subscriptions. */
30
+ export class WsTransport implements Transport {
31
+ private readonly url: string;
32
+ private ws: WebSocket;
33
+ private handler: (msg: ServerMsg) => void = () => {};
34
+ private reconnectHandler: () => void = () => {};
35
+ private downHandler: () => void = () => {};
36
+ private readonly pending: ClientMsg[] = [];
37
+ private open = false;
38
+ private everOpened = false;
39
+ private closedByUser = false;
40
+ private attempt = 0;
41
+ private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
42
+ /** Failed reconnect attempts after which the connection is declared "down" (fires `onDown`). */
43
+ private readonly downThreshold: number;
44
+ /** True once `onDown` has fired for the CURRENT down episode; reset on the next successful open
45
+ * so a later outage fires again (but a single episode fires `onDown` exactly once — no re-lease
46
+ * storm while a follower is gone). */
47
+ private downFired = false;
48
+
49
+ constructor(url: string, opts: { downThreshold?: number } = {}) {
50
+ this.url = url;
51
+ this.downThreshold = opts.downThreshold ?? 4;
52
+ this.ws = this.connect();
53
+ }
54
+
55
+ private connect(): WebSocket {
56
+ const ws = new WebSocket(this.url);
57
+ ws.addEventListener("open", () => {
58
+ this.open = true;
59
+ this.attempt = 0;
60
+ this.downFired = false; // a fresh connection clears the down episode
61
+ if (!this.everOpened) {
62
+ // First connection: flush whatever was buffered eagerly (init + subscribes).
63
+ this.everOpened = true;
64
+ for (const m of this.pending) ws.send(JSON.stringify(m));
65
+ this.pending.length = 0;
66
+ } else {
67
+ // A reconnect: drop any stale buffered frames — the source rebuilds the full desired
68
+ // state (re-init + re-subscribe, re-leasing as it goes) in onReconnect.
69
+ this.pending.length = 0;
70
+ this.reconnectHandler();
71
+ }
72
+ });
73
+ ws.addEventListener("message", (ev: MessageEvent) => {
74
+ this.handler(JSON.parse(typeof ev.data === "string" ? ev.data : String(ev.data)) as ServerMsg);
75
+ });
76
+ ws.addEventListener("close", () => {
77
+ this.open = false;
78
+ if (!this.closedByUser) this.scheduleReconnect();
79
+ });
80
+ // `error` is followed by `close`; let the close handler own reconnection.
81
+ ws.addEventListener("error", () => {});
82
+ return ws;
83
+ }
84
+
85
+ private scheduleReconnect(): void {
86
+ if (this.reconnectTimer !== undefined) return;
87
+ const delay = Math.min(250 * 2 ** this.attempt, 5000);
88
+ this.attempt++;
89
+ // Sustained failure (we had a connection, and several reconnects to this endpoint have failed):
90
+ // declare the connection down ONCE so the source can re-lease and migrate (§3). We keep
91
+ // reconnecting underneath in case the same follower returns (a reboot, same endpoint).
92
+ if (this.everOpened && !this.downFired && this.attempt >= this.downThreshold) {
93
+ this.downFired = true;
94
+ this.downHandler();
95
+ }
96
+ this.reconnectTimer = setTimeout(() => {
97
+ this.reconnectTimer = undefined;
98
+ if (!this.closedByUser) this.ws = this.connect();
99
+ }, delay);
100
+ }
101
+
102
+ send(msg: ClientMsg): void {
103
+ if (this.open) this.ws.send(JSON.stringify(msg));
104
+ else this.pending.push(msg);
105
+ }
106
+
107
+ onMessage(handler: (msg: ServerMsg) => void): void {
108
+ this.handler = handler;
109
+ }
110
+
111
+ onReconnect(handler: () => void): void {
112
+ this.reconnectHandler = handler;
113
+ }
114
+
115
+ onDown(handler: () => void): void {
116
+ this.downHandler = handler;
117
+ }
118
+
119
+ close(): void {
120
+ this.closedByUser = true;
121
+ if (this.reconnectTimer !== undefined) {
122
+ clearTimeout(this.reconnectTimer);
123
+ this.reconnectTimer = undefined;
124
+ }
125
+ this.ws.close();
126
+ }
127
+ }