@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,105 @@
|
|
|
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
|
+
/** A `Transport` over a `WebSocket` (text JSON frames). Messages sent before the socket
|
|
5
|
+
* opens are buffered and flushed on open (so `registerQuery`/`mutate` can be called eagerly).
|
|
6
|
+
* Reconnects with capped exponential backoff: if the socket drops (e.g. the daemon restarted)
|
|
7
|
+
* it reopens and fires `onReconnect` so the source rebuilds its subscriptions. */
|
|
8
|
+
export class WsTransport {
|
|
9
|
+
url;
|
|
10
|
+
ws;
|
|
11
|
+
handler = () => { };
|
|
12
|
+
reconnectHandler = () => { };
|
|
13
|
+
downHandler = () => { };
|
|
14
|
+
pending = [];
|
|
15
|
+
open = false;
|
|
16
|
+
everOpened = false;
|
|
17
|
+
closedByUser = false;
|
|
18
|
+
attempt = 0;
|
|
19
|
+
reconnectTimer;
|
|
20
|
+
/** Failed reconnect attempts after which the connection is declared "down" (fires `onDown`). */
|
|
21
|
+
downThreshold;
|
|
22
|
+
/** True once `onDown` has fired for the CURRENT down episode; reset on the next successful open
|
|
23
|
+
* so a later outage fires again (but a single episode fires `onDown` exactly once — no re-lease
|
|
24
|
+
* storm while a follower is gone). */
|
|
25
|
+
downFired = false;
|
|
26
|
+
constructor(url, opts = {}) {
|
|
27
|
+
this.url = url;
|
|
28
|
+
this.downThreshold = opts.downThreshold ?? 4;
|
|
29
|
+
this.ws = this.connect();
|
|
30
|
+
}
|
|
31
|
+
connect() {
|
|
32
|
+
const ws = new WebSocket(this.url);
|
|
33
|
+
ws.addEventListener("open", () => {
|
|
34
|
+
this.open = true;
|
|
35
|
+
this.attempt = 0;
|
|
36
|
+
this.downFired = false; // a fresh connection clears the down episode
|
|
37
|
+
if (!this.everOpened) {
|
|
38
|
+
// First connection: flush whatever was buffered eagerly (init + subscribes).
|
|
39
|
+
this.everOpened = true;
|
|
40
|
+
for (const m of this.pending)
|
|
41
|
+
ws.send(JSON.stringify(m));
|
|
42
|
+
this.pending.length = 0;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
// A reconnect: drop any stale buffered frames — the source rebuilds the full desired
|
|
46
|
+
// state (re-init + re-subscribe, re-leasing as it goes) in onReconnect.
|
|
47
|
+
this.pending.length = 0;
|
|
48
|
+
this.reconnectHandler();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
ws.addEventListener("message", (ev) => {
|
|
52
|
+
this.handler(JSON.parse(typeof ev.data === "string" ? ev.data : String(ev.data)));
|
|
53
|
+
});
|
|
54
|
+
ws.addEventListener("close", () => {
|
|
55
|
+
this.open = false;
|
|
56
|
+
if (!this.closedByUser)
|
|
57
|
+
this.scheduleReconnect();
|
|
58
|
+
});
|
|
59
|
+
// `error` is followed by `close`; let the close handler own reconnection.
|
|
60
|
+
ws.addEventListener("error", () => { });
|
|
61
|
+
return ws;
|
|
62
|
+
}
|
|
63
|
+
scheduleReconnect() {
|
|
64
|
+
if (this.reconnectTimer !== undefined)
|
|
65
|
+
return;
|
|
66
|
+
const delay = Math.min(250 * 2 ** this.attempt, 5000);
|
|
67
|
+
this.attempt++;
|
|
68
|
+
// Sustained failure (we had a connection, and several reconnects to this endpoint have failed):
|
|
69
|
+
// declare the connection down ONCE so the source can re-lease and migrate (§3). We keep
|
|
70
|
+
// reconnecting underneath in case the same follower returns (a reboot, same endpoint).
|
|
71
|
+
if (this.everOpened && !this.downFired && this.attempt >= this.downThreshold) {
|
|
72
|
+
this.downFired = true;
|
|
73
|
+
this.downHandler();
|
|
74
|
+
}
|
|
75
|
+
this.reconnectTimer = setTimeout(() => {
|
|
76
|
+
this.reconnectTimer = undefined;
|
|
77
|
+
if (!this.closedByUser)
|
|
78
|
+
this.ws = this.connect();
|
|
79
|
+
}, delay);
|
|
80
|
+
}
|
|
81
|
+
send(msg) {
|
|
82
|
+
if (this.open)
|
|
83
|
+
this.ws.send(JSON.stringify(msg));
|
|
84
|
+
else
|
|
85
|
+
this.pending.push(msg);
|
|
86
|
+
}
|
|
87
|
+
onMessage(handler) {
|
|
88
|
+
this.handler = handler;
|
|
89
|
+
}
|
|
90
|
+
onReconnect(handler) {
|
|
91
|
+
this.reconnectHandler = handler;
|
|
92
|
+
}
|
|
93
|
+
onDown(handler) {
|
|
94
|
+
this.downHandler = handler;
|
|
95
|
+
}
|
|
96
|
+
close() {
|
|
97
|
+
this.closedByUser = true;
|
|
98
|
+
if (this.reconnectTimer !== undefined) {
|
|
99
|
+
clearTimeout(this.reconnectTimer);
|
|
100
|
+
this.reconnectTimer = undefined;
|
|
101
|
+
}
|
|
102
|
+
this.ws.close();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=transport.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transport.js","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rindle/remote",
|
|
3
|
+
"version": "0.1.0-rc.5",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/rindle-sh/rindle.git",
|
|
8
|
+
"directory": "packages/remote"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"description": "Network backend for @rindle/client: RemoteBackend over a transport + the wire protocol (Publisher/Subscriber/schemaFp).",
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"@rindle/source": "./src/index.ts",
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@rindle/client": "0.1.0-rc.5"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^22.10.0",
|
|
32
|
+
"typescript": "^5.7.0"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc -p tsconfig.build.json",
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"test": "tsc --noEmit && node --conditions=@rindle/source --test test/*.test.ts"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/backend.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// The RemoteBackend: a `@rindle/client` `Backend` over a network transport. It owns the
|
|
2
|
+
// epoch/seq/gap protocol (via {@link Subscriber}) and emits only CLEAN, in-order
|
|
3
|
+
// `hello`/`snapshot`/`batch` `ChangeEvent`s upward — so the same `Store`/`ArrayView` that
|
|
4
|
+
// drives the local backends drives this one, never seeing seq/epoch (WASM-CLIENT-DESIGN.md §2.2).
|
|
5
|
+
//
|
|
6
|
+
// On a gap (or epoch/schema drift) the backend re-hydrates INTERNALLY: it re-subscribes, the
|
|
7
|
+
// server re-registers the query under a NEW epoch and replies with a fresh hello + snapshot,
|
|
8
|
+
// and the `ArrayView` resets in place — the caller's materialized view reference survives.
|
|
9
|
+
|
|
10
|
+
import { Store } from "@rindle/client";
|
|
11
|
+
import type { Backend, ChangeEvent, ColsMap, Mutation, QueryId, RemoteQuery, Schema } from "@rindle/client";
|
|
12
|
+
|
|
13
|
+
import { ProtocolError, Subscriber } from "./protocol.ts";
|
|
14
|
+
import type { Batch, Hello, ServerMsg } from "./protocol.ts";
|
|
15
|
+
import {
|
|
16
|
+
defaultSubscribeTarget,
|
|
17
|
+
isThenable,
|
|
18
|
+
subscribeMessage,
|
|
19
|
+
type RawMutationSender,
|
|
20
|
+
type SubscribeResolver,
|
|
21
|
+
} from "./subscribe.ts";
|
|
22
|
+
import { WsTransport } from "./transport.ts";
|
|
23
|
+
import type { Transport } from "./transport.ts";
|
|
24
|
+
|
|
25
|
+
interface QState {
|
|
26
|
+
remote: RemoteQuery;
|
|
27
|
+
subscriber: Subscriber | null;
|
|
28
|
+
/** The epoch of the current subscription (0 before the first hello). */
|
|
29
|
+
epoch: number;
|
|
30
|
+
/** True between sending a re-subscribe and receiving its hello (so a second gap is ignored). */
|
|
31
|
+
resubscribing: boolean;
|
|
32
|
+
/** Monotonic token that cancels stale async lease resolutions. */
|
|
33
|
+
subscribeTicket: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RemoteBackendOptions {
|
|
37
|
+
/** Resolve the upstream subscribe target. Defaults to embedded-server `{name,args}`. */
|
|
38
|
+
resolveSubscribe?: SubscribeResolver;
|
|
39
|
+
/** Override raw authoritative writes, e.g. POST them to an app API server. */
|
|
40
|
+
sendMutation?: RawMutationSender;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class RemoteBackend implements Backend {
|
|
44
|
+
private readonly transport: Transport;
|
|
45
|
+
private readonly resolveSubscribe: SubscribeResolver;
|
|
46
|
+
private readonly sendMutation?: RawMutationSender;
|
|
47
|
+
private handler: (qid: QueryId, ev: ChangeEvent) => void = () => {};
|
|
48
|
+
private readonly subs = new Map<QueryId, QState>();
|
|
49
|
+
|
|
50
|
+
constructor(transport: Transport, opts: RemoteBackendOptions = {}) {
|
|
51
|
+
this.transport = transport;
|
|
52
|
+
this.resolveSubscribe = opts.resolveSubscribe ?? defaultSubscribeTarget;
|
|
53
|
+
this.sendMutation = opts.sendMutation;
|
|
54
|
+
this.transport.onMessage((msg) => this.onServerMsg(msg));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
registerQuery(qid: QueryId, _ast: unknown, remote?: RemoteQuery): void {
|
|
58
|
+
if (!remote) {
|
|
59
|
+
console.error(`[rindle-remote] flat query ${qid} has no named remote identity`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
this.subs.set(qid, { remote, subscriber: null, epoch: 0, resubscribing: false, subscribeTicket: 0 });
|
|
63
|
+
this.subscribe(qid, remote);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
unregisterQuery(qid: QueryId): void {
|
|
67
|
+
this.subs.delete(qid);
|
|
68
|
+
this.transport.send({ t: "unsubscribe", queryId: qid });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Eventually-consistent: the mutation is sent; the resulting batches arrive async on the
|
|
72
|
+
* stream. The promise resolves once sent (the §2.3 write asymmetry, named not leaked). */
|
|
73
|
+
mutate(mutations: Mutation[]): Promise<void> {
|
|
74
|
+
if (this.sendMutation) return Promise.resolve(this.sendMutation(mutations));
|
|
75
|
+
this.transport.send({ t: "mutate", mutations });
|
|
76
|
+
return Promise.resolve();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
onEvent(handler: (qid: QueryId, ev: ChangeEvent) => void): void {
|
|
80
|
+
this.handler = handler;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- internals ---------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
private subscribe(qid: QueryId, remote: RemoteQuery): void {
|
|
86
|
+
const s = this.subs.get(qid);
|
|
87
|
+
if (!s) return;
|
|
88
|
+
const request = { queryId: qid, remote, mode: "flat" as const };
|
|
89
|
+
const ticket = ++s.subscribeTicket;
|
|
90
|
+
const send = (target: ReturnType<typeof defaultSubscribeTarget>) => {
|
|
91
|
+
const cur = this.subs.get(qid);
|
|
92
|
+
if (cur !== s || cur.subscribeTicket !== ticket) return;
|
|
93
|
+
this.transport.send(subscribeMessage(request, target));
|
|
94
|
+
};
|
|
95
|
+
const fail = (err: unknown) => {
|
|
96
|
+
const cur = this.subs.get(qid);
|
|
97
|
+
if (cur !== s || cur.subscribeTicket !== ticket) return;
|
|
98
|
+
s.resubscribing = false;
|
|
99
|
+
console.error(`[rindle-remote] query ${qid} subscribe resolution failed: ${String((err as Error)?.message ?? err)}`);
|
|
100
|
+
};
|
|
101
|
+
try {
|
|
102
|
+
const target = this.resolveSubscribe(request);
|
|
103
|
+
if (isThenable(target)) void target.then(send, fail);
|
|
104
|
+
else send(target);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
fail(err);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private onServerMsg(msg: ServerMsg): void {
|
|
111
|
+
if (msg.t === "queryError") {
|
|
112
|
+
this.subs.delete(msg.queryId);
|
|
113
|
+
console.error(`[rindle-remote] query ${msg.queryId} subscription rejected: ${msg.message}`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// This backend is flat-only; it ignores normalized frames (`nhello`/`nbatch`).
|
|
117
|
+
if (msg.t !== "hello" && msg.t !== "batch") return;
|
|
118
|
+
const s = this.subs.get(msg.queryId);
|
|
119
|
+
if (!s) return; // unsubscribed / unknown query
|
|
120
|
+
if (msg.t === "hello") this.openSubscriber(msg.queryId, s, msg.hello);
|
|
121
|
+
else this.applyBatch(msg.queryId, s, msg.batch);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private openSubscriber(qid: QueryId, s: QState, hello: Hello): void {
|
|
125
|
+
try {
|
|
126
|
+
// The Subscriber ctor validates the comparator + fingerprint and emits the `hello`
|
|
127
|
+
// ChangeEvent (→ the Store resets the view to this schema).
|
|
128
|
+
s.subscriber = new Subscriber(hello, (ev) => this.handler(qid, ev));
|
|
129
|
+
s.epoch = hello.epoch;
|
|
130
|
+
s.resubscribing = false;
|
|
131
|
+
} catch (e) {
|
|
132
|
+
// A comparator/schema mismatch at hello is unrecoverable (a code-contract divergence) —
|
|
133
|
+
// leave the view pending and report it; do not loop.
|
|
134
|
+
s.subscriber = null;
|
|
135
|
+
console.error(`[rindle-remote] query ${qid} subscription rejected: ${(e as Error).message}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private applyBatch(qid: QueryId, s: QState, batch: Batch): void {
|
|
140
|
+
if (!s.subscriber) return; // no hello yet (or mid re-hydrate)
|
|
141
|
+
if (batch.epoch < s.epoch) return; // a stale batch from a superseded epoch — drop
|
|
142
|
+
try {
|
|
143
|
+
s.subscriber.apply(batch);
|
|
144
|
+
} catch (e) {
|
|
145
|
+
if (!(e instanceof ProtocolError)) throw e;
|
|
146
|
+
if (s.resubscribing) return; // already recovering
|
|
147
|
+
// Gap / drift → re-hydrate under a new epoch (the server bumps it on re-subscribe).
|
|
148
|
+
s.resubscribing = true;
|
|
149
|
+
s.subscriber = null;
|
|
150
|
+
this.subscribe(qid, s.remote);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Convenience: a `Store` backed by a remote server, over a ws URL or a custom transport. */
|
|
156
|
+
export function createRemoteStore<S extends ColsMap>(
|
|
157
|
+
schema: Schema<S>,
|
|
158
|
+
urlOrTransport: string | Transport,
|
|
159
|
+
opts: RemoteBackendOptions = {},
|
|
160
|
+
): Store<S> {
|
|
161
|
+
const transport = typeof urlOrTransport === "string" ? new WsTransport(urlOrTransport) : urlOrTransport;
|
|
162
|
+
return new Store(schema, new RemoteBackend(transport, opts));
|
|
163
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// @rindle/remote — the network backend for @rindle/client: a `RemoteBackend` over a transport,
|
|
2
|
+
// driving the SAME Store/ArrayView as the local (wasm/replica) backends. Also exports the
|
|
3
|
+
// wire protocol (frames + `Publisher`/`Subscriber` + `schemaFp`) a server uses to talk to it.
|
|
4
|
+
// Re-exports @rindle/client so an app can import everything from here.
|
|
5
|
+
|
|
6
|
+
export * from "@rindle/client";
|
|
7
|
+
|
|
8
|
+
export { RemoteBackend, createRemoteStore } from "./backend.ts";
|
|
9
|
+
export type { RemoteBackendOptions } from "./backend.ts";
|
|
10
|
+
export { WsTransport } from "./transport.ts";
|
|
11
|
+
export type { Transport } from "./transport.ts";
|
|
12
|
+
export type {
|
|
13
|
+
MutationEnvelopeSender,
|
|
14
|
+
RawMutationSender,
|
|
15
|
+
SubscribeMode,
|
|
16
|
+
SubscribeRequest,
|
|
17
|
+
SubscribeResolver,
|
|
18
|
+
SubscribeTarget,
|
|
19
|
+
} from "./subscribe.ts";
|
|
20
|
+
|
|
21
|
+
export { COMPARATOR_VERSION, Publisher, ProtocolError, Subscriber, schemaFp } from "./protocol.ts";
|
|
22
|
+
export type { Batch, ClientMsg, Hello, ProtocolErrorKind, ServerMsg, SubscribeClientMsg } from "./protocol.ts";
|
|
23
|
+
|
|
24
|
+
// The normalized subscription protocol (the path-free twin) + the client-side source.
|
|
25
|
+
export { NormalizedSubscriber, normalizedFp } from "./normalized.ts";
|
|
26
|
+
export type { NormalizedBatch, NormalizedHello } from "./normalized.ts";
|
|
27
|
+
export { RemoteNormalizedSource, createRemoteNormalizedSource } from "./remote-source.ts";
|
|
28
|
+
export type { RemoteNormalizedSourceOptions } from "./remote-source.ts";
|
|
29
|
+
|
|
30
|
+
// The optimistic-writes source (the normalized stream + mutation push + progress frames).
|
|
31
|
+
export { RemoteOptimisticSource, createRemoteOptimisticSource } from "./optimistic-source.ts";
|
|
32
|
+
export type {
|
|
33
|
+
RemoteOptimisticConnection,
|
|
34
|
+
RemoteOptimisticSourceOptions,
|
|
35
|
+
TransportFactory,
|
|
36
|
+
} from "./optimistic-source.ts";
|
|
37
|
+
|
|
38
|
+
// The serverless mutation queue: confirmed, in-order batches over an unordered HTTP hop.
|
|
39
|
+
export { createQueuedMutationSender } from "./mutation-queue.ts";
|
|
40
|
+
export type { PushOutcome, QueuedMutationSenderOptions } from "./mutation-queue.ts";
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The client-side mutation queue for daemon/serverless deployments. The optimistic engine
|
|
2
|
+
// requires mids to arrive at the daemon CONTIGUOUSLY (`mid == lmid + 1`); a websocket gives
|
|
3
|
+
// that ordering for free, but the serverless hop (client → API server over HTTP → daemon)
|
|
4
|
+
// does not — two parallel fetches can reorder. The queue restores the invariant the cheap
|
|
5
|
+
// way: envelopes are sent as in-order batches, and the next batch does not leave until the
|
|
6
|
+
// prior one is confirmed (the API server's response means the daemon durably processed it).
|
|
7
|
+
//
|
|
8
|
+
// Delivery is at-least-once: a failed flush retries the SAME batch with backoff, which is
|
|
9
|
+
// safe because the daemon absorbs `mid ≤ lmid` as an idempotent replay. A per-envelope
|
|
10
|
+
// REJECTION (the API server's policy saying no) is not retried — the daemon has already
|
|
11
|
+
// advanced lmid past it, the authoritative snap-back rides the lmid release, and the reason
|
|
12
|
+
// surfaces here through `onRejected` (the wire itself carries no rejection signal).
|
|
13
|
+
|
|
14
|
+
import type { MutationEnvelope } from "@rindle/client";
|
|
15
|
+
|
|
16
|
+
import type { MutationEnvelopeSender } from "./subscribe.ts";
|
|
17
|
+
|
|
18
|
+
/** One envelope's outcome from the API server's mutate endpoint. */
|
|
19
|
+
export interface PushOutcome {
|
|
20
|
+
accepted: boolean;
|
|
21
|
+
reason?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface QueuedMutationSenderOptions {
|
|
25
|
+
/** Deliver one in-order batch; resolve once the daemon durably processed it (i.e. the
|
|
26
|
+
* API server awaited its daemon call before responding). Throw to have the SAME batch
|
|
27
|
+
* retried. Return per-envelope outcomes, or void when everything was accepted. */
|
|
28
|
+
send: (envelopes: MutationEnvelope[]) => Promise<PushOutcome[] | void>;
|
|
29
|
+
/** A policy rejection for one envelope (never retried; the prediction snaps back via the
|
|
30
|
+
* lmid release — this callback is where the reason reaches the app). */
|
|
31
|
+
onRejected?: (envelope: MutationEnvelope, reason: string) => void;
|
|
32
|
+
/** A failed flush attempt, before its retry (observability). */
|
|
33
|
+
onError?: (err: unknown, attempt: number) => void;
|
|
34
|
+
/** Backoff before retry `attempt` (1-based). Default: 200ms · 2^(attempt-1), capped at 5s. */
|
|
35
|
+
retryDelayMs?: (attempt: number) => number;
|
|
36
|
+
/** Max envelopes per flush. Default 32. */
|
|
37
|
+
maxBatch?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Pending {
|
|
41
|
+
envelope: MutationEnvelope;
|
|
42
|
+
/** Resolves when the envelope's batch is confirmed (accepted OR rejected — both are
|
|
43
|
+
* durably processed states). */
|
|
44
|
+
resolve: () => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const defaultDelay = (attempt: number): number => Math.min(200 * 2 ** (attempt - 1), 5_000);
|
|
48
|
+
|
|
49
|
+
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
50
|
+
|
|
51
|
+
/** A `MutationEnvelopeSender` (the `pushMutation` option of `RemoteOptimisticSource`) that
|
|
52
|
+
* queues envelopes and delivers them as confirmed, in-order batches. */
|
|
53
|
+
export function createQueuedMutationSender(opts: QueuedMutationSenderOptions): MutationEnvelopeSender {
|
|
54
|
+
const queue: Pending[] = [];
|
|
55
|
+
const maxBatch = Math.max(1, opts.maxBatch ?? 32);
|
|
56
|
+
const delay = opts.retryDelayMs ?? defaultDelay;
|
|
57
|
+
let flushing = false;
|
|
58
|
+
|
|
59
|
+
const flush = async (): Promise<void> => {
|
|
60
|
+
if (flushing) return;
|
|
61
|
+
flushing = true;
|
|
62
|
+
try {
|
|
63
|
+
while (queue.length > 0) {
|
|
64
|
+
const batch = queue.slice(0, maxBatch);
|
|
65
|
+
let outcomes: PushOutcome[] | void;
|
|
66
|
+
for (let attempt = 1; ; attempt++) {
|
|
67
|
+
try {
|
|
68
|
+
outcomes = await opts.send(batch.map((p) => p.envelope));
|
|
69
|
+
break;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
opts.onError?.(err, attempt);
|
|
72
|
+
await sleep(delay(attempt));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
queue.splice(0, batch.length);
|
|
76
|
+
batch.forEach((pending, i) => {
|
|
77
|
+
const outcome = Array.isArray(outcomes) ? outcomes[i] : undefined;
|
|
78
|
+
if (outcome && !outcome.accepted) {
|
|
79
|
+
opts.onRejected?.(pending.envelope, outcome.reason ?? "mutation rejected");
|
|
80
|
+
}
|
|
81
|
+
pending.resolve();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
} finally {
|
|
85
|
+
flushing = false;
|
|
86
|
+
}
|
|
87
|
+
// Envelopes enqueued during the final await: a new flush owns them.
|
|
88
|
+
if (queue.length > 0) void flush();
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return (envelope) =>
|
|
92
|
+
new Promise<void>((resolve) => {
|
|
93
|
+
queue.push({ envelope, resolve });
|
|
94
|
+
void flush();
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
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
|
+
|
|
10
|
+
import { COMPARATOR_VERSION } from "@rindle/client";
|
|
11
|
+
import type { NormalizedEvent, NormalizedOp, NormalizedTableSchema } from "@rindle/client";
|
|
12
|
+
|
|
13
|
+
import { Fnv } from "./protocol.ts";
|
|
14
|
+
import { ProtocolError } from "./protocol.ts";
|
|
15
|
+
|
|
16
|
+
// ----------------------------- normalized fingerprint -----------------------------
|
|
17
|
+
|
|
18
|
+
/** A `tables` set's content fingerprint — FNV-1a 64 over the canonical, length-prefixed byte
|
|
19
|
+
* stream of `rindle-replica::normalize_protocol::normalized_fp` (PK resolved to column NAMES),
|
|
20
|
+
* as 16-char lowercase hex (=== the Rust hex). `tables` MUST be sorted by name (the server's
|
|
21
|
+
* `NormalizedPublisher` guarantees it) so the fingerprint is order-stable. */
|
|
22
|
+
export function normalizedFp(tables: NormalizedTableSchema[]): string {
|
|
23
|
+
const f = new Fnv();
|
|
24
|
+
f.u32(tables.length);
|
|
25
|
+
for (const t of tables) {
|
|
26
|
+
f.s(t.name);
|
|
27
|
+
f.u32(t.columns.length);
|
|
28
|
+
for (const c of t.columns) f.s(c);
|
|
29
|
+
f.u32(t.primaryKey.length);
|
|
30
|
+
for (const pk of t.primaryKey) f.s(t.columns[pk]); // PK by NAME
|
|
31
|
+
}
|
|
32
|
+
return f.h.toString(16).padStart(16, "0");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Is `sub` a subsequence of `sup` by NAME — every entry of `sub` present in `sup`, in order?
|
|
36
|
+
* Accepts a projection (server ⊆ client) or an expansion (client ⊆ server); a rename/reorder
|
|
37
|
+
* satisfies neither direction. */
|
|
38
|
+
function isSubsequenceByName(sub: readonly string[], sup: readonly string[]): boolean {
|
|
39
|
+
let i = 0;
|
|
40
|
+
for (const name of sup) if (i < sub.length && sub[i] === name) i++;
|
|
41
|
+
return i === sub.length;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Validate the tables a `hello` advertises against the CLIENT's own typed schema, by name
|
|
45
|
+
* (column order + PK indices). `hello.tables` is the query's table subtree, so each one must
|
|
46
|
+
* be column-compatible with a client table. Throws {@link ProtocolError} `"schema-mismatch"`
|
|
47
|
+
* on the first unknown table / column-order skew / PK skew — the §3 "drift ⇒ re-subscribe"
|
|
48
|
+
* guard (CRIT#4). */
|
|
49
|
+
function validateAgainstClientSchema(
|
|
50
|
+
serverTables: NormalizedTableSchema[],
|
|
51
|
+
clientTables: NormalizedTableSchema[],
|
|
52
|
+
): void {
|
|
53
|
+
const byName = new Map(clientTables.map((t) => [t.name, t]));
|
|
54
|
+
const eq = (a: readonly (string | number)[], b: readonly (string | number)[]) =>
|
|
55
|
+
a.length === b.length && a.every((x, i) => x === b[i]);
|
|
56
|
+
for (const st of serverTables) {
|
|
57
|
+
const ct = byName.get(st.name);
|
|
58
|
+
if (!ct) {
|
|
59
|
+
throw new ProtocolError("schema-mismatch", `server advertises table "${st.name}" not in the client schema`);
|
|
60
|
+
}
|
|
61
|
+
// The server may advertise FEWER columns than the client (a projected query —
|
|
62
|
+
// PROJECTION-SUPPORT-DESIGN.md §5.2/§7) OR MORE (an EXPANDED server table mid an
|
|
63
|
+
// `expand-then-contract` migration — the client drops the columns it doesn't yet have).
|
|
64
|
+
// Both are safe: the client maps every advertised column to a base position BY NAME, so
|
|
65
|
+
// the relative order is preserved either way. Accept when one column list is a SUBSEQUENCE
|
|
66
|
+
// of the other by name — a narrowing (projection) or a widening (expand) — while still
|
|
67
|
+
// rejecting a genuine skew, where NEITHER is a subsequence of the other: a renamed column
|
|
68
|
+
// or a reordered one (the CRIT#4 guard). A server with more columns than the client is no
|
|
69
|
+
// longer drift; without this, `expand-then-contract` is impossible.
|
|
70
|
+
if (!isSubsequenceByName(st.columns, ct.columns) && !isSubsequenceByName(ct.columns, st.columns)) {
|
|
71
|
+
throw new ProtocolError(
|
|
72
|
+
"schema-mismatch",
|
|
73
|
+
`column drift on "${st.name}": server [${st.columns}] is neither a projection nor an expansion of client [${ct.columns}] (column order skew)`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
// PK compared by NAME (the server forces the PK into every projection, so it is always
|
|
77
|
+
// present): resolve each side's PK indices to names and require equality.
|
|
78
|
+
const serverPk = st.primaryKey.map((i) => st.columns[i]);
|
|
79
|
+
const clientPk = ct.primaryKey.map((i) => ct.columns[i]);
|
|
80
|
+
if (!eq(serverPk, clientPk)) {
|
|
81
|
+
throw new ProtocolError(
|
|
82
|
+
"schema-mismatch",
|
|
83
|
+
`primary-key drift on "${st.name}": client [${clientPk}] != server [${serverPk}]`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ----------------------------- the wire frames -----------------------------
|
|
90
|
+
|
|
91
|
+
/** The slim normalized handshake (§3), sent once before any {@link NormalizedBatch}. */
|
|
92
|
+
export interface NormalizedHello {
|
|
93
|
+
epoch: number;
|
|
94
|
+
comparatorVersion: number;
|
|
95
|
+
tables: NormalizedTableSchema[];
|
|
96
|
+
normalizedFp: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One committed transaction's normalized ops (or the seq-0 hydrate snapshot). `cv` (the
|
|
100
|
+
* global commit version the frame reflects) is stamped by optimistic-protocol servers and
|
|
101
|
+
* rides through to the `NormalizedEvent` (OPTIMISTIC-WRITES-DESIGN.md §8.3). */
|
|
102
|
+
export interface NormalizedBatch {
|
|
103
|
+
epoch: number;
|
|
104
|
+
seq: number;
|
|
105
|
+
normalizedFp: string;
|
|
106
|
+
ops: NormalizedOp[];
|
|
107
|
+
cv?: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ----------------------------- Subscriber (client side) -----------------------------
|
|
111
|
+
|
|
112
|
+
/** Receiver side: validates a normalized frame stream (comparator at `hello`; per batch —
|
|
113
|
+
* epoch match, fingerprint match, strict in-order seq) and emits clean `NormalizedEvent`s.
|
|
114
|
+
* It does NOT fold (the `NormalizedSync` layer does). Mirrors the flat {@link Subscriber}. */
|
|
115
|
+
export class NormalizedSubscriber {
|
|
116
|
+
readonly epoch: number;
|
|
117
|
+
readonly normalizedFp: string;
|
|
118
|
+
private readonly emit: (ev: NormalizedEvent) => void;
|
|
119
|
+
private phase: "snapshot" | "live" = "snapshot";
|
|
120
|
+
private lastSeq = 0;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param hello the server's normalized handshake.
|
|
124
|
+
* @param emit clean-event sink (the `NormalizedSync` fold).
|
|
125
|
+
* @param clientTables the CLIENT's own typed per-table schemas (all tables). When given,
|
|
126
|
+
* each table the hello advertises is validated by NAME against it (column order + PK
|
|
127
|
+
* indices). The hello's `tables` is a per-query SUBSET (the query's table tree), so this
|
|
128
|
+
* checks each advertised table rather than one global fingerprint. A mismatch (routine
|
|
129
|
+
* deployment / schema skew) is rejected here — without it, positional rows aligned to the
|
|
130
|
+
* SERVER's column order are stored verbatim under the CLIENT's order, silently swapping
|
|
131
|
+
* cells and mis-keying the refcount/GC (CRIT#4 / §3 "drift ⇒ re-subscribe").
|
|
132
|
+
*/
|
|
133
|
+
constructor(
|
|
134
|
+
hello: NormalizedHello,
|
|
135
|
+
emit: (ev: NormalizedEvent) => void,
|
|
136
|
+
clientTables?: NormalizedTableSchema[],
|
|
137
|
+
) {
|
|
138
|
+
this.emit = emit;
|
|
139
|
+
if (hello.comparatorVersion !== COMPARATOR_VERSION) {
|
|
140
|
+
throw new ProtocolError(
|
|
141
|
+
"comparator-mismatch",
|
|
142
|
+
`comparator version ${hello.comparatorVersion} != ${COMPARATOR_VERSION}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const computed = normalizedFp(hello.tables);
|
|
146
|
+
if (computed !== hello.normalizedFp) {
|
|
147
|
+
throw new ProtocolError("schema-mismatch", `advertised fp ${hello.normalizedFp} != computed ${computed}`);
|
|
148
|
+
}
|
|
149
|
+
if (clientTables) validateAgainstClientSchema(hello.tables, clientTables);
|
|
150
|
+
this.epoch = hello.epoch;
|
|
151
|
+
this.normalizedFp = hello.normalizedFp;
|
|
152
|
+
emit({
|
|
153
|
+
type: "hello",
|
|
154
|
+
tables: hello.tables,
|
|
155
|
+
comparatorVersion: hello.comparatorVersion,
|
|
156
|
+
normalizedFp: hello.normalizedFp,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Apply one normalized batch (or the seq-0 snapshot). Returns `"duplicate"` for an
|
|
161
|
+
* already-applied seq; throws {@link ProtocolError} on a gap / epoch / fp mismatch (the
|
|
162
|
+
* caller re-hydrates under a new epoch). */
|
|
163
|
+
apply(batch: NormalizedBatch): "applied" | "duplicate" {
|
|
164
|
+
if (batch.epoch !== this.epoch) {
|
|
165
|
+
throw new ProtocolError("epoch-mismatch", `expected epoch ${this.epoch}, got ${batch.epoch}`);
|
|
166
|
+
}
|
|
167
|
+
if (batch.normalizedFp !== this.normalizedFp) {
|
|
168
|
+
throw new ProtocolError("schema-mismatch", `expected fp ${this.normalizedFp}, got ${batch.normalizedFp}`);
|
|
169
|
+
}
|
|
170
|
+
if (this.phase === "snapshot") {
|
|
171
|
+
if (batch.seq === 0) {
|
|
172
|
+
this.phase = "live";
|
|
173
|
+
this.lastSeq = 0;
|
|
174
|
+
this.emit({ type: "snapshot", ops: batch.ops, cv: batch.cv });
|
|
175
|
+
return "applied";
|
|
176
|
+
}
|
|
177
|
+
throw new ProtocolError("gap", `expected the seq-0 snapshot, got seq ${batch.seq}`);
|
|
178
|
+
}
|
|
179
|
+
const expected = this.lastSeq + 1;
|
|
180
|
+
if (batch.seq < expected) return "duplicate";
|
|
181
|
+
if (batch.seq > expected) {
|
|
182
|
+
throw new ProtocolError("gap", `expected seq ${expected}, got ${batch.seq}`);
|
|
183
|
+
}
|
|
184
|
+
this.lastSeq = batch.seq;
|
|
185
|
+
this.emit({ type: "batch", ops: batch.ops, cv: batch.cv });
|
|
186
|
+
return "applied";
|
|
187
|
+
}
|
|
188
|
+
}
|