@rindle/optimistic 0.5.0 → 0.6.4

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/src/client-id.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { TicketPersistence } from "@rindle/remote";
2
+
1
3
  // The connection's stable clientID — kept in its own (wasm-free) module so it can be unit-tested
2
4
  // without pulling the engine in. A per-ORIGIN base (localStorage) shared by every tab and kept
3
5
  // across reloads, a per-TAB suffix (sessionStorage) that survives a reload within a tab but is
@@ -10,6 +12,12 @@
10
12
  const CLIENT_ID_KEY = "rindle-client-id";
11
13
  /** The per-TAB suffix (sessionStorage): survives a reload within a tab but is distinct per tab. */
12
14
  const TAB_ID_KEY = "rindle-tab-id";
15
+ /** The per-TAB follower-affinity ticket (sessionStorage): kept across a reload WITHIN a tab (so a
16
+ * reload lands back on the same follower — monotone reads survive it, FOLLOWER-AFFINITY-DESIGN.md
17
+ * §8), but DISTINCT per tab so two tabs can pin two regions (design §13; a shared localStorage
18
+ * ticket would force both onto one follower). The ticket is opaque and short-lived — the follower
19
+ * re-mints one on every connect — so losing it (no sessionStorage) merely re-anycasts. */
20
+ const AFFINITY_TICKET_KEY = "rindle-affinity-ticket";
13
21
 
14
22
  /** Read-or-mint a value in a web Storage area, tolerating its absence (SSR, privacy mode). Returns
15
23
  * `undefined` when the area is unavailable so the caller can pick a fallback. */
@@ -55,6 +63,42 @@ export function resetStableClientID(): void {
55
63
  tabInstance = 0;
56
64
  }
57
65
 
