@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/src/client.ts
CHANGED
|
@@ -9,7 +9,18 @@
|
|
|
9
9
|
// and the mutation queue flushing confirmed in-order batches through the mutate route
|
|
10
10
|
// (rejection reasons surface via `onRejected`).
|
|
11
11
|
|
|
12
|
-
import
|
|
12
|
+
import { localTableNames } from "@rindle/client";
|
|
13
|
+
import type {
|
|
14
|
+
Ast,
|
|
15
|
+
ColsMap,
|
|
16
|
+
Condition,
|
|
17
|
+
MutationEnvelope,
|
|
18
|
+
Query,
|
|
19
|
+
QueryId,
|
|
20
|
+
RealtimeQueryLabel,
|
|
21
|
+
RemoteQuery,
|
|
22
|
+
Schema,
|
|
23
|
+
} from "@rindle/client";
|
|
13
24
|
import {
|
|
14
25
|
RemoteOptimisticSource,
|
|
15
26
|
WsTransport,
|
|
@@ -17,11 +28,21 @@ import {
|
|
|
17
28
|
} from "@rindle/remote";
|
|
18
29
|
import type { PushOutcome, RemoteOptimisticConnection, Transport } from "@rindle/remote";
|
|
19
30
|
import { initWasm } from "@rindle/wasm";
|
|
31
|
+
import type { WritableDescriptor } from "@rindle/wasm";
|
|
20
32
|
|
|
21
33
|
import type { OptimisticBackend } from "./backend.ts";
|
|
22
34
|
import type { ClientRegistry, MutationTx } from "./backend.ts";
|
|
23
35
|
import { resetStableClientID, stableClientID } from "./client-id.ts";
|
|
36
|
+
import {
|
|
37
|
+
LIFECYCLE_QUERY_NAME,
|
|
38
|
+
ROOM_CLIENT_MUTATIONS_TABLE,
|
|
39
|
+
ROOM_MUTATION_OUTCOMES_TABLE,
|
|
40
|
+
ROOM_WATERMARK_TABLE,
|
|
41
|
+
SCOPE_SESSIONS_TABLE,
|
|
42
|
+
type SystemStreamTable,
|
|
43
|
+
} from "./system-streams.ts";
|
|
24
44
|
import { createOptimisticStore, type MutateFn } from "./index.ts";
|
|
45
|
+
import { attachLocalPersistence, type LocalPersistence, type PersistLocalOptions } from "./local-persist.ts";
|
|
25
46
|
import type { Store } from "@rindle/client";
|
|
26
47
|
|
|
27
48
|
/** Must mirror `DEFAULT_RINDLE_API_ROUTES` in `@rindle/api-server` (the app-wire contract;
|
|
@@ -30,6 +51,169 @@ const DEFAULT_ROUTES = { query: "/api/rindle/query", mutate: "/api/rindle/mutate
|
|
|
30
51
|
|
|
31
52
|
export type HeadersInit = Record<string, string>;
|
|
32
53
|
|
|
54
|
+
// --------------------------------------------------------------------------- Rindle Realtime (G-v)
|
|
55
|
+
//
|
|
56
|
+
// A named query stamped with a `realtime` label (RINDLE-REALTIME-QUERY-ENABLEMENT §2.1) resolves
|
|
57
|
+
// its lease FIRST (resolve-then-register): the local AST view still materializes synchronously,
|
|
58
|
+
// but the remote retain attaches only when the lease answers — on the ROOM channel when the lease
|
|
59
|
+
// carries a `realtime` block, byte-identically on the daemon when it doesn't (fail-open). The
|
|
60
|
+
// types below mirror the api-server's `QueryLeaseRealtime`/`RoomTableSpec` wire shapes (duplicated
|
|
61
|
+
// like DEFAULT_ROUTES so the browser bundle never imports the server package).
|
|
62
|
+
|
|
63
|
+
/** One footprint table's spec on the lease wire (mirror of the api-server's `RoomTableSpec`).
|
|
64
|
+
* `footprintWhere` (H-iii lease-wire flip) is the EXACT footprint-membership predicate from the
|
|
65
|
+
* ONE unified compiler (`compileRoomScopeSpecs` — the same output the boot wire ships the room
|
|
66
|
+
* gate): present only for an exact footprint ROOT (lossless row-local extraction; the vacuous-true
|
|
67
|
+
* empty AND for an unconstrained one), ABSENT for child/correlated tables. It feeds the §3
|
|
68
|
+
* router's pk-membership read proof (`OptimisticBackend`'s routing table) — never authorization. */
|
|
69
|
+
export interface RealtimeLeaseTableSpec {
|
|
70
|
+
table: string;
|
|
71
|
+
footprintWhere?: Condition;
|
|
72
|
+
writable:
|
|
73
|
+
| { kind: "none" }
|
|
74
|
+
| { kind: "predicate"; where?: Condition; joinKeyCols: string[] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The room-serve block on a query lease (mirror of the api-server's `QueryLeaseRealtime`). */
|
|
78
|
+
export interface RealtimeLeaseBlock {
|
|
79
|
+
/** The store's gate/domain key for this room source (`connectSource`) — `"room:<profile>/<key>"`. */
|
|
80
|
+
sourceKey: string;
|
|
81
|
+
/** Where the ROOM ws opens — the lease's DEDICATED field. Never confuse it with the TOP-LEVEL
|
|
82
|
+
* `wsEndpoint` (the read-router's whole-DAEMON-session migration signal). */
|
|
83
|
+
wsEndpoint: string;
|
|
84
|
+
/** The room shell's self-authorizing signed lease (seals the APPROVED query AST) — presented as
|
|
85
|
+
* the room subscribe's `leaseToken`. */
|
|
86
|
+
roomToken: string;
|
|
87
|
+
/** Token expiry (ms epoch) — the renewal clock (renewal = a fresh lease through the app route). */
|
|
88
|
+
exp: number;
|
|
89
|
+
doc: string;
|
|
90
|
+
tables: RealtimeLeaseTableSpec[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One minted SYSTEM-STREAM lease on the `lifecycle` block (mirror of the api-server's
|
|
94
|
+
* `QueryLeaseLifecycleLease`; Slice I-iii): an ordinary daemon materialization over one of the
|
|
95
|
+
* four `_rindle_*` lifecycle tables, presented on the wire exactly like the primary lease
|
|
96
|
+
* (subscribe-with-`leaseToken`). The identity fields document the minted predicate — this client
|
|
97
|
+
* keys its retains (idempotence per (table, scope/doc/clientId)) and the backend keys its
|
|
98
|
+
* release-time row filters on them. */
|
|
99
|
+
export interface LifecycleLeaseEntry {
|
|
100
|
+
table: string;
|
|
101
|
+
leaseToken: string;
|
|
102
|
+
wsEndpoint?: string;
|
|
103
|
+
/** DOORBELL only: the §4.1 occupancy scope (= the wire room doc, `"<profile>/<key>"`). */
|
|
104
|
+
scope?: string;
|
|
105
|
+
/** FENCE entries only: the room doc. */
|
|
106
|
+
doc?: string;
|
|
107
|
+
/** FENCE ledger/outcomes when the server could client-scope the predicate. */
|
|
108
|
+
clientId?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The §4 lifecycle block on a query lease (mirror of the api-server's `QueryLeaseLifecycle`):
|
|
112
|
+
* `doorbell` on every labeled lease under the opt-in server config, `fence` (watermark + ledger
|
|
113
|
+
* + outcomes) only when the lease is ALSO room-served. Absent ⇒ this client behaves exactly as
|
|
114
|
+
* today — the whole plane is inert-until-fed. */
|
|
115
|
+
export interface LifecycleLeaseBlock {
|
|
116
|
+
doorbell: LifecycleLeaseEntry;
|
|
117
|
+
fence?: LifecycleLeaseEntry[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The §4.2 downgrade fence block on a query lease (mirror of the api-server's
|
|
121
|
+
* `QueryLeaseRealtimeFence`, Slice I-v): rides a labeled reply whose occupancy gate CLOSED
|
|
122
|
+
* (no `realtime` block) when the server could drain the room — `finalFlushSeq` is the room's
|
|
123
|
+
* last COMMITTED flush seq, the value the client's ghost holds against
|
|
124
|
+
* (`_rindle_room_watermark(doc) ≥ finalFlushSeq` through the daemon plane). A room-attached
|
|
125
|
+
* query receiving it runs the GRACEFUL downgrade dance instead of the loud legacy anomaly. */
|
|
126
|
+
export interface RealtimeFenceBlock {
|
|
127
|
+
/** The retiring room source's gate/domain key (`"room:" + doc`). */
|
|
128
|
+
sourceKey: string;
|
|
129
|
+
doc: string;
|
|
130
|
+
finalFlushSeq: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The query-lease reply as this client reads it (top-level daemon lease + optional room block
|
|
134
|
+
* + optional §4.2 downgrade fence + optional §4 lifecycle system-stream block). */
|
|
135
|
+
interface QueryLeaseWire {
|
|
136
|
+
leaseToken: string;
|
|
137
|
+
wsEndpoint?: string;
|
|
138
|
+
realtime?: RealtimeLeaseBlock;
|
|
139
|
+
realtimeFence?: RealtimeFenceBlock;
|
|
140
|
+
lifecycle?: LifecycleLeaseBlock;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export type RealtimeAnomalyKind =
|
|
144
|
+
/** A re-lease (renewal / reconnect re-resolution) came back WITHOUT a realtime block AND
|
|
145
|
+
* without a §4.2 fence — the query is no longer room-served but the server gave nothing to
|
|
146
|
+
* downgrade behind (a legacy/pre-I-v server, or `lifecycle.drainRoom` unconfigured). Surfaced
|
|
147
|
+
* loudly; a reply WITH a `realtimeFence` takes the graceful I-v dance instead. */
|
|
148
|
+
| "downgrade"
|
|
149
|
+
/** The I-v ghost is STUCK (§7.5): its watermark fence cleared but sent room-domain mids never
|
|
150
|
+
* resolved (sent-but-undelivered when the socket died — undecidable in general). The ghost
|
|
151
|
+
* holds — no timeout-retire is invented — and the mids are named once, actionably. */
|
|
152
|
+
| "downgrade-stuck"
|
|
153
|
+
/** A lease named a DIFFERENT `sourceKey` than the query's live room sub — surfaced loudly, no
|
|
154
|
+
* re-attach. Deliberately NOT composed from demote+upgrade (deferred to §7.6's rare-case
|
|
155
|
+
* follow-up): a sourceKey-change reply carries a realtime block for the NEW room but NO
|
|
156
|
+
* fence for the OLD one, and without `finalFlushSeq` the old slice cannot be ghosted soundly. */
|
|
157
|
+
| "source-key-changed"
|
|
158
|
+
/** The lease POST failed or the room attach threw. The initial-materialize case fails OPEN to
|
|
159
|
+
* the daemon path (indistinguishable from an unlabeled query's recovery). */
|
|
160
|
+
| "lease-failed";
|
|
161
|
+
|
|
162
|
+
/** A loud realtime lease anomaly (always ALSO `console.error`'d). */
|
|
163
|
+
export interface RealtimeAnomaly {
|
|
164
|
+
kind: RealtimeAnomalyKind;
|
|
165
|
+
name: string;
|
|
166
|
+
args: unknown;
|
|
167
|
+
message: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Rindle Realtime client knobs (Slice G-v). All optional — an app with no labeled queries never
|
|
171
|
+
* touches any of this. */
|
|
172
|
+
export interface RealtimeClientOptions {
|
|
173
|
+
/** Build the ROOM ws transport for a lease's `realtime.wsEndpoint`. Default
|
|
174
|
+
* `(endpoint) => new WsTransport(endpoint)`. Injectable for tests / custom ws impls. */
|
|
175
|
+
transport?: (endpoint: string) => Transport;
|
|
176
|
+
/** Loud anomaly surface — see {@link RealtimeAnomaly}. Every anomaly is also `console.error`'d. */
|
|
177
|
+
onAnomaly?: (anomaly: RealtimeAnomaly) => void;
|
|
178
|
+
/** How long before a room lease's `exp` the proactive token renewal fires (default 30s). The
|
|
179
|
+
* renewal is a FRESH lease through the app query route (renewal-as-reauthorization), and the
|
|
180
|
+
* live room sub proactively re-subscribes with the fresh token so the shell's TTL backstop
|
|
181
|
+
* never fires on a healthy session. */
|
|
182
|
+
renewMarginMs?: number;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Read-only realtime bookkeeping snapshot ({@link RindleClient.__realtimeInspect}) — test/devtools
|
|
186
|
+
* introspection, mirroring the backend's `__inspect` convention. */
|
|
187
|
+
export interface RealtimeInspect {
|
|
188
|
+
rooms: Record<
|
|
189
|
+
string,
|
|
190
|
+
{
|
|
191
|
+
wsEndpoint: string;
|
|
192
|
+
/** Promoted table → the lease's `joinKeyCols` ("room mutators never change join keys", the
|
|
193
|
+
* §3 write proof's rule #3) — read back from the BACKEND's routing table (H-iii:
|
|
194
|
+
* `backend.roomTablesFor`, the one source of truth; the client keeps no shadow copy), and
|
|
195
|
+
* deliberately never crossing the wasm `WritableDescriptor` ABI. Shape unchanged since G-v. */
|
|
196
|
+
promoted: Record<string, string[]>;
|
|
197
|
+
/** Live room-retained queries on this room, by remote key. */
|
|
198
|
+
queries: Record<string, { name: string; sourceQid: QueryId; exp: number; refCount: number }>;
|
|
199
|
+
}
|
|
200
|
+
>;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Default {@link RealtimeClientOptions.renewMarginMs}. */
|
|
204
|
+
const DEFAULT_RENEW_MARGIN_MS = 30_000;
|
|
205
|
+
/** Renewal-delay floor: a nearly-expired lease still renews soon, but never in a hot loop. */
|
|
206
|
+
const MIN_RENEW_DELAY_MS = 1_000;
|
|
207
|
+
/** Retry delay after a failed renewal POST (only while the current token is still live). */
|
|
208
|
+
const RENEW_RETRY_MS = 5_000;
|
|
209
|
+
/** A one-shot token handoff not consumed within this window is stale — the resolver falls through
|
|
210
|
+
* to a fresh lease POST instead of presenting a token the server may already refuse. */
|
|
211
|
+
const HANDOFF_MAX_AGE_MS = 15_000;
|
|
212
|
+
/** Client-minted remote-retain qids live in their own high band so they can never collide with the
|
|
213
|
+
* Store's own 1, 2, 3, … view qids or the reserved per-channel lmid qid 0. Exact in f64 (the wire
|
|
214
|
+
* number type), far below 2^53. */
|
|
215
|
+
const REALTIME_RETAIN_QID_BASE = 2 ** 30;
|
|
216
|
+
|
|
33
217
|
export interface RindleClientOptions<S extends ColsMap, R extends ClientRegistry> {
|
|
34
218
|
schema: Schema<S>;
|
|
35
219
|
/** The PREDICTED mutators (the API server holds the authoritative twins by name). */
|
|
@@ -59,9 +243,32 @@ export interface RindleClientOptions<S extends ColsMap, R extends ClientRegistry
|
|
|
59
243
|
* reload keeps it; falls back to a fresh random id when web storage is unavailable. Pass a value
|
|
60
244
|
* to override. */
|
|
61
245
|
clientID?: string;
|
|
62
|
-
/** A policy rejection's reason (the prediction's snap-back rides the lmid release).
|
|
246
|
+
/** A policy rejection's reason (the prediction's snap-back rides the lmid release). Fires for
|
|
247
|
+
* BOTH planes since H-v: the HTTP mutate route's per-envelope rejections AND a room's
|
|
248
|
+
* `mutationOutcome {kind:"rejected"}` frames — one surface, whichever authority said no. */
|
|
63
249
|
onRejected?: (envelope: MutationEnvelope, reason: string) => void;
|
|
250
|
+
/** Persist `local: true` tables across reloads and keep them live-coherent across tabs
|
|
251
|
+
* (`207-LOCAL-TABLE-PERSISTENCE-DESIGN.md`). `user` is the storage identity (§3.2) — one IDB
|
|
252
|
+
* database per (origin, user); pass a sentinel like `"anon"` for a signed-out mode. When set,
|
|
253
|
+
* `createRindleClient` awaits the initial restore before returning, so the first render never
|
|
254
|
+
* flashes empty local state (§5.2). Logout is `deleteLocalPersistence(user)` — never implicit.
|
|
255
|
+
* Per-table opt-out: declare `table(name, { local: "session" })` for local state that must stay
|
|
256
|
+
* ephemeral and per-tab (e.g. selection) even with persistence on (§5.4). */
|
|
257
|
+
persistLocal?: PersistLocalOptions;
|
|
64
258
|
queue?: { maxBatch?: number; retryDelayMs?: (attempt: number) => number };
|
|
259
|
+
/** The explicit confirming-stream OVERRIDE (RINDLE-REALTIME-QUERY-ENABLEMENT §7.1/§3): which
|
|
260
|
+
* domain's ledger a mutation is dealt from — its mid comes from that domain's counter, it ships
|
|
261
|
+
* on that domain's channel (§7.5), and only that domain's confirm watermark retires it. Since
|
|
262
|
+
* Slice H-iii a returned string PINS that domain verbatim (no proof runs); `undefined` — or no
|
|
263
|
+
* policy at all, the default — DERIVES the route per §3 from the prediction run's write/read
|
|
264
|
+
* capture (prove-or-slow-path; any unproven condition routes to the daemon). With no room gate
|
|
265
|
+
* connected the derivation is `"daemon"`, byte-for-byte as before. A room route the gate
|
|
266
|
+
* DEOPTS at commit (H-iv-b) is recovered automatically since H-v: the client re-enqueues the
|
|
267
|
+
* same logical mutation onto the daemon stream (prediction applied throughout, one burnt room
|
|
268
|
+
* mid), so deriving is safe for realtime apps. */
|
|
269
|
+
domainPolicy?: (name: string, args: unknown) => string | undefined;
|
|
270
|
+
/** Rindle Realtime client knobs (G-v resolve-then-register) — see {@link RealtimeClientOptions}. */
|
|
271
|
+
realtime?: RealtimeClientOptions;
|
|
65
272
|
/** Development-only recovery knobs. Keep off in production: a mutation gap means state loss
|
|
66
273
|
* or two writers sharing a clientID, and should be investigated. */
|
|
67
274
|
dev?: {
|
|
@@ -82,6 +289,9 @@ export interface RindleClient<S extends ColsMap, R extends ClientRegistry> {
|
|
|
82
289
|
flushFolds(): void;
|
|
83
290
|
clientID: string;
|
|
84
291
|
close(): void;
|
|
292
|
+
/** Read-only realtime bookkeeping snapshot (rooms, promoted tables + their client-held
|
|
293
|
+
* `joinKeyCols`, live room queries) — the `__inspect`-convention test/devtools hook. */
|
|
294
|
+
__realtimeInspect(): RealtimeInspect;
|
|
85
295
|
}
|
|
86
296
|
|
|
87
297
|
export async function createRindleClient<S extends ColsMap, R extends ClientRegistry>(
|
|
@@ -106,6 +316,47 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
|
|
|
106
316
|
return text ? JSON.parse(text) : undefined;
|
|
107
317
|
};
|
|
108
318
|
|
|
319
|
+
// The ONE app-lease POST both legs share. Sends the stable `clientId` so the api-server/router
|
|
320
|
+
// can use it as the anonymous routing key (READ-ROUTER-DESIGN.md §2.2); the reply's top-level
|
|
321
|
+
// fields are the daemon lease, and a room-served labeled query ADDITIONALLY carries `realtime`.
|
|
322
|
+
const postLease = (remote: RemoteQuery): Promise<QueryLeaseWire> =>
|
|
323
|
+
post(routes.query, { name: remote.name, args: remote.args, clientId: clientID }) as Promise<QueryLeaseWire>;
|
|
324
|
+
|
|
325
|
+
// One-shot fresh-token handoffs, by remote key: G-v's resolve-then-register (and the proactive
|
|
326
|
+
// renewal) has ALREADY leased when the subscribe fires, so the resolver consumes the handed
|
|
327
|
+
// token instead of POSTing a second time — one lease per subscribe, exactly the unlabeled
|
|
328
|
+
// cadence. Age-capped: an entry no subscribe consumed (e.g. a refcount-only retain) must not
|
|
329
|
+
// serve a stale token to a much-later re-subscribe (which re-leases fresh instead).
|
|
330
|
+
const tokenHandoffs = new Map<string, { target: { leaseToken: string; wsEndpoint?: string }; at: number }>();
|
|
331
|
+
const takeHandoff = (key: string): { leaseToken: string; wsEndpoint?: string } | undefined => {
|
|
332
|
+
const handed = tokenHandoffs.get(key);
|
|
333
|
+
if (!handed) return undefined;
|
|
334
|
+
tokenHandoffs.delete(key);
|
|
335
|
+
return Date.now() - handed.at <= HANDOFF_MAX_AGE_MS ? handed.target : undefined;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
/** RE-resolve a SYSTEM (lifecycle) subscription (Slice I-iii — a reconnect / gap repair /
|
|
339
|
+
* overflow re-subscribe whose mint-time handoff is long consumed). `_rindle/lifecycle` is a
|
|
340
|
+
* reserved CLIENT-side name (like the lmid query's): the api-server cannot lease it by name,
|
|
341
|
+
* so the re-resolution re-leases the PARENT labeled query — renewal-as-reauthorization, the
|
|
342
|
+
* room-token precedent — and picks the matching entry out of the fresh `lifecycle` block. A
|
|
343
|
+
* reply without the entry throws: the server no longer minting this stream (config off, label
|
|
344
|
+
* dropped) must not silently re-attach — the source logs the failed subscribe, and I-iv/I-v
|
|
345
|
+
* own any reaction. */
|
|
346
|
+
const resolveLifecycleTarget = async (args: LifecycleRemoteArgs): Promise<{ leaseToken: string; wsEndpoint?: string }> => {
|
|
347
|
+
const out = (await postLease({ name: args.parent.name, args: args.parent.args })) as QueryLeaseWire;
|
|
348
|
+
const want = systemEntryKey(args);
|
|
349
|
+
const entry = out.lifecycle === undefined
|
|
350
|
+
? undefined
|
|
351
|
+
: [out.lifecycle.doorbell, ...(out.lifecycle.fence ?? [])].find((e) => systemEntryKey(e) === want);
|
|
352
|
+
if (entry === undefined) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
`lifecycle re-lease of "${args.parent.name}" no longer carries the ${args.table} system lease`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
return { leaseToken: entry.leaseToken, ...(entry.wsEndpoint !== undefined ? { wsEndpoint: entry.wsEndpoint } : {}) };
|
|
358
|
+
};
|
|
359
|
+
|
|
109
360
|
// Reads-leg connection: a fixed transport (tests/in-process) or a replaceable connection built
|
|
110
361
|
// from `wsUrl` (eager when present, lazy when omitted). A routed lease's `wsEndpoint` migrates it.
|
|
111
362
|
const connection: RemoteOptimisticConnection =
|
|
@@ -115,12 +366,18 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
|
|
|
115
366
|
|
|
116
367
|
const source = new RemoteOptimisticSource(connection, clientID, {
|
|
117
368
|
resolveSubscribe: async ({ remote }) => {
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
369
|
+
// A fail-open labeled register already leased — present exactly that token (see
|
|
370
|
+
// `tokenHandoffs`); otherwise lease now. Read back the follower's `wsEndpoint` for placement.
|
|
371
|
+
// A `realtime` block on a RE-resolution is deliberately IGNORED here even now that the
|
|
372
|
+
// upgrade dance exists (I-iv): retargeting from inside a reconnect's resolve would race the
|
|
373
|
+
// very re-subscribe it resolves. The §4.1 DOORBELL path owns upgrades (`runUpgrade` below)
|
|
374
|
+
// — the occupancy row that made this lease carry a block will (re)ring it.
|
|
375
|
+
const handed = takeHandoff(remoteKey(remote));
|
|
376
|
+
if (handed) return handed;
|
|
377
|
+
// A SYSTEM (lifecycle) sub re-resolves through its PARENT labeled query (I-iii) — the
|
|
378
|
+
// reserved name is never leaseable by itself (see resolveLifecycleTarget).
|
|
379
|
+
if (remote.name === LIFECYCLE_QUERY_NAME) return resolveLifecycleTarget(remote.args as LifecycleRemoteArgs);
|
|
380
|
+
const out = await postLease(remote);
|
|
124
381
|
return { leaseToken: out.leaseToken, wsEndpoint: out.wsEndpoint };
|
|
125
382
|
},
|
|
126
383
|
pushMutation: createQueuedMutationSender({
|
|
@@ -144,20 +401,789 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
|
|
|
144
401
|
const { store, backend, mutate } = createOptimisticStore(opts.schema, source, opts.mutators, {
|
|
145
402
|
clientID,
|
|
146
403
|
user: opts.user,
|
|
404
|
+
...(opts.domainPolicy ? { domainPolicy: opts.domainPolicy } : {}),
|
|
405
|
+
// Room-plane rejection parity (H-v): a room's `mutationOutcome {kind:"rejected"}` frame
|
|
406
|
+
// surfaces through the SAME callback the HTTP mutate path uses below — one app-level
|
|
407
|
+
// rejection surface, whichever authority said no.
|
|
408
|
+
...(opts.onRejected ? { onRejected: opts.onRejected } : {}),
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// ---- Rindle Realtime (G-v): resolve-then-register for LABELED queries -------------------------
|
|
412
|
+
//
|
|
413
|
+
// `store.materialize` is wrapped: an UNLABELED query takes the original path byte-identically; a
|
|
414
|
+
// query stamped with a `realtime` label materializes its local view synchronously (the Store's
|
|
415
|
+
// ordinary seed/`unknown` pre-marking runs untouched) while the remote register is SPLIT — the
|
|
416
|
+
// shadowed `backend.registerQuery` below registers the LOCAL half only, and the remote retain
|
|
417
|
+
// attaches when the lease answers: on `realtime.sourceKey`'s room channel when the lease carries
|
|
418
|
+
// a realtime block, on the daemon (fail-open, indistinguishable from unlabeled) when it doesn't.
|
|
419
|
+
|
|
420
|
+
const realtimeOpts = opts.realtime ?? {};
|
|
421
|
+
const roomTransportFactory = realtimeOpts.transport ?? ((endpoint: string) => new WsTransport(endpoint));
|
|
422
|
+
const renewMarginMs = realtimeOpts.renewMarginMs ?? DEFAULT_RENEW_MARGIN_MS;
|
|
423
|
+
const localTables = localTableNames(opts.schema);
|
|
424
|
+
let realtimeClosed = false;
|
|
425
|
+
|
|
426
|
+
const anomaly = (kind: RealtimeAnomalyKind, remote: RemoteQuery, message: string): void => {
|
|
427
|
+
// LOUD by contract: every anomaly hits the console even with a handler installed.
|
|
428
|
+
console.error(`[rindle] realtime ${kind} for query "${remote.name}": ${message}`);
|
|
429
|
+
try {
|
|
430
|
+
realtimeOpts.onAnomaly?.({ kind, name: remote.name, args: remote.args, message });
|
|
431
|
+
} catch (err) {
|
|
432
|
+
console.error("[rindle] realtime onAnomaly handler threw:", err);
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
/** One connected room: its gate key + its `RemoteOptimisticSource`. The promoted-table
|
|
437
|
+
* bookkeeping that used to live here (G-v's `promoted` map) is DELEGATED to the backend's
|
|
438
|
+
* routing table since H-iii (`backend.roomTablesFor(sourceKey)` — one source of truth for the
|
|
439
|
+
* §3 router, the idempotence check, and `__realtimeInspect`). */
|
|
440
|
+
interface RoomConnection {
|
|
441
|
+
sourceKey: string;
|
|
442
|
+
wsEndpoint: string;
|
|
443
|
+
source: RemoteOptimisticSource;
|
|
444
|
+
}
|
|
445
|
+
/** One room-retained (name, args): its ONE wire sub (`sourceQid` = the creating retain's qid),
|
|
446
|
+
* how many live views hold it, and the renewal clock. `lifecycleClaims` (I-iii) holds the
|
|
447
|
+
* system-stream claims RENEWAL-path re-leases made on this query's behalf (a renewal may mint
|
|
448
|
+
* entries the original attach never saw, e.g. the query became room-served); released when the
|
|
449
|
+
* last view drops the query. */
|
|
450
|
+
interface RoomQueryState {
|
|
451
|
+
remote: RemoteQuery;
|
|
452
|
+
sourceKey: string;
|
|
453
|
+
sourceQid: QueryId;
|
|
454
|
+
refCount: number;
|
|
455
|
+
exp: number;
|
|
456
|
+
renewTimer?: ReturnType<typeof setTimeout>;
|
|
457
|
+
lifecycleClaims: Set<string>;
|
|
458
|
+
/** The §4.1 doorbell scope this room-served query counts on (`lease.lifecycle.doorbell.scope`
|
|
459
|
+
* = the wire doc). Captured on attach/upgrade/renewal so the I-v downgrade dance — which runs
|
|
460
|
+
* from the renewal loop with no lease-block in scope for co-tenant queries — can re-register
|
|
461
|
+
* each surviving view as an upgrade candidate under the scope its next doorbell will ring. */
|
|
462
|
+
doorbellScope?: string;
|
|
463
|
+
}
|
|
464
|
+
const rooms = new Map<string, RoomConnection>();
|
|
465
|
+
const roomQueries = new Map<string, RoomQueryState>();
|
|
466
|
+
let nextRetainQid: QueryId = REALTIME_RETAIN_QID_BASE;
|
|
467
|
+
|
|
468
|
+
// ---- the §4 lifecycle SYSTEM-STREAM plane, client wiring (Slice I-iii) -----------------------
|
|
469
|
+
//
|
|
470
|
+
// A lease's `lifecycle` block names minted daemon subscriptions over the four `_rindle_*`
|
|
471
|
+
// system tables. They are retained on the DAEMON channel (that is the point of the plane: the
|
|
472
|
+
// outcome/ledger/watermark rows must reach the client with no room socket alive) through
|
|
473
|
+
// `backend.retainSystemQuery` — no store view, no user-visible table; the backend folds their
|
|
474
|
+
// rows at release time. Retains are IDEMPOTENT per (table, scope/doc/clientId): every live
|
|
475
|
+
// holder (a labeled view; a room query's renewal loop) claims a key at most once, one wire sub
|
|
476
|
+
// exists per key, and the LAST holder's release drops it. NO reactions are wired here — the
|
|
477
|
+
// doorbell-triggered re-lease is I-iv, the ghost-drop fence consumer is I-v. Absent block ⇒
|
|
478
|
+
// this whole section never runs.
|
|
479
|
+
|
|
480
|
+
/** One live system sub: the backend retain + how many holders claim it. */
|
|
481
|
+
interface SystemSubState {
|
|
482
|
+
retainQid: QueryId;
|
|
483
|
+
refCount: number;
|
|
484
|
+
}
|
|
485
|
+
const systemSubs = new Map<string, SystemSubState>();
|
|
486
|
+
|
|
487
|
+
/** Claim every entry of `block` for one holder (`claims` — the holder's own claim set; a key
|
|
488
|
+
* already claimed by THIS holder is skipped, so renewal re-presentations are idempotent).
|
|
489
|
+
* Unknown tables are skipped (forward-compat: a newer server minting a fifth stream must not
|
|
490
|
+
* break this client). */
|
|
491
|
+
const claimLifecycle = (claims: Set<string>, block: LifecycleLeaseBlock | undefined, parent: RemoteQuery): void => {
|
|
492
|
+
if (block === undefined || realtimeClosed) return;
|
|
493
|
+
for (const entry of [block.doorbell, ...(block.fence ?? [])]) {
|
|
494
|
+
if (!isSystemTable(entry.table)) continue;
|
|
495
|
+
const key = systemEntryKey(entry);
|
|
496
|
+
if (claims.has(key)) continue;
|
|
497
|
+
let live = systemSubs.get(key);
|
|
498
|
+
if (!live) {
|
|
499
|
+
// The sub's wire identity embeds the PARENT labeled query so a RE-resolution can
|
|
500
|
+
// re-lease it (resolveLifecycleTarget); the minted token is handed to the resolver so
|
|
501
|
+
// the first subscribe presents exactly it — one lease per subscribe, the G-v cadence.
|
|
502
|
+
const remote: RemoteQuery = {
|
|
503
|
+
name: LIFECYCLE_QUERY_NAME,
|
|
504
|
+
args: {
|
|
505
|
+
table: entry.table,
|
|
506
|
+
...(entry.scope !== undefined ? { scope: entry.scope } : {}),
|
|
507
|
+
...(entry.doc !== undefined ? { doc: entry.doc } : {}),
|
|
508
|
+
...(entry.clientId !== undefined ? { clientId: entry.clientId } : {}),
|
|
509
|
+
parent: { name: parent.name, args: parent.args },
|
|
510
|
+
} satisfies LifecycleRemoteArgs,
|
|
511
|
+
};
|
|
512
|
+
tokenHandoffs.set(remoteKey(remote), {
|
|
513
|
+
target: { leaseToken: entry.leaseToken, ...(entry.wsEndpoint !== undefined ? { wsEndpoint: entry.wsEndpoint } : {}) },
|
|
514
|
+
at: Date.now(),
|
|
515
|
+
});
|
|
516
|
+
const retainQid = nextRetainQid++;
|
|
517
|
+
backend.retainSystemQuery(retainQid, remote, {
|
|
518
|
+
table: entry.table,
|
|
519
|
+
...(entry.scope !== undefined ? { scope: entry.scope } : {}),
|
|
520
|
+
...(entry.doc !== undefined ? { doc: entry.doc } : {}),
|
|
521
|
+
});
|
|
522
|
+
live = { retainQid, refCount: 0 };
|
|
523
|
+
systemSubs.set(key, live);
|
|
524
|
+
}
|
|
525
|
+
live.refCount++;
|
|
526
|
+
claims.add(key);
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
/** Release one holder's claims; the LAST holder of a key releases the backend retain (the wire
|
|
531
|
+
* sub unsubscribes; the backend's folded fence/occupancy STATE deliberately survives). */
|
|
532
|
+
const releaseLifecycle = (claims: Set<string>): void => {
|
|
533
|
+
for (const key of claims) {
|
|
534
|
+
const live = systemSubs.get(key);
|
|
535
|
+
if (!live) continue;
|
|
536
|
+
if (--live.refCount <= 0) {
|
|
537
|
+
systemSubs.delete(key);
|
|
538
|
+
backend.releaseSystemQuery(live.retainQid);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
claims.clear();
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
/** Promote every lease table spec this client can hold, exactly once per (sourceKey, table) —
|
|
545
|
+
* re-leases (renewal / reconnect) must never re-promote (the engine refuses a duplicate room);
|
|
546
|
+
* the idempotence check reads the BACKEND's routing record (the one source of truth since
|
|
547
|
+
* H-iii). A footprint table absent from the client schema has nothing to promote (the client
|
|
548
|
+
* never materializes rows for it) and is skipped. The lease spec's routing half —
|
|
549
|
+
* `joinKeyCols`, the row-local writable `where`, and `footprintWhere` (H-iii) — threads through
|
|
550
|
+
* as the backend's per-(sourceKey, table) `RoomTableRouting` record, the §3 router's input;
|
|
551
|
+
* none of it crosses the wasm `WritableDescriptor` ABI. */
|
|
552
|
+
const promoteRoomTables = (room: RoomConnection, specs: RealtimeLeaseTableSpec[]): void => {
|
|
553
|
+
for (const spec of specs) {
|
|
554
|
+
if (backend.roomTablesFor(room.sourceKey).has(spec.table)) continue;
|
|
555
|
+
if (!(spec.table in opts.schema.tables)) continue;
|
|
556
|
+
backend.promoteRoomTable(spec.table, room.sourceKey, toWritableDescriptor(spec), {
|
|
557
|
+
joinKeyCols: spec.writable.kind === "predicate" ? spec.writable.joinKeyCols : [],
|
|
558
|
+
...(spec.writable.kind === "predicate" && spec.writable.where !== undefined
|
|
559
|
+
? { where: spec.writable.where }
|
|
560
|
+
: {}),
|
|
561
|
+
...(spec.footprintWhere !== undefined ? { footprintWhere: spec.footprintWhere } : {}),
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
/** (Re)arm a room query's proactive renewal from its current `exp`. Timers are unref'd (Node)
|
|
567
|
+
* so an idle renewal never holds the process open; cleared on release/close. */
|
|
568
|
+
const scheduleRenewal = (key: string, state: RoomQueryState, delayMs?: number): void => {
|
|
569
|
+
if (state.renewTimer !== undefined) clearTimeout(state.renewTimer);
|
|
570
|
+
if (realtimeClosed) return;
|
|
571
|
+
const delay = delayMs ?? Math.max(state.exp - Date.now() - renewMarginMs, MIN_RENEW_DELAY_MS);
|
|
572
|
+
state.renewTimer = setTimeout(() => {
|
|
573
|
+
state.renewTimer = undefined;
|
|
574
|
+
void renewRoomQuery(key, state);
|
|
575
|
+
}, delay);
|
|
576
|
+
(state.renewTimer as unknown as { unref?: () => void }).unref?.();
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
/** Proactive token renewal (renewal-as-reauthorization): re-lease through the SAME app query
|
|
580
|
+
* route; only a reply WITH a realtime block (and the SAME sourceKey) re-authorizes — the fresh
|
|
581
|
+
* token is handed to the resolver and the live sub re-subscribes with it BEFORE the room
|
|
582
|
+
* shell's TTL backstop can drop it. A reply without the block is the DOWNGRADE signal: loud;
|
|
583
|
+
* the sub is left to die at `exp` (the graceful downgrade dance is Slice I). */
|
|
584
|
+
const renewRoomQuery = async (key: string, state: RoomQueryState): Promise<void> => {
|
|
585
|
+
if (realtimeClosed || roomQueries.get(key) !== state) return;
|
|
586
|
+
let lease: QueryLeaseWire;
|
|
587
|
+
try {
|
|
588
|
+
lease = await postLease(state.remote);
|
|
589
|
+
} catch (err) {
|
|
590
|
+
anomaly("lease-failed", state.remote, `token renewal failed: ${String((err as Error)?.message ?? err)}`);
|
|
591
|
+
// Retry while the current token is still live; past `exp` the shell has dropped the sub
|
|
592
|
+
// anyway and the next reconnect re-resolution owns recovery.
|
|
593
|
+
if (Date.now() < state.exp && roomQueries.get(key) === state) scheduleRenewal(key, state, RENEW_RETRY_MS);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (realtimeClosed || roomQueries.get(key) !== state) return;
|
|
597
|
+
// I-iii: a renewal re-presents the lifecycle block — re-claim idempotently (a key this query
|
|
598
|
+
// already holds is skipped; a NEW entry, e.g. the fence appearing when the query became
|
|
599
|
+
// room-served mid-life, is retained now). Claimed BEFORE the realtime check on purpose: a
|
|
600
|
+
// downgraded renewal (no realtime block) still carries the doorbell, and the occupancy
|
|
601
|
+
// stream must survive the downgrade (it is what re-upgrades, §4.1).
|
|
602
|
+
claimLifecycle(state.lifecycleClaims, lease.lifecycle, state.remote);
|
|
603
|
+
const rt = lease.realtime;
|
|
604
|
+
if (rt === undefined) {
|
|
605
|
+
// No realtime block: the occupancy gate closed server-side. WITH a §4.2 fence, run the
|
|
606
|
+
// graceful I-v downgrade dance (retarget → demote behind the watermark → re-arm the
|
|
607
|
+
// doorbell); WITHOUT one, stay loud (a pre-I-v server, or `lifecycle.drainRoom`
|
|
608
|
+
// unconfigured — nothing to ghost behind soundly).
|
|
609
|
+
if (lease.realtimeFence !== undefined) {
|
|
610
|
+
// Hand the fresh daemon token so the driving query's daemon re-subscribe presents it (no
|
|
611
|
+
// extra POST); co-tenant queries sharing the room re-lease on their own daemon re-subscribe.
|
|
612
|
+
tokenHandoffs.set(key, {
|
|
613
|
+
target: { leaseToken: lease.leaseToken, ...(lease.wsEndpoint !== undefined ? { wsEndpoint: lease.wsEndpoint } : {}) },
|
|
614
|
+
at: Date.now(),
|
|
615
|
+
});
|
|
616
|
+
downgradeRoom(lease.realtimeFence);
|
|
617
|
+
} else {
|
|
618
|
+
anomaly(
|
|
619
|
+
"downgrade",
|
|
620
|
+
state.remote,
|
|
621
|
+
"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",
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (rt.sourceKey !== state.sourceKey) {
|
|
627
|
+
anomaly(
|
|
628
|
+
"source-key-changed",
|
|
629
|
+
state.remote,
|
|
630
|
+
`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)`,
|
|
631
|
+
);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const room = rooms.get(state.sourceKey);
|
|
635
|
+
if (!room) return;
|
|
636
|
+
try {
|
|
637
|
+
// Promotion is per-(sourceKey, table) idempotent, so a renewal compiles only tables that
|
|
638
|
+
// are NEW to the lease (a profile edit mid-life). A compile throw (schema skew) must not
|
|
639
|
+
// kill the renewal — this is a timer-driven void promise, so an escape would be an
|
|
640
|
+
// unhandled rejection — and the live sub keeps its already-promoted tables + the fresh
|
|
641
|
+
// token below; the new table's routing simply never arms (its writes route slow).
|
|
642
|
+
promoteRoomTables(room, rt.tables);
|
|
643
|
+
} catch (err) {
|
|
644
|
+
anomaly("lease-failed", state.remote, `renewal promotion failed: ${String((err as Error)?.message ?? err)} (the room keeps its already-promoted tables)`);
|
|
645
|
+
}
|
|
646
|
+
state.exp = rt.exp;
|
|
647
|
+
if (lease.lifecycle?.doorbell.scope !== undefined) state.doorbellScope = lease.lifecycle.doorbell.scope;
|
|
648
|
+
// Re-present NOW with the fresh token: hand it to the resolver and re-subscribe the live sub
|
|
649
|
+
// (an ordinary epoch bump server-side; the fresh snapshot re-hydrates through the room gate as
|
|
650
|
+
// a net-zero footprint diff).
|
|
651
|
+
tokenHandoffs.set(key, { target: { leaseToken: rt.roomToken, wsEndpoint: rt.wsEndpoint }, at: Date.now() });
|
|
652
|
+
room.source.registerQuery(state.sourceQid, state.remote);
|
|
653
|
+
scheduleRenewal(key, state);
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
/** The room channel's subscribe resolver: first subscribe consumes the handed fresh token; every
|
|
657
|
+
* RE-resolution (reconnect, gap repair, endpoint recovery) is a full re-lease through the app
|
|
658
|
+
* route — renewal-as-reauthorization, so a revoked/downgraded query cannot silently re-attach. */
|
|
659
|
+
const roomResolver =
|
|
660
|
+
(room: RoomConnection) =>
|
|
661
|
+
async ({ remote }: { queryId: QueryId; remote: RemoteQuery }) => {
|
|
662
|
+
const key = remoteKey(remote);
|
|
663
|
+
const handed = takeHandoff(key);
|
|
664
|
+
if (handed) return handed;
|
|
665
|
+
const lease = await postLease(remote);
|
|
666
|
+
const rt = lease.realtime;
|
|
667
|
+
if (rt === undefined) {
|
|
668
|
+
anomaly(
|
|
669
|
+
"downgrade",
|
|
670
|
+
remote,
|
|
671
|
+
"the re-lease carries no realtime block — the query is no longer room-served (room subscribe aborted; graceful downgrade is Slice I)",
|
|
672
|
+
);
|
|
673
|
+
throw new Error(`realtime downgrade: query "${remote.name}" is no longer room-served`);
|
|
674
|
+
}
|
|
675
|
+
if (rt.sourceKey !== room.sourceKey) {
|
|
676
|
+
anomaly(
|
|
677
|
+
"source-key-changed",
|
|
678
|
+
remote,
|
|
679
|
+
`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)`,
|
|
680
|
+
);
|
|
681
|
+
throw new Error(`realtime sourceKey changed for query "${remote.name}"`);
|
|
682
|
+
}
|
|
683
|
+
// A re-lease may widen the footprint (new tables) and always refreshes the renewal clock.
|
|
684
|
+
promoteRoomTables(room, rt.tables);
|
|
685
|
+
const state = roomQueries.get(key);
|
|
686
|
+
if (state) {
|
|
687
|
+
state.exp = rt.exp;
|
|
688
|
+
scheduleRenewal(key, state);
|
|
689
|
+
// I-iii: a re-resolution's lifecycle block re-claims like a renewal's (idempotent).
|
|
690
|
+
claimLifecycle(state.lifecycleClaims, lease.lifecycle, state.remote);
|
|
691
|
+
}
|
|
692
|
+
return { leaseToken: rt.roomToken, wsEndpoint: rt.wsEndpoint };
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
/** Connect (once) the room source for a lease's `sourceKey` — multiple labeled queries on the
|
|
696
|
+
* same room share the one source/gate. The endpoint is the lease's DEDICATED
|
|
697
|
+
* `realtime.wsEndpoint`; the TOP-LEVEL `wsEndpoint` (whole-daemon-session migration) never
|
|
698
|
+
* reaches a room transport. NO `pushMutation` override: a room-domain mutation ships over the
|
|
699
|
+
* ROOM socket itself (§7.5 sent-pins-domain — the backend's `channelFor` picks this source). */
|
|
700
|
+
const ensureRoom = (rt: RealtimeLeaseBlock): RoomConnection => {
|
|
701
|
+
const existing = rooms.get(rt.sourceKey);
|
|
702
|
+
if (existing) return existing;
|
|
703
|
+
const room: RoomConnection = {
|
|
704
|
+
sourceKey: rt.sourceKey,
|
|
705
|
+
wsEndpoint: rt.wsEndpoint,
|
|
706
|
+
source: undefined as unknown as RemoteOptimisticSource,
|
|
707
|
+
};
|
|
708
|
+
room.source = new RemoteOptimisticSource(
|
|
709
|
+
{ factory: roomTransportFactory, endpoint: rt.wsEndpoint },
|
|
710
|
+
clientID,
|
|
711
|
+
{ resolveSubscribe: roomResolver(room) },
|
|
712
|
+
);
|
|
713
|
+
rooms.set(rt.sourceKey, room);
|
|
714
|
+
// connectSource BEFORE any retain on this channel (the backend throws otherwise); it also
|
|
715
|
+
// auto-registers the reserved lmid system query, so the room's confirms fold into
|
|
716
|
+
// `watermark[sourceKey]` from the first frame.
|
|
717
|
+
backend.connectSource(rt.sourceKey, room.source);
|
|
718
|
+
return room;
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
// ---- the §4.1 doorbell reaction + upgrade retarget (Slice I-iv) ------------------------------
|
|
722
|
+
//
|
|
723
|
+
// A labeled query the lease left DAEMON-attached (the api-server's occupancy gate suppressed
|
|
724
|
+
// its room-serve — or the server simply couldn't serve it yet) registers as an UPGRADE
|
|
725
|
+
// CANDIDATE under its doorbell scope. The backend's scope-session fold then reports occupancy
|
|
726
|
+
// per release (`onScopeSessions`); on the 0→≥1 transition of ANOTHER clientID's unexpired
|
|
727
|
+
// session the candidate re-leases ONCE — debounced per (name, args): one in-flight re-lease,
|
|
728
|
+
// repeat doorbells coalesce into it — and a reply that NOW carries a realtime block runs the
|
|
729
|
+
// retarget: ensureRoom → promoteRoomTables → hand the roomToken → `backend.retargetRemoteQuery`
|
|
730
|
+
// (the two-phase no-flicker cutover; see its doc) — mirroring `attachRoom`'s exact order
|
|
731
|
+
// (connect + promote BEFORE any wire sub moves), with the retarget primitive replacing the
|
|
732
|
+
// fresh retain. Failures fail OPEN and LOUD: the daemon retain is untouched (the primitive
|
|
733
|
+
// validates before mutating), the anomaly surfaces, and the NEXT doorbell/renewal is the retry
|
|
734
|
+
// — no retry loop of our own. A reply still without a block is SILENT: suppression is the
|
|
735
|
+
// occupancy gate's designed state, not an anomaly.
|
|
736
|
+
|
|
737
|
+
interface UpgradeViewHook {
|
|
738
|
+
/** Flip this view's client-side bookkeeping onto the room query state (sets `roomKey`,
|
|
739
|
+
* joins the refcount) — a released view declines. */
|
|
740
|
+
adoptRoom(key: string, state: RoomQueryState): void;
|
|
741
|
+
/** The I-v inverse: detach this view's bookkeeping from a dismantled room query state (the
|
|
742
|
+
* downgrade deleted it wholesale — the view must not decrement a dead record on destroy). */
|
|
743
|
+
clearRoom(): void;
|
|
744
|
+
}
|
|
745
|
+
interface UpgradeCandidate {
|
|
746
|
+
remote: RemoteQuery;
|
|
747
|
+
scope: string;
|
|
748
|
+
views: Set<UpgradeViewHook>;
|
|
749
|
+
inFlight: boolean;
|
|
750
|
+
/** Lifecycle system-stream claims this candidate carries between a DOWNGRADE and the next
|
|
751
|
+
* upgrade (I-v): the dismantled room query's renewal-loop claims move here so the fence
|
|
752
|
+
* streams (the ghost drop's watermark input) outlive the room state. Adopted by the next
|
|
753
|
+
* upgrade's fresh {@link RoomQueryState}; released when the candidate dies with its last
|
|
754
|
+
* view. Empty for a fresh (never-downgraded) candidate. */
|
|
755
|
+
claims: Set<string>;
|
|
756
|
+
}
|
|
757
|
+
/** Candidates by remote key — ONE re-lease upgrades every view of the (name, args) at once
|
|
758
|
+
* (the backend moves the sub wholesale). */
|
|
759
|
+
const upgradeCandidates = new Map<string, UpgradeCandidate>();
|
|
760
|
+
/** EVERY live labeled view's hook, by remote key (I-v): the downgrade dance runs from the
|
|
761
|
+
* renewal loop — no view reference in scope — yet must re-register each surviving view as an
|
|
762
|
+
* upgrade candidate (the doorbell re-arms the next upgrade) and clear its room bookkeeping.
|
|
763
|
+
* Registered at materialize, dropped at destroy. */
|
|
764
|
+
const labeledViewHooks = new Map<string, Set<UpgradeViewHook>>();
|
|
765
|
+
/** Last observed other-session count per scope — the 0→≥1 transition tracker. A first
|
|
766
|
+
* observation at ≥1 counts as a transition (there was none before we could see). */
|
|
767
|
+
const lastOthers = new Map<string, number>();
|
|
768
|
+
|
|
769
|
+
const runUpgrade = async (cand: UpgradeCandidate): Promise<void> => {
|
|
770
|
+
let lease: QueryLeaseWire;
|
|
771
|
+
try {
|
|
772
|
+
lease = await postLease(cand.remote);
|
|
773
|
+
} catch (err) {
|
|
774
|
+
anomaly(
|
|
775
|
+
"lease-failed",
|
|
776
|
+
cand.remote,
|
|
777
|
+
`doorbell re-lease failed: ${String((err as Error)?.message ?? err)} (staying daemon-attached; the next doorbell/renewal is the retry)`,
|
|
778
|
+
);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if (realtimeClosed || cand.views.size === 0) return; // torn down while the lease was in flight
|
|
782
|
+
const rt = lease.realtime;
|
|
783
|
+
if (rt === undefined) return; // still gated server-side (e.g. its minSessions is higher) — stay daemon-attached, silently
|
|
784
|
+
const key = remoteKey(cand.remote);
|
|
785
|
+
if (roomQueries.has(key)) return; // already room-attached (a racing fresh view won) — nothing to move
|
|
786
|
+
try {
|
|
787
|
+
// The G-v attach order, verbatim, up to the sub move: room source/gate first, engine
|
|
788
|
+
// promotion second (both idempotent — `ensureRoom` per sourceKey, `promoteRoomTables` per
|
|
789
|
+
// (sourceKey, table) via the backend's routing record), THEN the wire cutover with the
|
|
790
|
+
// fresh roomToken handed to the room resolver. `retargetRemoteQuery` is itself idempotent
|
|
791
|
+
// per (query, sourceKey), so a duplicate doorbell that slipped the `inFlight` guard cannot
|
|
792
|
+
// double-attach.
|
|
793
|
+
const room = ensureRoom(rt);
|
|
794
|
+
promoteRoomTables(room, rt.tables);
|
|
795
|
+
tokenHandoffs.set(key, { target: { leaseToken: rt.roomToken, wsEndpoint: rt.wsEndpoint }, at: Date.now() });
|
|
796
|
+
const sourceQid = backend.retargetRemoteQuery(cand.remote, rt.sourceKey);
|
|
797
|
+
const state: RoomQueryState = {
|
|
798
|
+
remote: cand.remote,
|
|
799
|
+
sourceKey: rt.sourceKey,
|
|
800
|
+
sourceQid,
|
|
801
|
+
refCount: 0,
|
|
802
|
+
exp: rt.exp,
|
|
803
|
+
// ADOPT the candidate's claims (I-v): a re-upgrade after a downgrade inherits the fence
|
|
804
|
+
// streams the ghost still needs, already subscribed — so they are NOT re-subscribed; a
|
|
805
|
+
// fresh candidate's set is empty. `upgradeCandidates.delete(key)` below leaves the set
|
|
806
|
+
// owned by this state.
|
|
807
|
+
lifecycleClaims: cand.claims,
|
|
808
|
+
doorbellScope: cand.scope,
|
|
809
|
+
};
|
|
810
|
+
// The re-lease's lifecycle block now carries the fence bundle (the query is room-served):
|
|
811
|
+
// claim it on the query's renewal-loop set (idempotent — an adopted key is skipped), exactly
|
|
812
|
+
// as a renewal that turned room-served mid-life would (I-iii) — released when the last view
|
|
813
|
+
// drops the query.
|
|
814
|
+
claimLifecycle(state.lifecycleClaims, lease.lifecycle, cand.remote);
|
|
815
|
+
// No awaits since the `views.size` check above — destroys cannot have interleaved, so at
|
|
816
|
+
// least one view adopts (a released one declines via its own flag, defensively).
|
|
817
|
+
for (const view of [...cand.views]) view.adoptRoom(key, state);
|
|
818
|
+
upgradeCandidates.delete(key);
|
|
819
|
+
roomQueries.set(key, state);
|
|
820
|
+
scheduleRenewal(key, state);
|
|
821
|
+
} catch (err) {
|
|
822
|
+
// Fail open: the retarget primitive validates before mutating, so the daemon retain is
|
|
823
|
+
// intact — the query keeps serving from the daemon exactly as before the doorbell.
|
|
824
|
+
tokenHandoffs.delete(key); // never leave a room token where the DAEMON resolver could eat it
|
|
825
|
+
anomaly(
|
|
826
|
+
"lease-failed",
|
|
827
|
+
cand.remote,
|
|
828
|
+
`upgrade retarget failed: ${String((err as Error)?.message ?? err)} (staying daemon-attached; the next doorbell/renewal is the retry)`,
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
/** Kick every idle candidate on `scope` — the doorbell reaction proper. */
|
|
834
|
+
const maybeUpgrade = (scope: string): void => {
|
|
835
|
+
if (realtimeClosed) return;
|
|
836
|
+
for (const cand of upgradeCandidates.values()) {
|
|
837
|
+
if (cand.scope !== scope || cand.inFlight || cand.views.size === 0) continue;
|
|
838
|
+
cand.inFlight = true;
|
|
839
|
+
void runUpgrade(cand).finally(() => {
|
|
840
|
+
cand.inFlight = false;
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
const registerUpgradeCandidate = (remote: RemoteQuery, scope: string, hook: UpgradeViewHook): void => {
|
|
846
|
+
const key = remoteKey(remote);
|
|
847
|
+
let cand = upgradeCandidates.get(key);
|
|
848
|
+
if (!cand) upgradeCandidates.set(key, (cand = { remote, scope, views: new Set(), inFlight: false, claims: new Set() }));
|
|
849
|
+
cand.views.add(hook);
|
|
850
|
+
// Registration-time check: a doorbell that FOLDED before this candidate existed (the lease
|
|
851
|
+
// resolve raced the occupancy delta) must still trigger — same count rule as the events.
|
|
852
|
+
if (backend.otherScopeSessions(scope) >= 1) maybeUpgrade(scope);
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
const dropUpgradeCandidate = (remote: RemoteQuery, hook: UpgradeViewHook): void => {
|
|
856
|
+
const cand = upgradeCandidates.get(remoteKey(remote));
|
|
857
|
+
if (!cand) return;
|
|
858
|
+
cand.views.delete(hook);
|
|
859
|
+
if (cand.views.size === 0) {
|
|
860
|
+
upgradeCandidates.delete(remoteKey(remote));
|
|
861
|
+
// A candidate carrying a downgrade's fence-stream claims (I-v) releases them with its last
|
|
862
|
+
// view — the LAST holder unsubscribes the wire sub (empty set ⇒ no-op for a fresh candidate).
|
|
863
|
+
releaseLifecycle(cand.claims);
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
/** The §4.2 graceful downgrade dance (Slice I-v): a renewal came back with NO realtime block
|
|
868
|
+
* but WITH a fence. Handle the WHOLE room at once — retarget every live sub sharing the source
|
|
869
|
+
* onto the daemon (the I-iv retarget in REVERSE), demote the room source behind the watermark
|
|
870
|
+
* fence (its rows persist as a FROZEN ghost until the daemon plane absorbs the final flush),
|
|
871
|
+
* close the room transport, and re-register each surviving view as an upgrade candidate so the
|
|
872
|
+
* next doorbell re-upgrades the same doc. `demoteRoomSource` refuses to demote while any sub is
|
|
873
|
+
* still on the channel, so all subs must retarget first; a co-tenant query's own later renewal
|
|
874
|
+
* then finds the room gone and no-ops (retarget-to-daemon + demote are both idempotent).
|
|
875
|
+
*
|
|
876
|
+
* Ordering with disconnect: `demoteRoomSource` → `disconnectSource` drops the room gate, which
|
|
877
|
+
* makes the retarget's deferred phase-2 GC (`flushRetargetGc`, run at the daemon's first
|
|
878
|
+
* release) a no-op — it deletes the pending-GC record then finds no old gate to rewind, so the
|
|
879
|
+
* room slice's rows leave ONLY through the ghost's `removeRoomSource` under the fence (never via
|
|
880
|
+
* a GC rewind that would surface a lagging follower's pre-flush images). */
|
|
881
|
+
const downgradeRoom = (fence: RealtimeFenceBlock): void => {
|
|
882
|
+
if (realtimeClosed) return;
|
|
883
|
+
const sourceKey = fence.sourceKey;
|
|
884
|
+
const onRoom = [...roomQueries].filter(([, s]) => s.sourceKey === sourceKey);
|
|
885
|
+
for (const [key, state] of onRoom) {
|
|
886
|
+
backend.retargetRemoteQuery(state.remote, "daemon"); // room → daemon; the no-block reply IS a daemon lease
|
|
887
|
+
if (state.renewTimer !== undefined) clearTimeout(state.renewTimer);
|
|
888
|
+
roomQueries.delete(key);
|
|
889
|
+
const hooks = labeledViewHooks.get(key);
|
|
890
|
+
if (state.doorbellScope !== undefined && hooks !== undefined && hooks.size > 0) {
|
|
891
|
+
let cand = upgradeCandidates.get(key);
|
|
892
|
+
if (!cand) {
|
|
893
|
+
upgradeCandidates.set(key, (cand = { remote: state.remote, scope: state.doorbellScope, views: new Set(), inFlight: false, claims: new Set() }));
|
|
894
|
+
}
|
|
895
|
+
// Carry the renewal-loop's lifecycle claims (the fence streams — the ghost's watermark
|
|
896
|
+
// input, delivered on the DAEMON channel) onto the candidate so they outlive the room
|
|
897
|
+
// state and are adopted by the next upgrade (`runUpgrade`).
|
|
898
|
+
for (const c of state.lifecycleClaims) cand.claims.add(c);
|
|
899
|
+
state.lifecycleClaims.clear();
|
|
900
|
+
for (const h of hooks) {
|
|
901
|
+
h.clearRoom(); // forget the dead room bookkeeping (destroy must not decrement a gone state)
|
|
902
|
+
cand.views.add(h);
|
|
903
|
+
}
|
|
904
|
+
// Self-heal: a collaborator still present at downgrade re-rings immediately. Normally the
|
|
905
|
+
// scope is solo here (that IS why the server downgraded), so this is inert.
|
|
906
|
+
if (backend.otherScopeSessions(state.doorbellScope) >= 1) maybeUpgrade(state.doorbellScope);
|
|
907
|
+
} else {
|
|
908
|
+
// No re-upgrade possible (no doorbell scope, or no surviving view): release the claims.
|
|
909
|
+
if (hooks !== undefined) for (const h of hooks) h.clearRoom();
|
|
910
|
+
releaseLifecycle(state.lifecycleClaims);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
backend.demoteRoomSource(sourceKey, fence.doc, fence.finalFlushSeq); // frozen ghost behind the fence
|
|
914
|
+
const room = rooms.get(sourceKey);
|
|
915
|
+
if (room !== undefined) {
|
|
916
|
+
rooms.delete(sourceKey);
|
|
917
|
+
room.source.close(); // every sub retargeted off it
|
|
918
|
+
}
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
// The trigger: the backend reports (scope, other-session count) after each release that folded
|
|
922
|
+
// occupancy rows; the 0→≥1 transition rings. `others` never counts our own clientID or expired
|
|
923
|
+
// rows (the backend's one rule), so a solo tab's own row cannot ring its own bell, and a stale
|
|
924
|
+
// collaborator aging out then re-appearing rings again (0→1 anew) — which is idempotent here
|
|
925
|
+
// (an already-room-attached query has no candidate left to kick).
|
|
926
|
+
backend.onScopeSessions(({ scope, others }) => {
|
|
927
|
+
const prev = lastOthers.get(scope) ?? 0;
|
|
928
|
+
lastOthers.set(scope, others);
|
|
929
|
+
if (prev === 0 && others >= 1) maybeUpgrade(scope);
|
|
147
930
|
});
|
|
148
931
|
|
|
932
|
+
// The I-v stuck-downgrade surface (§7.5): a ghost whose watermark fence cleared but whose sent
|
|
933
|
+
// room-domain mids never resolved through the daemon-carried folds. The ghost HOLDS (no
|
|
934
|
+
// timeout-retire is invented) — surface it loudly, naming the mids.
|
|
935
|
+
backend.onDowngradeStuck(({ sourceKey, doc, mids }) => {
|
|
936
|
+
anomaly(
|
|
937
|
+
"downgrade-stuck",
|
|
938
|
+
{ name: sourceKey, args: { doc, mids } },
|
|
939
|
+
`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)`,
|
|
940
|
+
);
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
// The split-register ticket: set (synchronously) by the wrapped `materialize` just before it
|
|
944
|
+
// delegates, consumed by the shadowed `backend.registerQuery` below — which registers the LOCAL
|
|
945
|
+
// half only (the Store's SSR-seed + `unknown` pre-marking has already run) and defers the remote
|
|
946
|
+
// retain to the lease resolution. Everything else (unlabeled queries, React retains, re-registers)
|
|
947
|
+
// flows through untouched.
|
|
948
|
+
let labeledTicket: { consumed: boolean } | null = null;
|
|
949
|
+
const origRegisterQuery = backend.registerQuery.bind(backend);
|
|
950
|
+
backend.registerQuery = (qid: QueryId, ast: Ast, remote?: RemoteQuery, channel?: string): void => {
|
|
951
|
+
if (labeledTicket === null || remote === undefined) {
|
|
952
|
+
origRegisterQuery(qid, ast, remote, channel);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
const ticket = labeledTicket;
|
|
956
|
+
labeledTicket = null;
|
|
957
|
+
ticket.consumed = true;
|
|
958
|
+
// The LOCAL half of the split retain (the backend's documented split-retain shape): the remote
|
|
959
|
+
// attaches via `retainRemoteQuery` on the channel the lease names, once it answers.
|
|
960
|
+
origRegisterQuery(qid, ast, undefined);
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
const origMaterialize = store.materialize.bind(store) as (
|
|
964
|
+
query: Query<any, any, any>,
|
|
965
|
+
mOpts?: unknown,
|
|
966
|
+
) => MaterializedViewLike;
|
|
967
|
+
|
|
968
|
+
/** The G-v labeled-materialize: synchronous local view now, remote retain when the lease answers. */
|
|
969
|
+
const materializeLabeled = (query: Query<any, any, any> & { name: string }, mOpts?: unknown): MaterializedViewLike => {
|
|
970
|
+
const remote: RemoteQuery = { name: query.name, args: query.args };
|
|
971
|
+
const ast = query.ast() as Ast;
|
|
972
|
+
// E3 parity (201-LOCAL-ONLY-TABLES-DESIGN.md): the unlabeled remote path rejects a remote query
|
|
973
|
+
// naming a local-only table synchronously inside materialize; the labeled path defers the
|
|
974
|
+
// remote register past the lease, so run the SAME guard here — identical throw, identical
|
|
975
|
+
// timing, no view leaked.
|
|
976
|
+
for (const t of collectAstTables(ast)) {
|
|
977
|
+
if (localTables.has(t)) {
|
|
978
|
+
throw new Error(
|
|
979
|
+
`remote query "${remote.name}" references local-only table "${t}" — local tables never cross the wire (201-LOCAL-ONLY-TABLES-DESIGN.md E3).`,
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
const ticket = { consumed: false };
|
|
984
|
+
labeledTicket = ticket;
|
|
985
|
+
let view: MaterializedViewLike;
|
|
986
|
+
try {
|
|
987
|
+
view = origMaterialize(query, mOpts);
|
|
988
|
+
} finally {
|
|
989
|
+
labeledTicket = null;
|
|
990
|
+
}
|
|
991
|
+
const localQid = view.qid;
|
|
992
|
+
// The Store pre-marked the view `unknown` (a remote-identity register under a lifecycle
|
|
993
|
+
// backend), but the local-half register flipped it back to `complete` (a local-only
|
|
994
|
+
// registration is synchronously authoritative). Re-flip for the lease window so the view never
|
|
995
|
+
// reads server-authoritative before ANY authority answered — the retain below recomputes it
|
|
996
|
+
// against real hydration. (`readOnce` on a labeled query correctly waits because of this.)
|
|
997
|
+
if (ticket.consumed) flipResultTypeUnknown(view);
|
|
998
|
+
|
|
999
|
+
let released = false;
|
|
1000
|
+
let retainQid: QueryId | undefined;
|
|
1001
|
+
let roomKey: string | undefined;
|
|
1002
|
+
// I-iii: the lifecycle system-stream claims THIS VIEW holds (claimed once per key when its
|
|
1003
|
+
// lease resolves; released with the view — the LAST holder of a scope/doc drops the sub).
|
|
1004
|
+
const viewLifecycleClaims = new Set<string>();
|
|
1005
|
+
// I-iv/I-v: this view's hook — `adoptRoom` (an upgrade joins the view to the new room state)
|
|
1006
|
+
// and `clearRoom` (the downgrade dismantled the room state wholesale — forget it so destroy
|
|
1007
|
+
// never decrements a dead record). Registered in `labeledViewHooks` for EVERY labeled view so
|
|
1008
|
+
// the downgrade dance (which runs from the renewal loop, no view in scope) can find and
|
|
1009
|
+
// re-candidate each surviving view; used as the candidate hook for the daemon-attached shape.
|
|
1010
|
+
const viewHook: UpgradeViewHook = {
|
|
1011
|
+
adoptRoom: (key: string, state: RoomQueryState): void => {
|
|
1012
|
+
if (released) return; // a released view never joins (its retain is already gone)
|
|
1013
|
+
roomKey = key;
|
|
1014
|
+
state.refCount++;
|
|
1015
|
+
},
|
|
1016
|
+
clearRoom: (): void => {
|
|
1017
|
+
roomKey = undefined;
|
|
1018
|
+
},
|
|
1019
|
+
};
|
|
1020
|
+
const hookKey = remoteKey(remote);
|
|
1021
|
+
let viewHooks = labeledViewHooks.get(hookKey);
|
|
1022
|
+
if (viewHooks === undefined) labeledViewHooks.set(hookKey, (viewHooks = new Set()));
|
|
1023
|
+
viewHooks.add(viewHook);
|
|
1024
|
+
|
|
1025
|
+
/** Fail-open: retain on the daemon, indistinguishable from an unlabeled query. The ONE lease
|
|
1026
|
+
* already resolved (when it succeeded) is handed to the daemon resolver so the subscribe
|
|
1027
|
+
* presents exactly that token — one POST per subscribe, the unlabeled cadence. */
|
|
1028
|
+
const attachDaemon = (target?: { leaseToken: string; wsEndpoint?: string }): void => {
|
|
1029
|
+
if (target) tokenHandoffs.set(remoteKey(remote), { target, at: Date.now() });
|
|
1030
|
+
retainQid = nextRetainQid++;
|
|
1031
|
+
backend.retainRemoteQuery(retainQid, remote, localQid, ast);
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
/** Room-served: ensure the shared room source/gate, promote the engine per the lease's table
|
|
1035
|
+
* specs BEFORE retaining, then retain the sub on the room channel with the roomToken handed
|
|
1036
|
+
* to the resolver. `doorbellScope` (from the lease's lifecycle block) is pinned on the room
|
|
1037
|
+
* state so a later I-v downgrade can re-candidate this query. */
|
|
1038
|
+
const attachRoom = (rt: RealtimeLeaseBlock, doorbellScope?: string): void => {
|
|
1039
|
+
const key = remoteKey(remote);
|
|
1040
|
+
const existing = roomQueries.get(key);
|
|
1041
|
+
if (existing && existing.sourceKey !== rt.sourceKey) {
|
|
1042
|
+
anomaly(
|
|
1043
|
+
"source-key-changed",
|
|
1044
|
+
remote,
|
|
1045
|
+
`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)`,
|
|
1046
|
+
);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
const room = ensureRoom(rt);
|
|
1050
|
+
promoteRoomTables(room, rt.tables);
|
|
1051
|
+
// Only the retain that CREATES the wire sub consumes a token at subscribe time — hand one
|
|
1052
|
+
// exactly then (a refcount-only retain issues no wire subscribe; the age cap covers races).
|
|
1053
|
+
if (!existing) {
|
|
1054
|
+
tokenHandoffs.set(key, { target: { leaseToken: rt.roomToken, wsEndpoint: rt.wsEndpoint }, at: Date.now() });
|
|
1055
|
+
}
|
|
1056
|
+
retainQid = nextRetainQid++;
|
|
1057
|
+
backend.retainRemoteQuery(retainQid, remote, localQid, ast, rt.sourceKey);
|
|
1058
|
+
let state = existing;
|
|
1059
|
+
if (!state) {
|
|
1060
|
+
state = { remote, sourceKey: rt.sourceKey, sourceQid: retainQid, refCount: 0, exp: rt.exp, lifecycleClaims: new Set() };
|
|
1061
|
+
roomQueries.set(key, state);
|
|
1062
|
+
} else {
|
|
1063
|
+
state.exp = Math.max(state.exp, rt.exp);
|
|
1064
|
+
}
|
|
1065
|
+
if (doorbellScope !== undefined) state.doorbellScope = doorbellScope;
|
|
1066
|
+
state.refCount++;
|
|
1067
|
+
roomKey = key;
|
|
1068
|
+
scheduleRenewal(key, state);
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
// Resolve-then-register: the lease FIRST; the register follows its verdict.
|
|
1072
|
+
void (async () => {
|
|
1073
|
+
let lease: QueryLeaseWire;
|
|
1074
|
+
try {
|
|
1075
|
+
lease = await postLease(remote);
|
|
1076
|
+
} catch (err) {
|
|
1077
|
+
// The lease POST itself failed: fail OPEN to the daemon with no handoff — the daemon
|
|
1078
|
+
// retain's own resolver re-leases (and the transport's resync retries), exactly an
|
|
1079
|
+
// unlabeled query's recovery story.
|
|
1080
|
+
anomaly("lease-failed", remote, `query lease failed: ${String((err as Error)?.message ?? err)}`);
|
|
1081
|
+
if (!released && !realtimeClosed) attachDaemon();
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
if (released || realtimeClosed) return;
|
|
1085
|
+
try {
|
|
1086
|
+
if (lease.realtime === undefined) {
|
|
1087
|
+
attachDaemon({ leaseToken: lease.leaseToken, wsEndpoint: lease.wsEndpoint });
|
|
1088
|
+
// I-iv: a daemon-attached labeled view under a doorbell scope is an UPGRADE CANDIDATE —
|
|
1089
|
+
// the occupancy stream's 0→≥1 transition re-leases it and (block permitting) retargets
|
|
1090
|
+
// the whole (name, args) sub onto the room. A blockless lease (pre-lifecycle server)
|
|
1091
|
+
// registers nothing: the plane stays inert-until-fed.
|
|
1092
|
+
const doorbellScope = lease.lifecycle?.doorbell.scope;
|
|
1093
|
+
if (doorbellScope !== undefined) registerUpgradeCandidate(remote, doorbellScope, viewHook);
|
|
1094
|
+
} else {
|
|
1095
|
+
attachRoom(lease.realtime, lease.lifecycle?.doorbell.scope);
|
|
1096
|
+
}
|
|
1097
|
+
// I-iii: retain the lease's lifecycle system streams on the DAEMON channel — for the
|
|
1098
|
+
// room-served AND the daemon-served (labeled, not covered) shapes alike (the doorbell
|
|
1099
|
+
// rides both; the fence only where a room block exists). Absent block ⇒ no-op — a
|
|
1100
|
+
// pre-lifecycle server leaves this client byte-identical.
|
|
1101
|
+
claimLifecycle(viewLifecycleClaims, lease.lifecycle, remote);
|
|
1102
|
+
} catch (err) {
|
|
1103
|
+
// Fail OPEN, exactly like a lease without a block: a room-attach throw (most plausibly a
|
|
1104
|
+
// lease `where` this bundle's schema cannot compile — version skew) must not strand the
|
|
1105
|
+
// view local-only. `retainQid === undefined` ⇒ no retain was established (room OR daemon),
|
|
1106
|
+
// so the daemon fallback cannot double-attach; a throw AFTER a successful retain (a
|
|
1107
|
+
// lifecycle claim, say) leaves the live sub alone. Partial promotion is harmless (it is
|
|
1108
|
+
// idempotent, and the room gate re-proves any routed write) — but never leave the room
|
|
1109
|
+
// token where the daemon resolver could eat it.
|
|
1110
|
+
anomaly("lease-failed", remote, `realtime attach failed: ${String((err as Error)?.message ?? err)} (falling back to the daemon lease)`);
|
|
1111
|
+
if (!released && !realtimeClosed && retainQid === undefined) {
|
|
1112
|
+
tokenHandoffs.delete(remoteKey(remote));
|
|
1113
|
+
try {
|
|
1114
|
+
attachDaemon({ leaseToken: lease.leaseToken, wsEndpoint: lease.wsEndpoint });
|
|
1115
|
+
} catch (fallbackErr) {
|
|
1116
|
+
anomaly("lease-failed", remote, `daemon fallback failed: ${String((fallbackErr as Error)?.message ?? fallbackErr)}`);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
})();
|
|
1121
|
+
|
|
1122
|
+
// Teardown rides the view: release the remote retain (room or daemon) with the local view, and
|
|
1123
|
+
// drop the room query's refcount/renewal when the last view goes.
|
|
1124
|
+
const origDestroy = view.destroy.bind(view);
|
|
1125
|
+
view.destroy = () => {
|
|
1126
|
+
if (!released) {
|
|
1127
|
+
released = true;
|
|
1128
|
+
if (retainQid !== undefined) backend.releaseRemoteQuery(retainQid);
|
|
1129
|
+
// I-iv/I-v: drop this view's hook — from the per-key hook registry and the candidate set
|
|
1130
|
+
// (both no-ops when it was never a candidate; the candidate's own last-view release frees
|
|
1131
|
+
// any fence-stream claims a downgrade parked on it).
|
|
1132
|
+
viewHooks.delete(viewHook);
|
|
1133
|
+
if (viewHooks.size === 0) labeledViewHooks.delete(hookKey);
|
|
1134
|
+
dropUpgradeCandidate(remote, viewHook);
|
|
1135
|
+
// I-iii: this view's lifecycle claims drop with it; the LAST holder of a key releases
|
|
1136
|
+
// the system sub (the backend's folded fence/occupancy state deliberately survives).
|
|
1137
|
+
releaseLifecycle(viewLifecycleClaims);
|
|
1138
|
+
if (roomKey !== undefined) {
|
|
1139
|
+
const state = roomQueries.get(roomKey);
|
|
1140
|
+
if (state && --state.refCount <= 0) {
|
|
1141
|
+
if (state.renewTimer !== undefined) clearTimeout(state.renewTimer);
|
|
1142
|
+
roomQueries.delete(roomKey);
|
|
1143
|
+
// …including any claims the renewal loop made on this query's behalf.
|
|
1144
|
+
releaseLifecycle(state.lifecycleClaims);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
origDestroy();
|
|
1149
|
+
};
|
|
1150
|
+
return view;
|
|
1151
|
+
};
|
|
1152
|
+
|
|
1153
|
+
store.materialize = ((query: Query<any, any, any>, mOpts?: unknown) => {
|
|
1154
|
+
const label = (query as { realtime?: RealtimeQueryLabel }).realtime;
|
|
1155
|
+
if (label === undefined || typeof query.name !== "string") return origMaterialize(query, mOpts);
|
|
1156
|
+
return materializeLabeled(query as Query<any, any, any> & { name: string }, mOpts);
|
|
1157
|
+
}) as Store<S>["materialize"];
|
|
1158
|
+
|
|
1159
|
+
// Local-table persistence (207 §5.2): attach immediately after the store exists — before any
|
|
1160
|
+
// app write can reach `writeLocal` — and AWAIT the initial restore (one `getAll` over small
|
|
1161
|
+
// tables) so the first render never flashes empty local state. Restored rows arrive as ordinary
|
|
1162
|
+
// deltas, so correctness never needed the gate — only UX does.
|
|
1163
|
+
let persistence: LocalPersistence | undefined;
|
|
1164
|
+
if (opts.persistLocal) {
|
|
1165
|
+
persistence = attachLocalPersistence(backend, opts.schema, opts.persistLocal);
|
|
1166
|
+
await persistence.ready;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
149
1169
|
// Drain folds before the tab goes away (FOLDED-MUTATIONS-DESIGN §0.1/§3): a fold deliberately
|
|
150
1170
|
// holds its server write through the debounce window, so an unclean navigation would otherwise
|
|
151
1171
|
// lose the applied-locally tail. `pagehide`/`beforeunload` is the last-chance flush; `close`
|
|
152
|
-
// also drains.
|
|
1172
|
+
// also drains. The same hook is the persistence layer's best-effort final forward/persist
|
|
1173
|
+
// (207 §9 — bounds the unacked-tail loss). (Best-effort: an unclean crash still loses the
|
|
1174
|
+
// tail — an accepted cost, §0.1.)
|
|
153
1175
|
const flushFolds = () => backend.flushFolds();
|
|
1176
|
+
const onPageHide = () => {
|
|
1177
|
+
flushFolds();
|
|
1178
|
+
void persistence?.flush();
|
|
1179
|
+
};
|
|
154
1180
|
// Structural typing — this package has no DOM lib, but the browser global carries these.
|
|
155
1181
|
const target = globalThis as unknown as {
|
|
156
1182
|
addEventListener?: (type: string, listener: () => void) => void;
|
|
157
1183
|
removeEventListener?: (type: string, listener: () => void) => void;
|
|
158
1184
|
};
|
|
159
|
-
target.addEventListener?.("pagehide",
|
|
160
|
-
target.addEventListener?.("beforeunload",
|
|
1185
|
+
target.addEventListener?.("pagehide", onPageHide);
|
|
1186
|
+
target.addEventListener?.("beforeunload", onPageHide);
|
|
161
1187
|
|
|
162
1188
|
return {
|
|
163
1189
|
store,
|
|
@@ -167,13 +1193,148 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
|
|
|
167
1193
|
clientID,
|
|
168
1194
|
close: () => {
|
|
169
1195
|
flushFolds();
|
|
170
|
-
target.removeEventListener?.("pagehide",
|
|
171
|
-
target.removeEventListener?.("beforeunload",
|
|
1196
|
+
target.removeEventListener?.("pagehide", onPageHide);
|
|
1197
|
+
target.removeEventListener?.("beforeunload", onPageHide);
|
|
1198
|
+
persistence?.close(); // releases leadership + the channel + the IDB handle (207 P10)
|
|
1199
|
+
// Realtime teardown: renewal timers first (no renewal may fire into a closing client), then
|
|
1200
|
+
// every room socket; in-flight lease resolutions are made inert via the flag.
|
|
1201
|
+
realtimeClosed = true;
|
|
1202
|
+
upgradeCandidates.clear(); // no doorbell may retarget into a closing client
|
|
1203
|
+
for (const state of roomQueries.values()) {
|
|
1204
|
+
if (state.renewTimer !== undefined) clearTimeout(state.renewTimer);
|
|
1205
|
+
}
|
|
1206
|
+
roomQueries.clear();
|
|
1207
|
+
for (const room of rooms.values()) room.source.close();
|
|
1208
|
+
rooms.clear();
|
|
172
1209
|
source.close();
|
|
173
1210
|
},
|
|
1211
|
+
__realtimeInspect: (): RealtimeInspect => ({
|
|
1212
|
+
rooms: Object.fromEntries(
|
|
1213
|
+
[...rooms].map(([sourceKey, room]) => [
|
|
1214
|
+
sourceKey,
|
|
1215
|
+
{
|
|
1216
|
+
wsEndpoint: room.wsEndpoint,
|
|
1217
|
+
// Read back from the backend's routing table (H-iii) — shape-compatible with G-v's
|
|
1218
|
+
// client-held map (table → joinKeyCols).
|
|
1219
|
+
promoted: Object.fromEntries(
|
|
1220
|
+
[...backend.roomTablesFor(sourceKey)].map(([t, spec]) => [t, [...spec.joinKeyCols]]),
|
|
1221
|
+
),
|
|
1222
|
+
queries: Object.fromEntries(
|
|
1223
|
+
[...roomQueries]
|
|
1224
|
+
.filter(([, s]) => s.sourceKey === sourceKey)
|
|
1225
|
+
.map(([key, s]) => [
|
|
1226
|
+
key,
|
|
1227
|
+
{ name: s.remote.name, sourceQid: s.sourceQid, exp: s.exp, refCount: s.refCount },
|
|
1228
|
+
]),
|
|
1229
|
+
),
|
|
1230
|
+
},
|
|
1231
|
+
]),
|
|
1232
|
+
),
|
|
1233
|
+
}),
|
|
174
1234
|
};
|
|
175
1235
|
}
|
|
176
1236
|
|
|
1237
|
+
// --------------------------------------------------------------------------- realtime helpers
|
|
1238
|
+
|
|
1239
|
+
/** The minimal view surface the labeled-materialize wrapper needs (structural — the real return
|
|
1240
|
+
* type flows through unchanged). */
|
|
1241
|
+
interface MaterializedViewLike {
|
|
1242
|
+
readonly qid: QueryId;
|
|
1243
|
+
destroy(): void;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/** Map a lease `RoomTableSpec.writable` onto the wasm engine's {@link WritableDescriptor}. An
|
|
1247
|
+
* absent `where` on the predicate arm means NO row-local constraint — every row the room holds
|
|
1248
|
+
* for the table is in the writable scope (the spec contract) — i.e. the engine's `all` arm. The
|
|
1249
|
+
* spec's `joinKeyCols`/`footprintWhere` deliberately do NOT cross this boundary — they live in
|
|
1250
|
+
* the backend's per-(sourceKey, table) routing record (H-iii, `backend.roomTablesFor`). */
|
|
1251
|
+
function toWritableDescriptor(spec: RealtimeLeaseTableSpec): WritableDescriptor {
|
|
1252
|
+
if (spec.writable.kind === "none") return { kind: "none" };
|
|
1253
|
+
return spec.writable.where === undefined
|
|
1254
|
+
? { kind: "all" }
|
|
1255
|
+
: { kind: "predicate", where: spec.writable.where };
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/** Flip a just-materialized labeled view back to `unknown` for the lease-resolve window. The
|
|
1259
|
+
* plural `FlatArrayView` exposes `setResultType`; a `.one()` query's `SingularView` wrapper hides
|
|
1260
|
+
* it behind its (runtime-visible) `inner` — reach through. Best-effort by design: the deferred
|
|
1261
|
+
* retain recomputes the lifecycle authoritatively the moment it attaches, and the Store keeps
|
|
1262
|
+
* routing backend transitions to the SAME underlying view either way. */
|
|
1263
|
+
function flipResultTypeUnknown(view: unknown): void {
|
|
1264
|
+
const v = view as {
|
|
1265
|
+
setResultType?: (rt: "unknown") => void;
|
|
1266
|
+
inner?: { setResultType?: (rt: "unknown") => void };
|
|
1267
|
+
};
|
|
1268
|
+
if (typeof v.setResultType === "function") v.setResultType("unknown");
|
|
1269
|
+
else if (typeof v.inner?.setResultType === "function") v.inner.setResultType("unknown");
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** Every base table an AST tree can draw from (root, related subtrees, EXISTS children) — the E3
|
|
1273
|
+
* guard's input. Mirrors the backend's own `collectTables`. */
|
|
1274
|
+
function collectAstTables(ast: Ast, out = new Set<string>()): Set<string> {
|
|
1275
|
+
out.add(ast.table);
|
|
1276
|
+
for (const rel of ast.related ?? []) collectAstTables(rel.subquery, out);
|
|
1277
|
+
collectConditionTables(ast.where, out);
|
|
1278
|
+
collectConditionTables(ast.having, out);
|
|
1279
|
+
return out;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function collectConditionTables(cond: Condition | undefined, out: Set<string>): void {
|
|
1283
|
+
if (cond === undefined) return;
|
|
1284
|
+
if (cond.type === "and" || cond.type === "or") {
|
|
1285
|
+
for (const c of cond.conditions) collectConditionTables(c, out);
|
|
1286
|
+
} else if (cond.type === "correlatedSubquery") {
|
|
1287
|
+
collectAstTables(cond.related.subquery, out);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/** The (name, args) sub identity — key-order-stable, mirroring the backend's own `remoteKey` so
|
|
1292
|
+
* the client-side room bookkeeping groups retains exactly as the backend dedups subs. */
|
|
1293
|
+
function remoteKey(remote: RemoteQuery): string {
|
|
1294
|
+
return stableJson([remote.name, remote.args]);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// ---- lifecycle system-sub helpers (Slice I-iii) ----
|
|
1298
|
+
|
|
1299
|
+
/** A system sub's wire `args` (under the reserved `_rindle/lifecycle` name): the lease entry's
|
|
1300
|
+
* identity fields plus the PARENT labeled query, so a re-resolution can re-lease the parent and
|
|
1301
|
+
* re-find the entry (`resolveLifecycleTarget`). */
|
|
1302
|
+
interface LifecycleRemoteArgs {
|
|
1303
|
+
table: SystemStreamTable;
|
|
1304
|
+
scope?: string;
|
|
1305
|
+
doc?: string;
|
|
1306
|
+
clientId?: string;
|
|
1307
|
+
parent: { name: string; args: unknown };
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const SYSTEM_TABLES: ReadonlySet<string> = new Set([
|
|
1311
|
+
SCOPE_SESSIONS_TABLE,
|
|
1312
|
+
ROOM_WATERMARK_TABLE,
|
|
1313
|
+
ROOM_CLIENT_MUTATIONS_TABLE,
|
|
1314
|
+
ROOM_MUTATION_OUTCOMES_TABLE,
|
|
1315
|
+
]);
|
|
1316
|
+
|
|
1317
|
+
function isSystemTable(table: string): table is SystemStreamTable {
|
|
1318
|
+
return SYSTEM_TABLES.has(table);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/** The idempotence key a lifecycle retain is claimed under: the minted predicate's full identity
|
|
1322
|
+
* (table + scope/doc/clientId) — two labeled queries on one scope share ONE doorbell sub; two
|
|
1323
|
+
* docs' fences never alias. */
|
|
1324
|
+
function systemEntryKey(entry: { table: string; scope?: string; doc?: string; clientId?: string }): string {
|
|
1325
|
+
return stableJson([entry.table, entry.scope ?? null, entry.doc ?? null, entry.clientId ?? null]);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function stableJson(value: unknown): string {
|
|
1329
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
1330
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
1331
|
+
const obj = value as Record<string, unknown>;
|
|
1332
|
+
return `{${Object.keys(obj)
|
|
1333
|
+
.sort()
|
|
1334
|
+
.map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`)
|
|
1335
|
+
.join(",")}}`;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
177
1338
|
export type { ClientRegistry, MutationTx };
|
|
178
1339
|
export type { MutateFn } from "./index.ts";
|
|
179
1340
|
|