@rindle/optimistic 0.4.3 → 0.5.0
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/README.md +30 -61
- package/dist/backend.d.ts +881 -26
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +2033 -205
- package/dist/backend.js.map +1 -1
- package/dist/client.d.ts +156 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +848 -8
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/local-persist.d.ts +102 -0
- package/dist/local-persist.d.ts.map +1 -0
- package/dist/local-persist.js +957 -0
- package/dist/local-persist.js.map +1 -0
- package/dist/system-streams.d.ts +62 -0
- package/dist/system-streams.d.ts.map +1 -0
- package/dist/system-streams.js +107 -0
- package/dist/system-streams.js.map +1 -0
- package/package.json +6 -5
- package/src/backend.ts +2476 -198
- package/src/client.ts +1174 -13
- package/src/index.ts +51 -0
- package/src/local-persist.ts +1129 -0
- package/src/system-streams.ts +140 -0
package/dist/client.js
CHANGED
|
@@ -8,13 +8,29 @@
|
|
|
8
8
|
// the ws transport to the daemon, lease resolution through the API server's query route,
|
|
9
9
|
// and the mutation queue flushing confirmed in-order batches through the mutate route
|
|
10
10
|
// (rejection reasons surface via `onRejected`).
|
|
11
|
+
import { localTableNames } from "@rindle/client";
|
|
11
12
|
import { RemoteOptimisticSource, WsTransport, createQueuedMutationSender, } from "@rindle/remote";
|
|
12
13
|
import { initWasm } from "@rindle/wasm";
|
|
13
14
|
import { resetStableClientID, stableClientID } from "./client-id.js";
|
|
15
|
+
import { LIFECYCLE_QUERY_NAME, ROOM_CLIENT_MUTATIONS_TABLE, ROOM_MUTATION_OUTCOMES_TABLE, ROOM_WATERMARK_TABLE, SCOPE_SESSIONS_TABLE, } from "./system-streams.js";
|
|
14
16
|
import { createOptimisticStore } from "./index.js";
|
|
17
|
+
import { attachLocalPersistence } from "./local-persist.js";
|
|
15
18
|
/** Must mirror `DEFAULT_RINDLE_API_ROUTES` in `@rindle/api-server` (the app-wire contract;
|
|
16
19
|
* duplicated so the browser bundle doesn't import the server package). */
|
|
17
20
|
const DEFAULT_ROUTES = { query: "/api/rindle/query", mutate: "/api/rindle/mutate" };
|
|
21
|
+
/** Default {@link RealtimeClientOptions.renewMarginMs}. */
|
|
22
|
+
const DEFAULT_RENEW_MARGIN_MS = 30_000;
|
|
23
|
+
/** Renewal-delay floor: a nearly-expired lease still renews soon, but never in a hot loop. */
|
|
24
|
+
const MIN_RENEW_DELAY_MS = 1_000;
|
|
25
|
+
/** Retry delay after a failed renewal POST (only while the current token is still live). */
|
|
26
|
+
const RENEW_RETRY_MS = 5_000;
|
|
27
|
+
/** A one-shot token handoff not consumed within this window is stale — the resolver falls through
|
|
28
|
+
* to a fresh lease POST instead of presenting a token the server may already refuse. */
|
|
29
|
+
const HANDOFF_MAX_AGE_MS = 15_000;
|
|
30
|
+
/** Client-minted remote-retain qids live in their own high band so they can never collide with the
|
|
31
|
+
* Store's own 1, 2, 3, … view qids or the reserved per-channel lmid qid 0. Exact in f64 (the wire
|
|
32
|
+
* number type), far below 2^53. */
|
|
33
|
+
const REALTIME_RETAIN_QID_BASE = 2 ** 30;
|
|
18
34
|
export async function createRindleClient(opts) {
|
|
19
35
|
await initWasm();
|
|
20
36
|
const clientID = opts.clientID ?? stableClientID();
|
|
@@ -33,6 +49,42 @@ export async function createRindleClient(opts) {
|
|
|
33
49
|
throw new RindleApiHttpError(path, res.status, text);
|
|
34
50
|
return text ? JSON.parse(text) : undefined;
|
|
35
51
|
};
|
|
52
|
+
// The ONE app-lease POST both legs share. Sends the stable `clientId` so the api-server/router
|
|
53
|
+
// can use it as the anonymous routing key (READ-ROUTER-DESIGN.md §2.2); the reply's top-level
|
|
54
|
+
// fields are the daemon lease, and a room-served labeled query ADDITIONALLY carries `realtime`.
|
|
55
|
+
const postLease = (remote) => post(routes.query, { name: remote.name, args: remote.args, clientId: clientID });
|
|
56
|
+
// One-shot fresh-token handoffs, by remote key: G-v's resolve-then-register (and the proactive
|
|
57
|
+
// renewal) has ALREADY leased when the subscribe fires, so the resolver consumes the handed
|
|
58
|
+
// token instead of POSTing a second time — one lease per subscribe, exactly the unlabeled
|
|
59
|
+
// cadence. Age-capped: an entry no subscribe consumed (e.g. a refcount-only retain) must not
|
|
60
|
+
// serve a stale token to a much-later re-subscribe (which re-leases fresh instead).
|
|
61
|
+
const tokenHandoffs = new Map();
|
|
62
|
+
const takeHandoff = (key) => {
|
|
63
|
+
const handed = tokenHandoffs.get(key);
|
|
64
|
+
if (!handed)
|
|
65
|
+
return undefined;
|
|
66
|
+
tokenHandoffs.delete(key);
|
|
67
|
+
return Date.now() - handed.at <= HANDOFF_MAX_AGE_MS ? handed.target : undefined;
|
|
68
|
+
};
|
|
69
|
+
/** RE-resolve a SYSTEM (lifecycle) subscription (Slice I-iii — a reconnect / gap repair /
|
|
70
|
+
* overflow re-subscribe whose mint-time handoff is long consumed). `_rindle/lifecycle` is a
|
|
71
|
+
* reserved CLIENT-side name (like the lmid query's): the api-server cannot lease it by name,
|
|
72
|
+
* so the re-resolution re-leases the PARENT labeled query — renewal-as-reauthorization, the
|
|
73
|
+
* room-token precedent — and picks the matching entry out of the fresh `lifecycle` block. A
|
|
74
|
+
* reply without the entry throws: the server no longer minting this stream (config off, label
|
|
75
|
+
* dropped) must not silently re-attach — the source logs the failed subscribe, and I-iv/I-v
|
|
76
|
+
* own any reaction. */
|
|
77
|
+
const resolveLifecycleTarget = async (args) => {
|
|
78
|
+
const out = (await postLease({ name: args.parent.name, args: args.parent.args }));
|
|
79
|
+
const want = systemEntryKey(args);
|
|
80
|
+
const entry = out.lifecycle === undefined
|
|
81
|
+
? undefined
|
|
82
|
+
: [out.lifecycle.doorbell, ...(out.lifecycle.fence ?? [])].find((e) => systemEntryKey(e) === want);
|
|
83
|
+
if (entry === undefined) {
|
|
84
|
+
throw new Error(`lifecycle re-lease of "${args.parent.name}" no longer carries the ${args.table} system lease`);
|
|
85
|
+
}
|
|
86
|
+
return { leaseToken: entry.leaseToken, ...(entry.wsEndpoint !== undefined ? { wsEndpoint: entry.wsEndpoint } : {}) };
|
|
87
|
+
};
|
|
36
88
|
// Reads-leg connection: a fixed transport (tests/in-process) or a replaceable connection built
|
|
37
89
|
// from `wsUrl` (eager when present, lazy when omitted). A routed lease's `wsEndpoint` migrates it.
|
|
38
90
|
const connection = "transport" in opts.daemon
|
|
@@ -40,9 +92,20 @@ export async function createRindleClient(opts) {
|
|
|
40
92
|
: { factory: (endpoint) => new WsTransport(endpoint), endpoint: opts.daemon.wsUrl };
|
|
41
93
|
const source = new RemoteOptimisticSource(connection, clientID, {
|
|
42
94
|
resolveSubscribe: async ({ remote }) => {
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
95
|
+
// A fail-open labeled register already leased — present exactly that token (see
|
|
96
|
+
// `tokenHandoffs`); otherwise lease now. Read back the follower's `wsEndpoint` for placement.
|
|
97
|
+
// A `realtime` block on a RE-resolution is deliberately IGNORED here even now that the
|
|
98
|
+
// upgrade dance exists (I-iv): retargeting from inside a reconnect's resolve would race the
|
|
99
|
+
// very re-subscribe it resolves. The §4.1 DOORBELL path owns upgrades (`runUpgrade` below)
|
|
100
|
+
// — the occupancy row that made this lease carry a block will (re)ring it.
|
|
101
|
+
const handed = takeHandoff(remoteKey(remote));
|
|
102
|
+
if (handed)
|
|
103
|
+
return handed;
|
|
104
|
+
// A SYSTEM (lifecycle) sub re-resolves through its PARENT labeled query (I-iii) — the
|
|
105
|
+
// reserved name is never leaseable by itself (see resolveLifecycleTarget).
|
|
106
|
+
if (remote.name === LIFECYCLE_QUERY_NAME)
|
|
107
|
+
return resolveLifecycleTarget(remote.args);
|
|
108
|
+
const out = await postLease(remote);
|
|
46
109
|
return { leaseToken: out.leaseToken, wsEndpoint: out.wsEndpoint };
|
|
47
110
|
},
|
|
48
111
|
pushMutation: createQueuedMutationSender({
|
|
@@ -66,16 +129,687 @@ export async function createRindleClient(opts) {
|
|
|
66
129
|
const { store, backend, mutate } = createOptimisticStore(opts.schema, source, opts.mutators, {
|
|
67
130
|
clientID,
|
|
68
131
|
user: opts.user,
|
|
132
|
+
...(opts.domainPolicy ? { domainPolicy: opts.domainPolicy } : {}),
|
|
133
|
+
// Room-plane rejection parity (H-v): a room's `mutationOutcome {kind:"rejected"}` frame
|
|
134
|
+
// surfaces through the SAME callback the HTTP mutate path uses below — one app-level
|
|
135
|
+
// rejection surface, whichever authority said no.
|
|
136
|
+
...(opts.onRejected ? { onRejected: opts.onRejected } : {}),
|
|
137
|
+
});
|
|
138
|
+
// ---- Rindle Realtime (G-v): resolve-then-register for LABELED queries -------------------------
|
|
139
|
+
//
|
|
140
|
+
// `store.materialize` is wrapped: an UNLABELED query takes the original path byte-identically; a
|
|
141
|
+
// query stamped with a `realtime` label materializes its local view synchronously (the Store's
|
|
142
|
+
// ordinary seed/`unknown` pre-marking runs untouched) while the remote register is SPLIT — the
|
|
143
|
+
// shadowed `backend.registerQuery` below registers the LOCAL half only, and the remote retain
|
|
144
|
+
// attaches when the lease answers: on `realtime.sourceKey`'s room channel when the lease carries
|
|
145
|
+
// a realtime block, on the daemon (fail-open, indistinguishable from unlabeled) when it doesn't.
|
|
146
|
+
const realtimeOpts = opts.realtime ?? {};
|
|
147
|
+
const roomTransportFactory = realtimeOpts.transport ?? ((endpoint) => new WsTransport(endpoint));
|
|
148
|
+
const renewMarginMs = realtimeOpts.renewMarginMs ?? DEFAULT_RENEW_MARGIN_MS;
|
|
149
|
+
const localTables = localTableNames(opts.schema);
|
|
150
|
+
let realtimeClosed = false;
|
|
151
|
+
const anomaly = (kind, remote, message) => {
|
|
152
|
+
// LOUD by contract: every anomaly hits the console even with a handler installed.
|
|
153
|
+
console.error(`[rindle] realtime ${kind} for query "${remote.name}": ${message}`);
|
|
154
|
+
try {
|
|
155
|
+
realtimeOpts.onAnomaly?.({ kind, name: remote.name, args: remote.args, message });
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
console.error("[rindle] realtime onAnomaly handler threw:", err);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
const rooms = new Map();
|
|
162
|
+
const roomQueries = new Map();
|
|
163
|
+
let nextRetainQid = REALTIME_RETAIN_QID_BASE;
|
|
164
|
+
const systemSubs = new Map();
|
|
165
|
+
/** Claim every entry of `block` for one holder (`claims` — the holder's own claim set; a key
|
|
166
|
+
* already claimed by THIS holder is skipped, so renewal re-presentations are idempotent).
|
|
167
|
+
* Unknown tables are skipped (forward-compat: a newer server minting a fifth stream must not
|
|
168
|
+
* break this client). */
|
|
169
|
+
const claimLifecycle = (claims, block, parent) => {
|
|
170
|
+
if (block === undefined || realtimeClosed)
|
|
171
|
+
return;
|
|
172
|
+
for (const entry of [block.doorbell, ...(block.fence ?? [])]) {
|
|
173
|
+
if (!isSystemTable(entry.table))
|
|
174
|
+
continue;
|
|
175
|
+
const key = systemEntryKey(entry);
|
|
176
|
+
if (claims.has(key))
|
|
177
|
+
continue;
|
|
178
|
+
let live = systemSubs.get(key);
|
|
179
|
+
if (!live) {
|
|
180
|
+
// The sub's wire identity embeds the PARENT labeled query so a RE-resolution can
|
|
181
|
+
// re-lease it (resolveLifecycleTarget); the minted token is handed to the resolver so
|
|
182
|
+
// the first subscribe presents exactly it — one lease per subscribe, the G-v cadence.
|
|
183
|
+
const remote = {
|
|
184
|
+
name: LIFECYCLE_QUERY_NAME,
|
|
185
|
+
args: {
|
|
186
|
+
table: entry.table,
|
|
187
|
+
...(entry.scope !== undefined ? { scope: entry.scope } : {}),
|
|
188
|
+
...(entry.doc !== undefined ? { doc: entry.doc } : {}),
|
|
189
|
+
...(entry.clientId !== undefined ? { clientId: entry.clientId } : {}),
|
|
190
|
+
parent: { name: parent.name, args: parent.args },
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
tokenHandoffs.set(remoteKey(remote), {
|
|
194
|
+
target: { leaseToken: entry.leaseToken, ...(entry.wsEndpoint !== undefined ? { wsEndpoint: entry.wsEndpoint } : {}) },
|
|
195
|
+
at: Date.now(),
|
|
196
|
+
});
|
|
197
|
+
const retainQid = nextRetainQid++;
|
|
198
|
+
backend.retainSystemQuery(retainQid, remote, {
|
|
199
|
+
table: entry.table,
|
|
200
|
+
...(entry.scope !== undefined ? { scope: entry.scope } : {}),
|
|
201
|
+
...(entry.doc !== undefined ? { doc: entry.doc } : {}),
|
|
202
|
+
});
|
|
203
|
+
live = { retainQid, refCount: 0 };
|
|
204
|
+
systemSubs.set(key, live);
|
|
205
|
+
}
|
|
206
|
+
live.refCount++;
|
|
207
|
+
claims.add(key);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
/** Release one holder's claims; the LAST holder of a key releases the backend retain (the wire
|
|
211
|
+
* sub unsubscribes; the backend's folded fence/occupancy STATE deliberately survives). */
|
|
212
|
+
const releaseLifecycle = (claims) => {
|
|
213
|
+
for (const key of claims) {
|
|
214
|
+
const live = systemSubs.get(key);
|
|
215
|
+
if (!live)
|
|
216
|
+
continue;
|
|
217
|
+
if (--live.refCount <= 0) {
|
|
218
|
+
systemSubs.delete(key);
|
|
219
|
+
backend.releaseSystemQuery(live.retainQid);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
claims.clear();
|
|
223
|
+
};
|
|
224
|
+
/** Promote every lease table spec this client can hold, exactly once per (sourceKey, table) —
|
|
225
|
+
* re-leases (renewal / reconnect) must never re-promote (the engine refuses a duplicate room);
|
|
226
|
+
* the idempotence check reads the BACKEND's routing record (the one source of truth since
|
|
227
|
+
* H-iii). A footprint table absent from the client schema has nothing to promote (the client
|
|
228
|
+
* never materializes rows for it) and is skipped. The lease spec's routing half —
|
|
229
|
+
* `joinKeyCols`, the row-local writable `where`, and `footprintWhere` (H-iii) — threads through
|
|
230
|
+
* as the backend's per-(sourceKey, table) `RoomTableRouting` record, the §3 router's input;
|
|
231
|
+
* none of it crosses the wasm `WritableDescriptor` ABI. */
|
|
232
|
+
const promoteRoomTables = (room, specs) => {
|
|
233
|
+
for (const spec of specs) {
|
|
234
|
+
if (backend.roomTablesFor(room.sourceKey).has(spec.table))
|
|
235
|
+
continue;
|
|
236
|
+
if (!(spec.table in opts.schema.tables))
|
|
237
|
+
continue;
|
|
238
|
+
backend.promoteRoomTable(spec.table, room.sourceKey, toWritableDescriptor(spec), {
|
|
239
|
+
joinKeyCols: spec.writable.kind === "predicate" ? spec.writable.joinKeyCols : [],
|
|
240
|
+
...(spec.writable.kind === "predicate" && spec.writable.where !== undefined
|
|
241
|
+
? { where: spec.writable.where }
|
|
242
|
+
: {}),
|
|
243
|
+
...(spec.footprintWhere !== undefined ? { footprintWhere: spec.footprintWhere } : {}),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
/** (Re)arm a room query's proactive renewal from its current `exp`. Timers are unref'd (Node)
|
|
248
|
+
* so an idle renewal never holds the process open; cleared on release/close. */
|
|
249
|
+
const scheduleRenewal = (key, state, delayMs) => {
|
|
250
|
+
if (state.renewTimer !== undefined)
|
|
251
|
+
clearTimeout(state.renewTimer);
|
|
252
|
+
if (realtimeClosed)
|
|
253
|
+
return;
|
|
254
|
+
const delay = delayMs ?? Math.max(state.exp - Date.now() - renewMarginMs, MIN_RENEW_DELAY_MS);
|
|
255
|
+
state.renewTimer = setTimeout(() => {
|
|
256
|
+
state.renewTimer = undefined;
|
|
257
|
+
void renewRoomQuery(key, state);
|
|
258
|
+
}, delay);
|
|
259
|
+
state.renewTimer.unref?.();
|
|
260
|
+
};
|
|
261
|
+
/** Proactive token renewal (renewal-as-reauthorization): re-lease through the SAME app query
|
|
262
|
+
* route; only a reply WITH a realtime block (and the SAME sourceKey) re-authorizes — the fresh
|
|
263
|
+
* token is handed to the resolver and the live sub re-subscribes with it BEFORE the room
|
|
264
|
+
* shell's TTL backstop can drop it. A reply without the block is the DOWNGRADE signal: loud;
|
|
265
|
+
* the sub is left to die at `exp` (the graceful downgrade dance is Slice I). */
|
|
266
|
+
const renewRoomQuery = async (key, state) => {
|
|
267
|
+
if (realtimeClosed || roomQueries.get(key) !== state)
|
|
268
|
+
return;
|
|
269
|
+
let lease;
|
|
270
|
+
try {
|
|
271
|
+
lease = await postLease(state.remote);
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
anomaly("lease-failed", state.remote, `token renewal failed: ${String(err?.message ?? err)}`);
|
|
275
|
+
// Retry while the current token is still live; past `exp` the shell has dropped the sub
|
|
276
|
+
// anyway and the next reconnect re-resolution owns recovery.
|
|
277
|
+
if (Date.now() < state.exp && roomQueries.get(key) === state)
|
|
278
|
+
scheduleRenewal(key, state, RENEW_RETRY_MS);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (realtimeClosed || roomQueries.get(key) !== state)
|
|
282
|
+
return;
|
|
283
|
+
// I-iii: a renewal re-presents the lifecycle block — re-claim idempotently (a key this query
|
|
284
|
+
// already holds is skipped; a NEW entry, e.g. the fence appearing when the query became
|
|
285
|
+
// room-served mid-life, is retained now). Claimed BEFORE the realtime check on purpose: a
|
|
286
|
+
// downgraded renewal (no realtime block) still carries the doorbell, and the occupancy
|
|
287
|
+
// stream must survive the downgrade (it is what re-upgrades, §4.1).
|
|
288
|
+
claimLifecycle(state.lifecycleClaims, lease.lifecycle, state.remote);
|
|
289
|
+
const rt = lease.realtime;
|
|
290
|
+
if (rt === undefined) {
|
|
291
|
+
// No realtime block: the occupancy gate closed server-side. WITH a §4.2 fence, run the
|
|
292
|
+
// graceful I-v downgrade dance (retarget → demote behind the watermark → re-arm the
|
|
293
|
+
// doorbell); WITHOUT one, stay loud (a pre-I-v server, or `lifecycle.drainRoom`
|
|
294
|
+
// unconfigured — nothing to ghost behind soundly).
|
|
295
|
+
if (lease.realtimeFence !== undefined) {
|
|
296
|
+
// Hand the fresh daemon token so the driving query's daemon re-subscribe presents it (no
|
|
297
|
+
// extra POST); co-tenant queries sharing the room re-lease on their own daemon re-subscribe.
|
|
298
|
+
tokenHandoffs.set(key, {
|
|
299
|
+
target: { leaseToken: lease.leaseToken, ...(lease.wsEndpoint !== undefined ? { wsEndpoint: lease.wsEndpoint } : {}) },
|
|
300
|
+
at: Date.now(),
|
|
301
|
+
});
|
|
302
|
+
downgradeRoom(lease.realtimeFence);
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
anomaly("downgrade", state.remote, "the renewal lease carries no realtime block AND no §4.2 downgrade fence — the query is no longer room-served and the server offered nothing to fall back behind (a pre-I-v server, or lifecycle.drainRoom unconfigured); its room sub will lapse at exp");
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (rt.sourceKey !== state.sourceKey) {
|
|
310
|
+
anomaly("source-key-changed", state.remote, `the renewal lease names sourceKey ${JSON.stringify(rt.sourceKey)} but the live sub is on ${JSON.stringify(state.sourceKey)} (teardown + re-register is the §7.6 rare-case follow-up — deferred, no fence for the old room)`);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const room = rooms.get(state.sourceKey);
|
|
314
|
+
if (!room)
|
|
315
|
+
return;
|
|
316
|
+
try {
|
|
317
|
+
// Promotion is per-(sourceKey, table) idempotent, so a renewal compiles only tables that
|
|
318
|
+
// are NEW to the lease (a profile edit mid-life). A compile throw (schema skew) must not
|
|
319
|
+
// kill the renewal — this is a timer-driven void promise, so an escape would be an
|
|
320
|
+
// unhandled rejection — and the live sub keeps its already-promoted tables + the fresh
|
|
321
|
+
// token below; the new table's routing simply never arms (its writes route slow).
|
|
322
|
+
promoteRoomTables(room, rt.tables);
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
anomaly("lease-failed", state.remote, `renewal promotion failed: ${String(err?.message ?? err)} (the room keeps its already-promoted tables)`);
|
|
326
|
+
}
|
|
327
|
+
state.exp = rt.exp;
|
|
328
|
+
if (lease.lifecycle?.doorbell.scope !== undefined)
|
|
329
|
+
state.doorbellScope = lease.lifecycle.doorbell.scope;
|
|
330
|
+
// Re-present NOW with the fresh token: hand it to the resolver and re-subscribe the live sub
|
|
331
|
+
// (an ordinary epoch bump server-side; the fresh snapshot re-hydrates through the room gate as
|
|
332
|
+
// a net-zero footprint diff).
|
|
333
|
+
tokenHandoffs.set(key, { target: { leaseToken: rt.roomToken, wsEndpoint: rt.wsEndpoint }, at: Date.now() });
|
|
334
|
+
room.source.registerQuery(state.sourceQid, state.remote);
|
|
335
|
+
scheduleRenewal(key, state);
|
|
336
|
+
};
|
|
337
|
+
/** The room channel's subscribe resolver: first subscribe consumes the handed fresh token; every
|
|
338
|
+
* RE-resolution (reconnect, gap repair, endpoint recovery) is a full re-lease through the app
|
|
339
|
+
* route — renewal-as-reauthorization, so a revoked/downgraded query cannot silently re-attach. */
|
|
340
|
+
const roomResolver = (room) => async ({ remote }) => {
|
|
341
|
+
const key = remoteKey(remote);
|
|
342
|
+
const handed = takeHandoff(key);
|
|
343
|
+
if (handed)
|
|
344
|
+
return handed;
|
|
345
|
+
const lease = await postLease(remote);
|
|
346
|
+
const rt = lease.realtime;
|
|
347
|
+
if (rt === undefined) {
|
|
348
|
+
anomaly("downgrade", remote, "the re-lease carries no realtime block — the query is no longer room-served (room subscribe aborted; graceful downgrade is Slice I)");
|
|
349
|
+
throw new Error(`realtime downgrade: query "${remote.name}" is no longer room-served`);
|
|
350
|
+
}
|
|
351
|
+
if (rt.sourceKey !== room.sourceKey) {
|
|
352
|
+
anomaly("source-key-changed", remote, `the re-lease names sourceKey ${JSON.stringify(rt.sourceKey)} but the live sub is on ${JSON.stringify(room.sourceKey)} (teardown + re-register is the §7.6 rare-case follow-up — deferred, no fence for the old room)`);
|
|
353
|
+
throw new Error(`realtime sourceKey changed for query "${remote.name}"`);
|
|
354
|
+
}
|
|
355
|
+
// A re-lease may widen the footprint (new tables) and always refreshes the renewal clock.
|
|
356
|
+
promoteRoomTables(room, rt.tables);
|
|
357
|
+
const state = roomQueries.get(key);
|
|
358
|
+
if (state) {
|
|
359
|
+
state.exp = rt.exp;
|
|
360
|
+
scheduleRenewal(key, state);
|
|
361
|
+
// I-iii: a re-resolution's lifecycle block re-claims like a renewal's (idempotent).
|
|
362
|
+
claimLifecycle(state.lifecycleClaims, lease.lifecycle, state.remote);
|
|
363
|
+
}
|
|
364
|
+
return { leaseToken: rt.roomToken, wsEndpoint: rt.wsEndpoint };
|
|
365
|
+
};
|
|
366
|
+
/** Connect (once) the room source for a lease's `sourceKey` — multiple labeled queries on the
|
|
367
|
+
* same room share the one source/gate. The endpoint is the lease's DEDICATED
|
|
368
|
+
* `realtime.wsEndpoint`; the TOP-LEVEL `wsEndpoint` (whole-daemon-session migration) never
|
|
369
|
+
* reaches a room transport. NO `pushMutation` override: a room-domain mutation ships over the
|
|
370
|
+
* ROOM socket itself (§7.5 sent-pins-domain — the backend's `channelFor` picks this source). */
|
|
371
|
+
const ensureRoom = (rt) => {
|
|
372
|
+
const existing = rooms.get(rt.sourceKey);
|
|
373
|
+
if (existing)
|
|
374
|
+
return existing;
|
|
375
|
+
const room = {
|
|
376
|
+
sourceKey: rt.sourceKey,
|
|
377
|
+
wsEndpoint: rt.wsEndpoint,
|
|
378
|
+
source: undefined,
|
|
379
|
+
};
|
|
380
|
+
room.source = new RemoteOptimisticSource({ factory: roomTransportFactory, endpoint: rt.wsEndpoint }, clientID, { resolveSubscribe: roomResolver(room) });
|
|
381
|
+
rooms.set(rt.sourceKey, room);
|
|
382
|
+
// connectSource BEFORE any retain on this channel (the backend throws otherwise); it also
|
|
383
|
+
// auto-registers the reserved lmid system query, so the room's confirms fold into
|
|
384
|
+
// `watermark[sourceKey]` from the first frame.
|
|
385
|
+
backend.connectSource(rt.sourceKey, room.source);
|
|
386
|
+
return room;
|
|
387
|
+
};
|
|
388
|
+
/** Candidates by remote key — ONE re-lease upgrades every view of the (name, args) at once
|
|
389
|
+
* (the backend moves the sub wholesale). */
|
|
390
|
+
const upgradeCandidates = new Map();
|
|
391
|
+
/** EVERY live labeled view's hook, by remote key (I-v): the downgrade dance runs from the
|
|
392
|
+
* renewal loop — no view reference in scope — yet must re-register each surviving view as an
|
|
393
|
+
* upgrade candidate (the doorbell re-arms the next upgrade) and clear its room bookkeeping.
|
|
394
|
+
* Registered at materialize, dropped at destroy. */
|
|
395
|
+
const labeledViewHooks = new Map();
|
|
396
|
+
/** Last observed other-session count per scope — the 0→≥1 transition tracker. A first
|
|
397
|
+
* observation at ≥1 counts as a transition (there was none before we could see). */
|
|
398
|
+
const lastOthers = new Map();
|
|
399
|
+
const runUpgrade = async (cand) => {
|
|
400
|
+
let lease;
|
|
401
|
+
try {
|
|
402
|
+
lease = await postLease(cand.remote);
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
anomaly("lease-failed", cand.remote, `doorbell re-lease failed: ${String(err?.message ?? err)} (staying daemon-attached; the next doorbell/renewal is the retry)`);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (realtimeClosed || cand.views.size === 0)
|
|
409
|
+
return; // torn down while the lease was in flight
|
|
410
|
+
const rt = lease.realtime;
|
|
411
|
+
if (rt === undefined)
|
|
412
|
+
return; // still gated server-side (e.g. its minSessions is higher) — stay daemon-attached, silently
|
|
413
|
+
const key = remoteKey(cand.remote);
|
|
414
|
+
if (roomQueries.has(key))
|
|
415
|
+
return; // already room-attached (a racing fresh view won) — nothing to move
|
|
416
|
+
try {
|
|
417
|
+
// The G-v attach order, verbatim, up to the sub move: room source/gate first, engine
|
|
418
|
+
// promotion second (both idempotent — `ensureRoom` per sourceKey, `promoteRoomTables` per
|
|
419
|
+
// (sourceKey, table) via the backend's routing record), THEN the wire cutover with the
|
|
420
|
+
// fresh roomToken handed to the room resolver. `retargetRemoteQuery` is itself idempotent
|
|
421
|
+
// per (query, sourceKey), so a duplicate doorbell that slipped the `inFlight` guard cannot
|
|
422
|
+
// double-attach.
|
|
423
|
+
const room = ensureRoom(rt);
|
|
424
|
+
promoteRoomTables(room, rt.tables);
|
|
425
|
+
tokenHandoffs.set(key, { target: { leaseToken: rt.roomToken, wsEndpoint: rt.wsEndpoint }, at: Date.now() });
|
|
426
|
+
const sourceQid = backend.retargetRemoteQuery(cand.remote, rt.sourceKey);
|
|
427
|
+
const state = {
|
|
428
|
+
remote: cand.remote,
|
|
429
|
+
sourceKey: rt.sourceKey,
|
|
430
|
+
sourceQid,
|
|
431
|
+
refCount: 0,
|
|
432
|
+
exp: rt.exp,
|
|
433
|
+
// ADOPT the candidate's claims (I-v): a re-upgrade after a downgrade inherits the fence
|
|
434
|
+
// streams the ghost still needs, already subscribed — so they are NOT re-subscribed; a
|
|
435
|
+
// fresh candidate's set is empty. `upgradeCandidates.delete(key)` below leaves the set
|
|
436
|
+
// owned by this state.
|
|
437
|
+
lifecycleClaims: cand.claims,
|
|
438
|
+
doorbellScope: cand.scope,
|
|
439
|
+
};
|
|
440
|
+
// The re-lease's lifecycle block now carries the fence bundle (the query is room-served):
|
|
441
|
+
// claim it on the query's renewal-loop set (idempotent — an adopted key is skipped), exactly
|
|
442
|
+
// as a renewal that turned room-served mid-life would (I-iii) — released when the last view
|
|
443
|
+
// drops the query.
|
|
444
|
+
claimLifecycle(state.lifecycleClaims, lease.lifecycle, cand.remote);
|
|
445
|
+
// No awaits since the `views.size` check above — destroys cannot have interleaved, so at
|
|
446
|
+
// least one view adopts (a released one declines via its own flag, defensively).
|
|
447
|
+
for (const view of [...cand.views])
|
|
448
|
+
view.adoptRoom(key, state);
|
|
449
|
+
upgradeCandidates.delete(key);
|
|
450
|
+
roomQueries.set(key, state);
|
|
451
|
+
scheduleRenewal(key, state);
|
|
452
|
+
}
|
|
453
|
+
catch (err) {
|
|
454
|
+
// Fail open: the retarget primitive validates before mutating, so the daemon retain is
|
|
455
|
+
// intact — the query keeps serving from the daemon exactly as before the doorbell.
|
|
456
|
+
tokenHandoffs.delete(key); // never leave a room token where the DAEMON resolver could eat it
|
|
457
|
+
anomaly("lease-failed", cand.remote, `upgrade retarget failed: ${String(err?.message ?? err)} (staying daemon-attached; the next doorbell/renewal is the retry)`);
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
/** Kick every idle candidate on `scope` — the doorbell reaction proper. */
|
|
461
|
+
const maybeUpgrade = (scope) => {
|
|
462
|
+
if (realtimeClosed)
|
|
463
|
+
return;
|
|
464
|
+
for (const cand of upgradeCandidates.values()) {
|
|
465
|
+
if (cand.scope !== scope || cand.inFlight || cand.views.size === 0)
|
|
466
|
+
continue;
|
|
467
|
+
cand.inFlight = true;
|
|
468
|
+
void runUpgrade(cand).finally(() => {
|
|
469
|
+
cand.inFlight = false;
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
const registerUpgradeCandidate = (remote, scope, hook) => {
|
|
474
|
+
const key = remoteKey(remote);
|
|
475
|
+
let cand = upgradeCandidates.get(key);
|
|
476
|
+
if (!cand)
|
|
477
|
+
upgradeCandidates.set(key, (cand = { remote, scope, views: new Set(), inFlight: false, claims: new Set() }));
|
|
478
|
+
cand.views.add(hook);
|
|
479
|
+
// Registration-time check: a doorbell that FOLDED before this candidate existed (the lease
|
|
480
|
+
// resolve raced the occupancy delta) must still trigger — same count rule as the events.
|
|
481
|
+
if (backend.otherScopeSessions(scope) >= 1)
|
|
482
|
+
maybeUpgrade(scope);
|
|
483
|
+
};
|
|
484
|
+
const dropUpgradeCandidate = (remote, hook) => {
|
|
485
|
+
const cand = upgradeCandidates.get(remoteKey(remote));
|
|
486
|
+
if (!cand)
|
|
487
|
+
return;
|
|
488
|
+
cand.views.delete(hook);
|
|
489
|
+
if (cand.views.size === 0) {
|
|
490
|
+
upgradeCandidates.delete(remoteKey(remote));
|
|
491
|
+
// A candidate carrying a downgrade's fence-stream claims (I-v) releases them with its last
|
|
492
|
+
// view — the LAST holder unsubscribes the wire sub (empty set ⇒ no-op for a fresh candidate).
|
|
493
|
+
releaseLifecycle(cand.claims);
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
/** The §4.2 graceful downgrade dance (Slice I-v): a renewal came back with NO realtime block
|
|
497
|
+
* but WITH a fence. Handle the WHOLE room at once — retarget every live sub sharing the source
|
|
498
|
+
* onto the daemon (the I-iv retarget in REVERSE), demote the room source behind the watermark
|
|
499
|
+
* fence (its rows persist as a FROZEN ghost until the daemon plane absorbs the final flush),
|
|
500
|
+
* close the room transport, and re-register each surviving view as an upgrade candidate so the
|
|
501
|
+
* next doorbell re-upgrades the same doc. `demoteRoomSource` refuses to demote while any sub is
|
|
502
|
+
* still on the channel, so all subs must retarget first; a co-tenant query's own later renewal
|
|
503
|
+
* then finds the room gone and no-ops (retarget-to-daemon + demote are both idempotent).
|
|
504
|
+
*
|
|
505
|
+
* Ordering with disconnect: `demoteRoomSource` → `disconnectSource` drops the room gate, which
|
|
506
|
+
* makes the retarget's deferred phase-2 GC (`flushRetargetGc`, run at the daemon's first
|
|
507
|
+
* release) a no-op — it deletes the pending-GC record then finds no old gate to rewind, so the
|
|
508
|
+
* room slice's rows leave ONLY through the ghost's `removeRoomSource` under the fence (never via
|
|
509
|
+
* a GC rewind that would surface a lagging follower's pre-flush images). */
|
|
510
|
+
const downgradeRoom = (fence) => {
|
|
511
|
+
if (realtimeClosed)
|
|
512
|
+
return;
|
|
513
|
+
const sourceKey = fence.sourceKey;
|
|
514
|
+
const onRoom = [...roomQueries].filter(([, s]) => s.sourceKey === sourceKey);
|
|
515
|
+
for (const [key, state] of onRoom) {
|
|
516
|
+
backend.retargetRemoteQuery(state.remote, "daemon"); // room → daemon; the no-block reply IS a daemon lease
|
|
517
|
+
if (state.renewTimer !== undefined)
|
|
518
|
+
clearTimeout(state.renewTimer);
|
|
519
|
+
roomQueries.delete(key);
|
|
520
|
+
const hooks = labeledViewHooks.get(key);
|
|
521
|
+
if (state.doorbellScope !== undefined && hooks !== undefined && hooks.size > 0) {
|
|
522
|
+
let cand = upgradeCandidates.get(key);
|
|
523
|
+
if (!cand) {
|
|
524
|
+
upgradeCandidates.set(key, (cand = { remote: state.remote, scope: state.doorbellScope, views: new Set(), inFlight: false, claims: new Set() }));
|
|
525
|
+
}
|
|
526
|
+
// Carry the renewal-loop's lifecycle claims (the fence streams — the ghost's watermark
|
|
527
|
+
// input, delivered on the DAEMON channel) onto the candidate so they outlive the room
|
|
528
|
+
// state and are adopted by the next upgrade (`runUpgrade`).
|
|
529
|
+
for (const c of state.lifecycleClaims)
|
|
530
|
+
cand.claims.add(c);
|
|
531
|
+
state.lifecycleClaims.clear();
|
|
532
|
+
for (const h of hooks) {
|
|
533
|
+
h.clearRoom(); // forget the dead room bookkeeping (destroy must not decrement a gone state)
|
|
534
|
+
cand.views.add(h);
|
|
535
|
+
}
|
|
536
|
+
// Self-heal: a collaborator still present at downgrade re-rings immediately. Normally the
|
|
537
|
+
// scope is solo here (that IS why the server downgraded), so this is inert.
|
|
538
|
+
if (backend.otherScopeSessions(state.doorbellScope) >= 1)
|
|
539
|
+
maybeUpgrade(state.doorbellScope);
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
// No re-upgrade possible (no doorbell scope, or no surviving view): release the claims.
|
|
543
|
+
if (hooks !== undefined)
|
|
544
|
+
for (const h of hooks)
|
|
545
|
+
h.clearRoom();
|
|
546
|
+
releaseLifecycle(state.lifecycleClaims);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
backend.demoteRoomSource(sourceKey, fence.doc, fence.finalFlushSeq); // frozen ghost behind the fence
|
|
550
|
+
const room = rooms.get(sourceKey);
|
|
551
|
+
if (room !== undefined) {
|
|
552
|
+
rooms.delete(sourceKey);
|
|
553
|
+
room.source.close(); // every sub retargeted off it
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
// The trigger: the backend reports (scope, other-session count) after each release that folded
|
|
557
|
+
// occupancy rows; the 0→≥1 transition rings. `others` never counts our own clientID or expired
|
|
558
|
+
// rows (the backend's one rule), so a solo tab's own row cannot ring its own bell, and a stale
|
|
559
|
+
// collaborator aging out then re-appearing rings again (0→1 anew) — which is idempotent here
|
|
560
|
+
// (an already-room-attached query has no candidate left to kick).
|
|
561
|
+
backend.onScopeSessions(({ scope, others }) => {
|
|
562
|
+
const prev = lastOthers.get(scope) ?? 0;
|
|
563
|
+
lastOthers.set(scope, others);
|
|
564
|
+
if (prev === 0 && others >= 1)
|
|
565
|
+
maybeUpgrade(scope);
|
|
566
|
+
});
|
|
567
|
+
// The I-v stuck-downgrade surface (§7.5): a ghost whose watermark fence cleared but whose sent
|
|
568
|
+
// room-domain mids never resolved through the daemon-carried folds. The ghost HOLDS (no
|
|
569
|
+
// timeout-retire is invented) — surface it loudly, naming the mids.
|
|
570
|
+
backend.onDowngradeStuck(({ sourceKey, doc, mids }) => {
|
|
571
|
+
anomaly("downgrade-stuck", { name: sourceKey, args: { doc, mids } }, `the downgrade ghost for doc ${JSON.stringify(doc)} is stuck: sent room mids [${mids.join(", ")}] never resolved through the daemon-carried outcome/ledger folds (§7.5 sent-pins-domain — undecidable in general; the ghost holds, investigate the lost outcome frames)`);
|
|
572
|
+
});
|
|
573
|
+
// The split-register ticket: set (synchronously) by the wrapped `materialize` just before it
|
|
574
|
+
// delegates, consumed by the shadowed `backend.registerQuery` below — which registers the LOCAL
|
|
575
|
+
// half only (the Store's SSR-seed + `unknown` pre-marking has already run) and defers the remote
|
|
576
|
+
// retain to the lease resolution. Everything else (unlabeled queries, React retains, re-registers)
|
|
577
|
+
// flows through untouched.
|
|
578
|
+
let labeledTicket = null;
|
|
579
|
+
const origRegisterQuery = backend.registerQuery.bind(backend);
|
|
580
|
+
backend.registerQuery = (qid, ast, remote, channel) => {
|
|
581
|
+
if (labeledTicket === null || remote === undefined) {
|
|
582
|
+
origRegisterQuery(qid, ast, remote, channel);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const ticket = labeledTicket;
|
|
586
|
+
labeledTicket = null;
|
|
587
|
+
ticket.consumed = true;
|
|
588
|
+
// The LOCAL half of the split retain (the backend's documented split-retain shape): the remote
|
|
589
|
+
// attaches via `retainRemoteQuery` on the channel the lease names, once it answers.
|
|
590
|
+
origRegisterQuery(qid, ast, undefined);
|
|
591
|
+
};
|
|
592
|
+
const origMaterialize = store.materialize.bind(store);
|
|
593
|
+
/** The G-v labeled-materialize: synchronous local view now, remote retain when the lease answers. */
|
|
594
|
+
const materializeLabeled = (query, mOpts) => {
|
|
595
|
+
const remote = { name: query.name, args: query.args };
|
|
596
|
+
const ast = query.ast();
|
|
597
|
+
// E3 parity (201-LOCAL-ONLY-TABLES-DESIGN.md): the unlabeled remote path rejects a remote query
|
|
598
|
+
// naming a local-only table synchronously inside materialize; the labeled path defers the
|
|
599
|
+
// remote register past the lease, so run the SAME guard here — identical throw, identical
|
|
600
|
+
// timing, no view leaked.
|
|
601
|
+
for (const t of collectAstTables(ast)) {
|
|
602
|
+
if (localTables.has(t)) {
|
|
603
|
+
throw new Error(`remote query "${remote.name}" references local-only table "${t}" — local tables never cross the wire (201-LOCAL-ONLY-TABLES-DESIGN.md E3).`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const ticket = { consumed: false };
|
|
607
|
+
labeledTicket = ticket;
|
|
608
|
+
let view;
|
|
609
|
+
try {
|
|
610
|
+
view = origMaterialize(query, mOpts);
|
|
611
|
+
}
|
|
612
|
+
finally {
|
|
613
|
+
labeledTicket = null;
|
|
614
|
+
}
|
|
615
|
+
const localQid = view.qid;
|
|
616
|
+
// The Store pre-marked the view `unknown` (a remote-identity register under a lifecycle
|
|
617
|
+
// backend), but the local-half register flipped it back to `complete` (a local-only
|
|
618
|
+
// registration is synchronously authoritative). Re-flip for the lease window so the view never
|
|
619
|
+
// reads server-authoritative before ANY authority answered — the retain below recomputes it
|
|
620
|
+
// against real hydration. (`readOnce` on a labeled query correctly waits because of this.)
|
|
621
|
+
if (ticket.consumed)
|
|
622
|
+
flipResultTypeUnknown(view);
|
|
623
|
+
let released = false;
|
|
624
|
+
let retainQid;
|
|
625
|
+
let roomKey;
|
|
626
|
+
// I-iii: the lifecycle system-stream claims THIS VIEW holds (claimed once per key when its
|
|
627
|
+
// lease resolves; released with the view — the LAST holder of a scope/doc drops the sub).
|
|
628
|
+
const viewLifecycleClaims = new Set();
|
|
629
|
+
// I-iv/I-v: this view's hook — `adoptRoom` (an upgrade joins the view to the new room state)
|
|
630
|
+
// and `clearRoom` (the downgrade dismantled the room state wholesale — forget it so destroy
|
|
631
|
+
// never decrements a dead record). Registered in `labeledViewHooks` for EVERY labeled view so
|
|
632
|
+
// the downgrade dance (which runs from the renewal loop, no view in scope) can find and
|
|
633
|
+
// re-candidate each surviving view; used as the candidate hook for the daemon-attached shape.
|
|
634
|
+
const viewHook = {
|
|
635
|
+
adoptRoom: (key, state) => {
|
|
636
|
+
if (released)
|
|
637
|
+
return; // a released view never joins (its retain is already gone)
|
|
638
|
+
roomKey = key;
|
|
639
|
+
state.refCount++;
|
|
640
|
+
},
|
|
641
|
+
clearRoom: () => {
|
|
642
|
+
roomKey = undefined;
|
|
643
|
+
},
|
|
644
|
+
};
|
|
645
|
+
const hookKey = remoteKey(remote);
|
|
646
|
+
let viewHooks = labeledViewHooks.get(hookKey);
|
|
647
|
+
if (viewHooks === undefined)
|
|
648
|
+
labeledViewHooks.set(hookKey, (viewHooks = new Set()));
|
|
649
|
+
viewHooks.add(viewHook);
|
|
650
|
+
/** Fail-open: retain on the daemon, indistinguishable from an unlabeled query. The ONE lease
|
|
651
|
+
* already resolved (when it succeeded) is handed to the daemon resolver so the subscribe
|
|
652
|
+
* presents exactly that token — one POST per subscribe, the unlabeled cadence. */
|
|
653
|
+
const attachDaemon = (target) => {
|
|
654
|
+
if (target)
|
|
655
|
+
tokenHandoffs.set(remoteKey(remote), { target, at: Date.now() });
|
|
656
|
+
retainQid = nextRetainQid++;
|
|
657
|
+
backend.retainRemoteQuery(retainQid, remote, localQid, ast);
|
|
658
|
+
};
|
|
659
|
+
/** Room-served: ensure the shared room source/gate, promote the engine per the lease's table
|
|
660
|
+
* specs BEFORE retaining, then retain the sub on the room channel with the roomToken handed
|
|
661
|
+
* to the resolver. `doorbellScope` (from the lease's lifecycle block) is pinned on the room
|
|
662
|
+
* state so a later I-v downgrade can re-candidate this query. */
|
|
663
|
+
const attachRoom = (rt, doorbellScope) => {
|
|
664
|
+
const key = remoteKey(remote);
|
|
665
|
+
const existing = roomQueries.get(key);
|
|
666
|
+
if (existing && existing.sourceKey !== rt.sourceKey) {
|
|
667
|
+
anomaly("source-key-changed", remote, `this lease names sourceKey ${JSON.stringify(rt.sourceKey)} but the live sub is on ${JSON.stringify(existing.sourceKey)} (teardown + re-register is the §7.6 rare-case follow-up, deferred; this view stays local-only)`);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
const room = ensureRoom(rt);
|
|
671
|
+
promoteRoomTables(room, rt.tables);
|
|
672
|
+
// Only the retain that CREATES the wire sub consumes a token at subscribe time — hand one
|
|
673
|
+
// exactly then (a refcount-only retain issues no wire subscribe; the age cap covers races).
|
|
674
|
+
if (!existing) {
|
|
675
|
+
tokenHandoffs.set(key, { target: { leaseToken: rt.roomToken, wsEndpoint: rt.wsEndpoint }, at: Date.now() });
|
|
676
|
+
}
|
|
677
|
+
retainQid = nextRetainQid++;
|
|
678
|
+
backend.retainRemoteQuery(retainQid, remote, localQid, ast, rt.sourceKey);
|
|
679
|
+
let state = existing;
|
|
680
|
+
if (!state) {
|
|
681
|
+
state = { remote, sourceKey: rt.sourceKey, sourceQid: retainQid, refCount: 0, exp: rt.exp, lifecycleClaims: new Set() };
|
|
682
|
+
roomQueries.set(key, state);
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
state.exp = Math.max(state.exp, rt.exp);
|
|
686
|
+
}
|
|
687
|
+
if (doorbellScope !== undefined)
|
|
688
|
+
state.doorbellScope = doorbellScope;
|
|
689
|
+
state.refCount++;
|
|
690
|
+
roomKey = key;
|
|
691
|
+
scheduleRenewal(key, state);
|
|
692
|
+
};
|
|
693
|
+
// Resolve-then-register: the lease FIRST; the register follows its verdict.
|
|
694
|
+
void (async () => {
|
|
695
|
+
let lease;
|
|
696
|
+
try {
|
|
697
|
+
lease = await postLease(remote);
|
|
698
|
+
}
|
|
699
|
+
catch (err) {
|
|
700
|
+
// The lease POST itself failed: fail OPEN to the daemon with no handoff — the daemon
|
|
701
|
+
// retain's own resolver re-leases (and the transport's resync retries), exactly an
|
|
702
|
+
// unlabeled query's recovery story.
|
|
703
|
+
anomaly("lease-failed", remote, `query lease failed: ${String(err?.message ?? err)}`);
|
|
704
|
+
if (!released && !realtimeClosed)
|
|
705
|
+
attachDaemon();
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (released || realtimeClosed)
|
|
709
|
+
return;
|
|
710
|
+
try {
|
|
711
|
+
if (lease.realtime === undefined) {
|
|
712
|
+
attachDaemon({ leaseToken: lease.leaseToken, wsEndpoint: lease.wsEndpoint });
|
|
713
|
+
// I-iv: a daemon-attached labeled view under a doorbell scope is an UPGRADE CANDIDATE —
|
|
714
|
+
// the occupancy stream's 0→≥1 transition re-leases it and (block permitting) retargets
|
|
715
|
+
// the whole (name, args) sub onto the room. A blockless lease (pre-lifecycle server)
|
|
716
|
+
// registers nothing: the plane stays inert-until-fed.
|
|
717
|
+
const doorbellScope = lease.lifecycle?.doorbell.scope;
|
|
718
|
+
if (doorbellScope !== undefined)
|
|
719
|
+
registerUpgradeCandidate(remote, doorbellScope, viewHook);
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
attachRoom(lease.realtime, lease.lifecycle?.doorbell.scope);
|
|
723
|
+
}
|
|
724
|
+
// I-iii: retain the lease's lifecycle system streams on the DAEMON channel — for the
|
|
725
|
+
// room-served AND the daemon-served (labeled, not covered) shapes alike (the doorbell
|
|
726
|
+
// rides both; the fence only where a room block exists). Absent block ⇒ no-op — a
|
|
727
|
+
// pre-lifecycle server leaves this client byte-identical.
|
|
728
|
+
claimLifecycle(viewLifecycleClaims, lease.lifecycle, remote);
|
|
729
|
+
}
|
|
730
|
+
catch (err) {
|
|
731
|
+
// Fail OPEN, exactly like a lease without a block: a room-attach throw (most plausibly a
|
|
732
|
+
// lease `where` this bundle's schema cannot compile — version skew) must not strand the
|
|
733
|
+
// view local-only. `retainQid === undefined` ⇒ no retain was established (room OR daemon),
|
|
734
|
+
// so the daemon fallback cannot double-attach; a throw AFTER a successful retain (a
|
|
735
|
+
// lifecycle claim, say) leaves the live sub alone. Partial promotion is harmless (it is
|
|
736
|
+
// idempotent, and the room gate re-proves any routed write) — but never leave the room
|
|
737
|
+
// token where the daemon resolver could eat it.
|
|
738
|
+
anomaly("lease-failed", remote, `realtime attach failed: ${String(err?.message ?? err)} (falling back to the daemon lease)`);
|
|
739
|
+
if (!released && !realtimeClosed && retainQid === undefined) {
|
|
740
|
+
tokenHandoffs.delete(remoteKey(remote));
|
|
741
|
+
try {
|
|
742
|
+
attachDaemon({ leaseToken: lease.leaseToken, wsEndpoint: lease.wsEndpoint });
|
|
743
|
+
}
|
|
744
|
+
catch (fallbackErr) {
|
|
745
|
+
anomaly("lease-failed", remote, `daemon fallback failed: ${String(fallbackErr?.message ?? fallbackErr)}`);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
})();
|
|
750
|
+
// Teardown rides the view: release the remote retain (room or daemon) with the local view, and
|
|
751
|
+
// drop the room query's refcount/renewal when the last view goes.
|
|
752
|
+
const origDestroy = view.destroy.bind(view);
|
|
753
|
+
view.destroy = () => {
|
|
754
|
+
if (!released) {
|
|
755
|
+
released = true;
|
|
756
|
+
if (retainQid !== undefined)
|
|
757
|
+
backend.releaseRemoteQuery(retainQid);
|
|
758
|
+
// I-iv/I-v: drop this view's hook — from the per-key hook registry and the candidate set
|
|
759
|
+
// (both no-ops when it was never a candidate; the candidate's own last-view release frees
|
|
760
|
+
// any fence-stream claims a downgrade parked on it).
|
|
761
|
+
viewHooks.delete(viewHook);
|
|
762
|
+
if (viewHooks.size === 0)
|
|
763
|
+
labeledViewHooks.delete(hookKey);
|
|
764
|
+
dropUpgradeCandidate(remote, viewHook);
|
|
765
|
+
// I-iii: this view's lifecycle claims drop with it; the LAST holder of a key releases
|
|
766
|
+
// the system sub (the backend's folded fence/occupancy state deliberately survives).
|
|
767
|
+
releaseLifecycle(viewLifecycleClaims);
|
|
768
|
+
if (roomKey !== undefined) {
|
|
769
|
+
const state = roomQueries.get(roomKey);
|
|
770
|
+
if (state && --state.refCount <= 0) {
|
|
771
|
+
if (state.renewTimer !== undefined)
|
|
772
|
+
clearTimeout(state.renewTimer);
|
|
773
|
+
roomQueries.delete(roomKey);
|
|
774
|
+
// …including any claims the renewal loop made on this query's behalf.
|
|
775
|
+
releaseLifecycle(state.lifecycleClaims);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
origDestroy();
|
|
780
|
+
};
|
|
781
|
+
return view;
|
|
782
|
+
};
|
|
783
|
+
store.materialize = ((query, mOpts) => {
|
|
784
|
+
const label = query.realtime;
|
|
785
|
+
if (label === undefined || typeof query.name !== "string")
|
|
786
|
+
return origMaterialize(query, mOpts);
|
|
787
|
+
return materializeLabeled(query, mOpts);
|
|
69
788
|
});
|
|
789
|
+
// Local-table persistence (207 §5.2): attach immediately after the store exists — before any
|
|
790
|
+
// app write can reach `writeLocal` — and AWAIT the initial restore (one `getAll` over small
|
|
791
|
+
// tables) so the first render never flashes empty local state. Restored rows arrive as ordinary
|
|
792
|
+
// deltas, so correctness never needed the gate — only UX does.
|
|
793
|
+
let persistence;
|
|
794
|
+
if (opts.persistLocal) {
|
|
795
|
+
persistence = attachLocalPersistence(backend, opts.schema, opts.persistLocal);
|
|
796
|
+
await persistence.ready;
|
|
797
|
+
}
|
|
70
798
|
// Drain folds before the tab goes away (FOLDED-MUTATIONS-DESIGN §0.1/§3): a fold deliberately
|
|
71
799
|
// holds its server write through the debounce window, so an unclean navigation would otherwise
|
|
72
800
|
// lose the applied-locally tail. `pagehide`/`beforeunload` is the last-chance flush; `close`
|
|
73
|
-
// also drains.
|
|
801
|
+
// also drains. The same hook is the persistence layer's best-effort final forward/persist
|
|
802
|
+
// (207 §9 — bounds the unacked-tail loss). (Best-effort: an unclean crash still loses the
|
|
803
|
+
// tail — an accepted cost, §0.1.)
|
|
74
804
|
const flushFolds = () => backend.flushFolds();
|
|
805
|
+
const onPageHide = () => {
|
|
806
|
+
flushFolds();
|
|
807
|
+
void persistence?.flush();
|
|
808
|
+
};
|
|
75
809
|
// Structural typing — this package has no DOM lib, but the browser global carries these.
|
|
76
810
|
const target = globalThis;
|
|
77
|
-
target.addEventListener?.("pagehide",
|
|
78
|
-
target.addEventListener?.("beforeunload",
|
|
811
|
+
target.addEventListener?.("pagehide", onPageHide);
|
|
812
|
+
target.addEventListener?.("beforeunload", onPageHide);
|
|
79
813
|
return {
|
|
80
814
|
store,
|
|
81
815
|
backend,
|
|
@@ -84,12 +818,118 @@ export async function createRindleClient(opts) {
|
|
|
84
818
|
clientID,
|
|
85
819
|
close: () => {
|
|
86
820
|
flushFolds();
|
|
87
|
-
target.removeEventListener?.("pagehide",
|
|
88
|
-
target.removeEventListener?.("beforeunload",
|
|
821
|
+
target.removeEventListener?.("pagehide", onPageHide);
|
|
822
|
+
target.removeEventListener?.("beforeunload", onPageHide);
|
|
823
|
+
persistence?.close(); // releases leadership + the channel + the IDB handle (207 P10)
|
|
824
|
+
// Realtime teardown: renewal timers first (no renewal may fire into a closing client), then
|
|
825
|
+
// every room socket; in-flight lease resolutions are made inert via the flag.
|
|
826
|
+
realtimeClosed = true;
|
|
827
|
+
upgradeCandidates.clear(); // no doorbell may retarget into a closing client
|
|
828
|
+
for (const state of roomQueries.values()) {
|
|
829
|
+
if (state.renewTimer !== undefined)
|
|
830
|
+
clearTimeout(state.renewTimer);
|
|
831
|
+
}
|
|
832
|
+
roomQueries.clear();
|
|
833
|
+
for (const room of rooms.values())
|
|
834
|
+
room.source.close();
|
|
835
|
+
rooms.clear();
|
|
89
836
|
source.close();
|
|
90
837
|
},
|
|
838
|
+
__realtimeInspect: () => ({
|
|
839
|
+
rooms: Object.fromEntries([...rooms].map(([sourceKey, room]) => [
|
|
840
|
+
sourceKey,
|
|
841
|
+
{
|
|
842
|
+
wsEndpoint: room.wsEndpoint,
|
|
843
|
+
// Read back from the backend's routing table (H-iii) — shape-compatible with G-v's
|
|
844
|
+
// client-held map (table → joinKeyCols).
|
|
845
|
+
promoted: Object.fromEntries([...backend.roomTablesFor(sourceKey)].map(([t, spec]) => [t, [...spec.joinKeyCols]])),
|
|
846
|
+
queries: Object.fromEntries([...roomQueries]
|
|
847
|
+
.filter(([, s]) => s.sourceKey === sourceKey)
|
|
848
|
+
.map(([key, s]) => [
|
|
849
|
+
key,
|
|
850
|
+
{ name: s.remote.name, sourceQid: s.sourceQid, exp: s.exp, refCount: s.refCount },
|
|
851
|
+
])),
|
|
852
|
+
},
|
|
853
|
+
])),
|
|
854
|
+
}),
|
|
91
855
|
};
|
|
92
856
|
}
|
|
857
|
+
/** Map a lease `RoomTableSpec.writable` onto the wasm engine's {@link WritableDescriptor}. An
|
|
858
|
+
* absent `where` on the predicate arm means NO row-local constraint — every row the room holds
|
|
859
|
+
* for the table is in the writable scope (the spec contract) — i.e. the engine's `all` arm. The
|
|
860
|
+
* spec's `joinKeyCols`/`footprintWhere` deliberately do NOT cross this boundary — they live in
|
|
861
|
+
* the backend's per-(sourceKey, table) routing record (H-iii, `backend.roomTablesFor`). */
|
|
862
|
+
function toWritableDescriptor(spec) {
|
|
863
|
+
if (spec.writable.kind === "none")
|
|
864
|
+
return { kind: "none" };
|
|
865
|
+
return spec.writable.where === undefined
|
|
866
|
+
? { kind: "all" }
|
|
867
|
+
: { kind: "predicate", where: spec.writable.where };
|
|
868
|
+
}
|
|
869
|
+
/** Flip a just-materialized labeled view back to `unknown` for the lease-resolve window. The
|
|
870
|
+
* plural `FlatArrayView` exposes `setResultType`; a `.one()` query's `SingularView` wrapper hides
|
|
871
|
+
* it behind its (runtime-visible) `inner` — reach through. Best-effort by design: the deferred
|
|
872
|
+
* retain recomputes the lifecycle authoritatively the moment it attaches, and the Store keeps
|
|
873
|
+
* routing backend transitions to the SAME underlying view either way. */
|
|
874
|
+
function flipResultTypeUnknown(view) {
|
|
875
|
+
const v = view;
|
|
876
|
+
if (typeof v.setResultType === "function")
|
|
877
|
+
v.setResultType("unknown");
|
|
878
|
+
else if (typeof v.inner?.setResultType === "function")
|
|
879
|
+
v.inner.setResultType("unknown");
|
|
880
|
+
}
|
|
881
|
+
/** Every base table an AST tree can draw from (root, related subtrees, EXISTS children) — the E3
|
|
882
|
+
* guard's input. Mirrors the backend's own `collectTables`. */
|
|
883
|
+
function collectAstTables(ast, out = new Set()) {
|
|
884
|
+
out.add(ast.table);
|
|
885
|
+
for (const rel of ast.related ?? [])
|
|
886
|
+
collectAstTables(rel.subquery, out);
|
|
887
|
+
collectConditionTables(ast.where, out);
|
|
888
|
+
collectConditionTables(ast.having, out);
|
|
889
|
+
return out;
|
|
890
|
+
}
|
|
891
|
+
function collectConditionTables(cond, out) {
|
|
892
|
+
if (cond === undefined)
|
|
893
|
+
return;
|
|
894
|
+
if (cond.type === "and" || cond.type === "or") {
|
|
895
|
+
for (const c of cond.conditions)
|
|
896
|
+
collectConditionTables(c, out);
|
|
897
|
+
}
|
|
898
|
+
else if (cond.type === "correlatedSubquery") {
|
|
899
|
+
collectAstTables(cond.related.subquery, out);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
/** The (name, args) sub identity — key-order-stable, mirroring the backend's own `remoteKey` so
|
|
903
|
+
* the client-side room bookkeeping groups retains exactly as the backend dedups subs. */
|
|
904
|
+
function remoteKey(remote) {
|
|
905
|
+
return stableJson([remote.name, remote.args]);
|
|
906
|
+
}
|
|
907
|
+
const SYSTEM_TABLES = new Set([
|
|
908
|
+
SCOPE_SESSIONS_TABLE,
|
|
909
|
+
ROOM_WATERMARK_TABLE,
|
|
910
|
+
ROOM_CLIENT_MUTATIONS_TABLE,
|
|
911
|
+
ROOM_MUTATION_OUTCOMES_TABLE,
|
|
912
|
+
]);
|
|
913
|
+
function isSystemTable(table) {
|
|
914
|
+
return SYSTEM_TABLES.has(table);
|
|
915
|
+
}
|
|
916
|
+
/** The idempotence key a lifecycle retain is claimed under: the minted predicate's full identity
|
|
917
|
+
* (table + scope/doc/clientId) — two labeled queries on one scope share ONE doorbell sub; two
|
|
918
|
+
* docs' fences never alias. */
|
|
919
|
+
function systemEntryKey(entry) {
|
|
920
|
+
return stableJson([entry.table, entry.scope ?? null, entry.doc ?? null, entry.clientId ?? null]);
|
|
921
|
+
}
|
|
922
|
+
function stableJson(value) {
|
|
923
|
+
if (value === null || typeof value !== "object")
|
|
924
|
+
return JSON.stringify(value);
|
|
925
|
+
if (Array.isArray(value))
|
|
926
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
927
|
+
const obj = value;
|
|
928
|
+
return `{${Object.keys(obj)
|
|
929
|
+
.sort()
|
|
930
|
+
.map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`)
|
|
931
|
+
.join(",")}}`;
|
|
932
|
+
}
|
|
93
933
|
class RindleApiHttpError extends Error {
|
|
94
934
|
path;
|
|
95
935
|
status;
|