66
+ /** A sessionStorage-backed {@link TicketPersistence} for the affinity ticket — per-tab, survives a
67
+ * reload (see {@link AFFINITY_TICKET_KEY}). Every access tolerates web storage being absent (SSR,
68
+ * private mode): the store then behaves as pure in-memory, re-anycasting on each fresh load. */
69
+ export function sessionTicketPersistence(): TicketPersistence {
70
+ const area = () => {
71
+ try {
72
+ return (globalThis as unknown as Record<string, Storage | undefined>).sessionStorage;
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ };
77
+ return {
78
+ load: () => {
79
+ try {
80
+ return area()?.getItem(AFFINITY_TICKET_KEY) ?? undefined;
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ },
85
+ save: (ticket) => {
86
+ try {
87
+ area()?.setItem(AFFINITY_TICKET_KEY, ticket);
88
+ } catch {
89
+ // Best-effort persistence; a full/blocked store just means we re-anycast on the next load.
90
+ }
91
+ },
92
+ clear: () => {
93
+ try {
94
+ area()?.removeItem(AFFINITY_TICKET_KEY);
95
+ } catch {
96
+ // Best-effort.
97
+ }
98
+ },
99
+ };
100
+ }
101
+
58
102
  /** The connection's stable clientID. A per-origin base (localStorage) keeps one logical identity
59
103
  * across reloads; a per-tab suffix (sessionStorage) gives each tab its OWN mid/lmid stream; a
60
104
  * per-load instance suffix keeps two clients constructed in ONE tab from sharing that stream. Falls
package/src/client.ts CHANGED
@@ -24,15 +24,16 @@ import type {
24
24
  import {
25
25
  RemoteOptimisticSource,
26
26
  WsTransport,
27
+ createAffinityTicketStore,
27
28
  createQueuedMutationSender,
29
+ offerSubprotocols,
28
30
  } from "@rindle/remote";
29
- import type { PushOutcome, RemoteOptimisticConnection, Transport } from "@rindle/remote";
31
+ import type { AffinityTicketStore, PushOutcome, RemoteOptimisticConnection, Transport } from "@rindle/remote";
30
32
  import { initWasm } from "@rindle/wasm";
31
- import type { WritableDescriptor } from "@rindle/wasm";
32
33
 
33
34
  import type { OptimisticBackend } from "./backend.ts";
34
35
  import type { ClientRegistry, MutationTx } from "./backend.ts";
35
- import { resetStableClientID, stableClientID } from "./client-id.ts";
36
+ import { resetStableClientID, sessionTicketPersistence, stableClientID } from "./client-id.ts";
36
37
  import {
37
38
  LIFECYCLE_QUERY_NAME,
38
39
  ROOM_CLIENT_MUTATIONS_TABLE,
@@ -170,6 +171,15 @@ export interface RealtimeAnomaly {
170
171
  /** Rindle Realtime client knobs (Slice G-v). All optional — an app with no labeled queries never
171
172
  * touches any of this. */
172
173
  export interface RealtimeClientOptions {
174
+ /** The DECLARED room mutators (302 §5: declared, not derived). A mutator named here routes to
175
+ * the attached room — it stages onto the room's own tables and ships on the room socket —
176
+ * whenever exactly ONE room is attached; solo (no room) it takes the ordinary daemon path,
177
+ * and with several rooms attached it routes daemon too (explicit multi-room binding is a
178
+ * later slice). Every mutator NOT named here is a daemon mutator. A misdeclaration fails
179
+ * SOFT (302 §5.1): the write lands on the other authority's tables, so the view just stops
180
+ * feeling instant until the echo relays it — never a divergence. An explicit top-level
181
+ * `domainPolicy` overrides this entirely. */
182
+ mutators?: readonly string[];
173
183
  /** Build the ROOM ws transport for a lease's `realtime.wsEndpoint`. Default
174
184
  * `(endpoint) => new WsTransport(endpoint)`. Injectable for tests / custom ws impls. */
175
185
  transport?: (endpoint: string) => Transport;
@@ -189,11 +199,10 @@ export interface RealtimeInspect {
189
199
  string,
190
200
  {
191
201
  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[]>;
202
+ /** The room's OWNED tables (302 §2): wire table its namespaced engine table — read back
203
+ * from the BACKEND's registry (`backend.roomTablesFor`, the one source of truth; the
204
+ * client keeps no shadow copy). */
205
+ promoted: Record<string, string>;
197
206
  /** Live room-retained queries on this room, by remote key. */
198
207
  queries: Record<string, { name: string; sourceQid: QueryId; exp: number; refCount: number }>;
199
208
  }
@@ -209,6 +218,11 @@ const RENEW_RETRY_MS = 5_000;
209
218
  /** A one-shot token handoff not consumed within this window is stale — the resolver falls through
210
219
  * to a fresh lease POST instead of presenting a token the server may already refuse. */
211
220
  const HANDOFF_MAX_AGE_MS = 15_000;
221
+ /** How long a fresh (ticketless) connect waits for its affinity mint frame before leasing
222
+ * TICKETLESS + warning (FOLLOWER-AFFINITY-DESIGN.md §4.1). Generous — the frame normally arrives in
223
+ * well under one RTT; this bound only trips on an affinity-off daemon (misconfig / rolling upgrade),
224
+ * so it degrades loudly instead of hanging. */
225
+ const AFFINITY_TICKET_TIMEOUT_MS = 4_000;
212
226
  /** Client-minted remote-retain qids live in their own high band so they can never collide with the
213
227
  * Store's own 1, 2, 3, … view qids or the reserved per-channel lmid qid 0. Exact in f64 (the wire
214
228
  * number type), far below 2^53. */
@@ -236,8 +250,14 @@ export interface RindleClientOptions<S extends ColsMap, R extends ClientRegistry
236
250
  * different follower migrates the connection there.
237
251
  * - `{ wsUrl }` omitted — pure-lazy: the first lease's `wsEndpoint` opens the connection (a
238
252
  * routed SPA with no SSR bootstrap).
239
- * - `{ transport }` — a pre-built transport (tests / in-process); fixed, no migration. */
240
- daemon: { wsUrl?: string } | { transport: Transport };
253
+ * - `{ transport }` — a pre-built transport (tests / in-process); fixed, no migration.
254
+ *
255
+ * Set `affinity: true` (with `wsUrl` pointing at the fleet host, `app-<id>.rindle.cloud`) to run
256
+ * FOLLOWER-AFFINITY mode (design §2): the ws offers a placement ticket as a subprotocol, the
257
+ * client persists the follower's minted ticket (per-tab) and forwards it on the lease POST so both
258
+ * legs pin the same nearby follower — sticky, regional, monotone reads. Off (default) is today's
259
+ * single-daemon behavior, byte-identical. Ignored for a pre-built `{ transport }`. */
260
+ daemon: { wsUrl?: string; affinity?: boolean } | { transport: Transport };
241
261
  /** Stable client identity. Default: a per-origin base (localStorage) plus per-tab and per-instance
242
262
  * suffixes, so each tab — and each client instance within a tab — gets its own mid sequence yet a
243
263
  * reload keeps it; falls back to a fresh random id when web storage is unavailable. Pass a value
@@ -316,11 +336,51 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
316
336
  return text ? JSON.parse(text) : undefined;
317
337
  };
318
338
 
339
+ // FOLLOWER-AFFINITY mode (design §2): opt-in, and only for a fleet `wsUrl` connection (a pre-built
340
+ // transport has no fleet edge to route through). The store is shared by the ws transport (offers
341
+ // the ticket as a subprotocol), the source (records the follower's minted ticket / clears it on a
342
+ // dead follower), and the lease POST below (forwards it). Persisted per-TAB (sessionStorage) so two
343
+ // tabs can pin two regions (design §13) yet a reload lands back on the same follower.
344
+ const affinityOn = !("transport" in opts.daemon) && opts.daemon.affinity === true;
345
+ const affinityStore: AffinityTicketStore | undefined = affinityOn
346
+ ? createAffinityTicketStore(sessionTicketPersistence())
347
+ : undefined;
348
+
319
349
  // The ONE app-lease POST both legs share. Sends the stable `clientId` so the api-server/router
320
350
  // can use it as the anonymous routing key (READ-ROUTER-DESIGN.md §2.2); the reply's top-level
321
351
  // 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>;
352
+ // In affinity mode it ALSO forwards the placement `affinity` ticket — awaited first so a fresh
353
+ // (ticketless) connect leases only AFTER its mint frame arrives, co-locating both legs (§4.1). On
354
+ // reconnect, the WebSocket may offer the persisted ticket on its handshake, but the lease waits
355
+ // for that connection's fresh mint frame. The wait is BOUNDED: if no mint frame arrives (the daemon is
356
+ // affinity-off — a misconfig, or mid rolling-upgrade), we lease
357
+ // TICKETLESS and warn rather than hang. That first timeout LATCHES ticketless mode in the store:
358
+ // later leases return immediately (and do not accumulate abandoned waiters) until an affinity
359
+ // frame actually arrives. A persisted ticket remains useful for the ws handshake but is never
360
+ // forwarded on a lease until the CURRENT connection refreshes it, preventing a restored stale
361
+ // ticket from independently re-pinning the HTTP and ws legs.
362
+ let affinityFallbackWarned = false;
363
+ const postLease = async (remote: RemoteQuery): Promise<QueryLeaseWire> => {
364
+ let affinity: string | undefined;
365
+ if (affinityStore) {
366
+ const ticket = await affinityStore.leaseTicket(AFFINITY_TICKET_TIMEOUT_MS);
367
+ affinity = ticket.ticket;
368
+ if (affinity !== undefined) affinityFallbackWarned = false;
369
+ if (ticket.timedOut && !affinityFallbackWarned) {
370
+ affinityFallbackWarned = true;
371
+ console.warn(
372
+ `[rindle] no affinity ticket after ${AFFINITY_TICKET_TIMEOUT_MS}ms — leasing ticketless ` +
373
+ "(is the daemon affinity-enabled / RINDLE_AFFINITY_KEY set?)",
374
+ );
375
+ }
376
+ }
377
+ return post(routes.query, {
378
+ name: remote.name,
379
+ args: remote.args,
380
+ clientId: clientID,
381
+ ...(affinity !== undefined ? { affinity } : {}),
382
+ }) as Promise<QueryLeaseWire>;
383
+ };
324
384
 
325
385
  // One-shot fresh-token handoffs, by remote key: G-v's resolve-then-register (and the proactive
326
386
  // renewal) has ALREADY leased when the subscribe fires, so the resolver consumes the handed
@@ -359,12 +419,22 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
359
419
 
360
420
  // Reads-leg connection: a fixed transport (tests/in-process) or a replaceable connection built
361
421
  // from `wsUrl` (eager when present, lazy when omitted). A routed lease's `wsEndpoint` migrates it.
422
+ // In affinity mode each transport offers the current ticket as a subprotocol (evaluated per
423
+ // connect, so a reconnect presents the freshest — or freshly cleared — ticket).
362
424
  const connection: RemoteOptimisticConnection =
363
425
  "transport" in opts.daemon
364
426
  ? { transport: opts.daemon.transport }
365
- : { factory: (endpoint) => new WsTransport(endpoint), endpoint: opts.daemon.wsUrl };
427
+ : {
428
+ factory: (endpoint) =>
429
+ new WsTransport(
430
+ endpoint,
431
+ affinityStore ? { subprotocols: () => offerSubprotocols(affinityStore) } : {},
432
+ ),
433
+ endpoint: opts.daemon.wsUrl,
434
+ };
366
435
 
367
436
  const source = new RemoteOptimisticSource(connection, clientID, {
437
+ ...(affinityStore ? { affinity: affinityStore } : {}),
368
438
  resolveSubscribe: async ({ remote }) => {
369
439
  // A fail-open labeled register already leased — present exactly that token (see
370
440
  // `tokenHandoffs`); otherwise lease now. Read back the follower's `wsEndpoint` for placement.
@@ -401,7 +471,14 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
401
471
  const { store, backend, mutate } = createOptimisticStore(opts.schema, source, opts.mutators, {
402
472
  clientID,
403
473
  user: opts.user,
404
- ...(opts.domainPolicy ? { domainPolicy: opts.domainPolicy } : {}),
474
+ // The DECLARED router (302 §5): an explicit `domainPolicy` wins; otherwise the app's declared
475
+ // realtime mutators route to the one attached room (`rooms` is read lazily at invoke time —
476
+ // it is declared below, after this construction).
477
+ ...(opts.domainPolicy
478
+ ? { domainPolicy: opts.domainPolicy }
479
+ : opts.realtime?.mutators !== undefined
480
+ ? { domainPolicy: declaredMutatorPolicy(new Set(opts.realtime.mutators), () => rooms) }
481
+ : {}),
405
482
  // Room-plane rejection parity (H-v): a room's `mutationOutcome {kind:"rejected"}` frame
406
483
  // surfaces through the SAME callback the HTTP mutate path uses below — one app-level
407
484
  // rejection surface, whichever authority said no.
@@ -434,9 +511,9 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
434
511
  };
435
512
 
436
513
  /** 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`). */
514
+ * bookkeeping lives in the backend's 302 roomTables record (`backend.roomTablesFor(sourceKey)`
515
+ * the wire→namespaced-twin rename map; one source of truth for the gate's rename/DROP, the
516
+ * idempotence check, and `__realtimeInspect`). */
440
517
  interface RoomConnection {
441
518
  sourceKey: string;
442
519
  wsEndpoint: string;
@@ -541,26 +618,21 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
541
618
  claims.clear();
542
619
  };
543
620
 
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. */
621
+ /** Register the room's OWNED tables (302 §2 one source per table): every lease table spec
622
+ * whose `writable` kind is not `"none"` names a table the room owns; the backend registers a
623
+ * namespaced engine twin the room channel feeds and the room-homed views swap onto. Context
624
+ * tables (`kind: "none"`) are deliberately NOT registered the daemon is their sole
625
+ * authority, and the gate DROPS the room's relayed copies (302 §6). Idempotent per
626
+ * (sourceKey, table) — the backend's record is the one source of truth; a footprint table
627
+ * absent from the client schema has nothing to hold rows for and is skipped backend-side. */
552
628
  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
- }
629
+ const owned = specs.filter((s) => s.writable.kind !== "none").map((s) => s.table);
630
+ // ALWAYS register — even an all-context lease's `owned = []`: the installed (empty) map is
631
+ // what makes the room gate DROP every relayed delta (302 §6). Skipping the call would leave
632
+ // `gate.tableMap` undefined — the DAEMON identity path — and fold the room's relayed copies
633
+ // of daemon-authoritative rows verbatim into the plain tables, two syncs fighting over one
634
+ // baseline (stale overwrites + dueling GC removes).
635
+ backend.registerRoomTables(room.sourceKey, owned);
564
636
  };
565
637
 
566
638
  /** (Re)arm a room query's proactive renewal from its current `exp`. Timers are unref'd (Node)
@@ -1214,11 +1286,8 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
1214
1286
  sourceKey,
1215
1287
  {
1216
1288
  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
- ),
1289
+ // Read back from the backend's room-table registry (302 §2): wire engine table.
1290
+ promoted: Object.fromEntries(backend.roomTablesFor(sourceKey)),
1222
1291
  queries: Object.fromEntries(
1223
1292
  [...roomQueries]
1224
1293
  .filter(([, s]) => s.sourceKey === sourceKey)
@@ -1243,16 +1312,20 @@ interface MaterializedViewLike {
1243
1312
  destroy(): void;
1244
1313
  }
1245
1314
 
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 };
1315
+ /** The default DECLARED router (302 §5) when the app names `realtime.mutators` and passes no
1316
+ * explicit `domainPolicy`: a declared mutator routes to the ONE attached room; solo or
1317
+ * multi-room it abstains (⇒ daemon). `getRooms` is read lazily per invoke so the policy tracks
1318
+ * attach/downgrade live. */
1319
+ function declaredMutatorPolicy(
1320
+ declared: ReadonlySet<string>,
1321
+ getRooms: () => ReadonlyMap<string, unknown>,
1322
+ ): (name: string, args: unknown) => string | undefined {
1323
+ return (name) => {
1324
+ if (!declared.has(name)) return undefined;
1325
+ const rooms = getRooms();
1326
+ if (rooms.size !== 1) return undefined; // solo or ambiguous — the daemon path (302 §5)
1327
+ return rooms.keys().next().value as string;
1328
+ };
1256
1329
  }
1257
1330
 
1258
1331
  /** Flip a just-materialized labeled view back to `unknown` for the lease-resolve window. The
package/src/index.ts CHANGED
@@ -41,10 +41,6 @@ export type {
41
41
  ReadOutcome,
42
42
  ReadRecord,
43
43
  ResultType,
44
- RoomTableRouting,
45
- RoomTableRoutingSpec,
46
- RoutingFailureReason,
47
- RoutingInspect,
48
44
  ScopeSessionsEvent,
49
45
  SystemStreamSpec,
50
46
  SystemStreamTable,
@@ -62,7 +58,11 @@ export {
62
58
  roomDomainKey,
63
59
  SCOPE_SESSIONS_TABLE,
64
60
  } from "./system-streams.ts";
65
- export type { MutationEnvelope, OptimisticSource, ProgressFrame } from "@rindle/client";
61
+ export type {
62
+ MutationEnvelope,
63
+ OptimisticSource,
64
+ ProgressFrame,
65
+ } from "@rindle/client";
66
66
  // The shared (generator) mutator seam — a registry may hold plain client mutators OR these isomorphic
67
67
  // generators (the SAME body the API server runs); re-exported here so an app registers from one import.
68
68
  export { isoTx } from "@rindle/client";