@rindle/client 0.4.4 → 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/src/query.ts CHANGED
@@ -83,6 +83,10 @@ interface QueryBase<C extends AnyCols, Rels, One extends boolean, Sel extends st
83
83
  readonly name?: string;
84
84
  /** Present when this local query came from `defineQuery`; sent with `name` upstream. */
85
85
  readonly args?: unknown;
86
+ /** Present when this local query came from a realtime-labeled `defineQuery`
87
+ * (RINDLE-REALTIME-QUERY-ENABLEMENT §2.1). Declaration metadata only — it never joins the
88
+ * wire identity and never changes the AST. */
89
+ readonly realtime?: RealtimeQueryLabel;
86
90
  /** Condition form — consumes `or()`/`and()`/`exists()`/field conditions (AND-ed across calls).
87
91
  * Filters over the FULL column set (`RowOf<C>`), independent of what's `select`ed/masked. */
88
92
  where(cond: Cond<RowOf<C>>): Query<C, Rels, One, Sel, LocalRels>;
@@ -314,15 +318,20 @@ export type QueryLocalData<Q extends AnyQuery> =
314
318
  const STAMP_NAMED_QUERY: unique symbol = Symbol("rindle.stampNamedQuery");
315
319
 
316
320
  interface QueryInternals {
317
- [STAMP_NAMED_QUERY](name: string, args: unknown): unknown;
321
+ [STAMP_NAMED_QUERY](name: string, args: unknown, realtime?: RealtimeQueryLabel): unknown;
318
322
  }
319
323
 
320
- function stampNamedQuery<Q extends AnyQuery>(query: Q, name: string, args: unknown): Q {
324
+ function stampNamedQuery<Q extends AnyQuery>(
325
+ query: Q,
326
+ name: string,
327
+ args: unknown,
328
+ realtime?: RealtimeQueryLabel,
329
+ ): Q {
321
330
  const stamp = (query as unknown as QueryInternals)[STAMP_NAMED_QUERY];
322
331
  if (typeof stamp !== "function") {
323
332
  throw new Error("defineQuery's build must return a Query built by newQueryBuilder/queries");
324
333
  }
325
- return stamp(name, args) as Q;
334
+ return stamp(name, args, realtime) as Q;
326
335
  }
327
336
 
328
337
  /** Turn raw, UNTRUSTED wire args into the canonical, typed args a query is built from. Its return
@@ -351,6 +360,56 @@ export type QueryBuilder<Args, Q extends AnyQuery, Ctx extends readonly unknown[
351
360
  ...ctx: Ctx
352
361
  ) => Q;
353
362
 
363
+ /**
364
+ * The realtime LABEL a named query may declare (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §2.1):
365
+ * which api-server room PROFILE the query wants to be served from, and how the query's args map
366
+ * to that profile's key args. This is the DECLARATION only — the serve decision (covering proof,
367
+ * lease routing) is the server's, and the final room key is minted server-side by the profile's
368
+ * own `key` under authoritative ctx, so a label can never place a query in a room the server
369
+ * didn't derive itself. Both tiers import the SAME `defineQuery` value, so they always agree
370
+ * *which profile* a query belongs to.
371
+ */
372
+ export interface RealtimeQueryLabel<Args = any> {
373
+ /** The room profile name — must match a `realtime.rooms` key on the api-server (validated
374
+ * loudly at `createRindleApiServer` construction). May not contain `/` (the wire room-key
375
+ * delimiter). */
376
+ readonly room: string;
377
+ /** Map the query's VALIDATED args to the profile's key args (what the server feeds the
378
+ * profile's `key(args)`). Identity when omitted. Must be pure — both tiers may run it. */
379
+ readonly args?: (queryArgs: Args) => unknown;
380
+ }
381
+
382
+ /** Options for {@link defineQuery}. */
383
+ export interface DefineQueryOptions<Args = any> {
384
+ /** Declare this query realtime-eligible — see {@link RealtimeQueryLabel}. */
385
+ realtime?: RealtimeQueryLabel<Args>;
386
+ }
387
+
388
+ /** Loud, definition-time validation of a realtime label — a malformed label is a config bug the
389
+ * author should hit at module load, not a query that silently never room-serves. */
390
+ function validateRealtimeLabel(
391
+ name: string,
392
+ label: RealtimeQueryLabel<any> | undefined,
393
+ ): RealtimeQueryLabel<any> | undefined {
394
+ if (label === undefined) return undefined;
395
+ if (typeof label.room !== "string" || label.room.length === 0) {
396
+ throw new Error(`defineQuery("${name}"): realtime.room must be a non-empty room-profile name.`);
397
+ }
398
+ if (label.room.includes("/")) {
399
+ throw new Error(
400
+ `defineQuery("${name}"): realtime.room "${label.room}" may not contain "/" — it delimits the ` +
401
+ `wire room key ("<profile>/<key>").`,
402
+ );
403
+ }
404
+ if (label.args !== undefined && typeof label.args !== "function") {
405
+ throw new Error(
406
+ `defineQuery("${name}"): realtime.args must be a function mapping the query's args to the ` +
407
+ `profile's key args (or omitted for identity).`,
408
+ );
409
+ }
410
+ return label;
411
+ }
412
+
354
413
  /**
355
414
  * A single, co-located NAMED query (see {@link defineQuery}). It is:
356
415
  * - **callable on the client** — `q(args, ctx?)` validates + builds + stamps the result with its
@@ -368,6 +427,10 @@ export interface NamedQuery<Args, Ctx extends readonly unknown[], Q extends AnyQ
368
427
  /** The wire identity. The daemon leases by this name; client and server MUST agree on it — which
369
428
  * they do, because both sides import the SAME `defineQuery` value. */
370
429
  readonly queryName: string;
430
+ /** The §2.1 realtime label, when this query was defined with one ({@link DefineQueryOptions}).
431
+ * Absent on unlabeled queries. Carried onto the stamped built Query too, and preserved through
432
+ * `registerQueries` on the server. */
433
+ readonly realtime?: RealtimeQueryLabel<Args>;
371
434
  /** Server-side: validate raw wire args, then build the authoritative `Query` from the server's
372
435
  * authoritative ctx (forwarded by `registerQueries`). */
373
436
  resolve(rawArgs: unknown, ...ctx: Ctx): Q;
@@ -406,29 +469,43 @@ export interface NamedQuery<Args, Ctx extends readonly unknown[], Q extends AnyQ
406
469
  * // client: myIssuesQuery({ limit: 20 }, { user: currentUser() })
407
470
  * ```
408
471
  */
409
- export function defineQuery<Q extends AnyQuery>(name: string, build: () => Q): NamedQuery<void, [], Q>;
472
+ export function defineQuery<Q extends AnyQuery>(
473
+ name: string,
474
+ build: () => Q,
475
+ options?: DefineQueryOptions<void>,
476
+ ): NamedQuery<void, [], Q>;
410
477
  export function defineQuery<Args, Ctx extends readonly unknown[], Q extends AnyQuery>(
411
478
  name: string,
412
479
  build: (args: Args, ...ctx: Ctx) => Q,
480
+ options?: DefineQueryOptions<Args>,
413
481
  ): NamedQuery<Args, Ctx, Q>;
414
482
  export function defineQuery<Args, Ctx extends readonly unknown[], Q extends AnyQuery>(
415
483
  name: string,
416
484
  validate: QueryValidator<Args>,
417
485
  build: (args: Args, ...ctx: Ctx) => Q,
486
+ options?: DefineQueryOptions<Args>,
418
487
  ): NamedQuery<Args, Ctx, Q>;
419
488
  export function defineQuery(
420
489
  name: string,
421
490
  validateOrBuild: (...a: any[]) => any,
422
- maybeBuild?: (...a: any[]) => any,
491
+ maybeBuildOrOptions?: ((...a: any[]) => any) | DefineQueryOptions<any>,
492
+ maybeOptions?: DefineQueryOptions<any>,
423
493
  ): NamedQuery<any, any, AnyQuery> {
424
- const hasValidator = maybeBuild !== undefined;
494
+ const hasValidator = typeof maybeBuildOrOptions === "function";
425
495
  const validate = (hasValidator ? validateOrBuild : (raw: unknown) => raw) as (raw: unknown) => unknown;
426
- const build = (hasValidator ? maybeBuild : validateOrBuild) as (args: unknown, ...ctx: unknown[]) => AnyQuery;
496
+ const build = (hasValidator ? maybeBuildOrOptions : validateOrBuild) as (args: unknown, ...ctx: unknown[]) => AnyQuery;
497
+ const options = hasValidator ? maybeOptions : (maybeBuildOrOptions as DefineQueryOptions<any> | undefined);
498
+ // §2.1 realtime label — pure declaration metadata, validated loudly at definition time.
499
+ const realtime = validateRealtimeLabel(name, options?.realtime);
427
500
  const resolve = (rawArgs: unknown, ...ctx: unknown[]): AnyQuery => build(validate(rawArgs), ...ctx);
428
501
  // The wire identity is (name, args) ONLY — ctx is never stamped, so it never crosses the wire.
502
+ // The realtime label rides the stamp as metadata beside the identity, never inside it.
429
503
  const call = (args: unknown, ...ctx: unknown[]): AnyQuery =>
430
- stampNamedQuery(build(validate(args), ...ctx), name, args ?? null);
431
- return Object.assign(call, { queryName: name, resolve }) as NamedQuery<any, any, AnyQuery>;
504
+ stampNamedQuery(build(validate(args), ...ctx), name, args ?? null, realtime);
505
+ return Object.assign(
506
+ call,
507
+ realtime === undefined ? { queryName: name, resolve } : { queryName: name, resolve, realtime },
508
+ ) as NamedQuery<any, any, AnyQuery>;
432
509
  }
433
510
 
434
511
  // ----------------------------- fragments (FRAGMENT-COMPOSITION-DESIGN.md, Phase 0) -----------------------------
@@ -708,6 +785,8 @@ interface State {
708
785
  interface NamedQueryState {
709
786
  name: string;
710
787
  args: unknown;
788
+ /** The §2.1 realtime label (metadata only — never part of the wire identity). */
789
+ realtime?: RealtimeQueryLabel;
711
790
  }
712
791
 
713
792
  function emptyState(table: string): State {
@@ -965,6 +1044,7 @@ function makeQuery(
965
1044
  const base: Record<string, unknown> = {
966
1045
  name: named?.name,
967
1046
  args: named?.args,
1047
+ realtime: named?.realtime,
968
1048
  where: (cond: Condition) => next({ wheres: [...s.wheres, cond] }),
969
1049
  orderBy: (col: string, dir: Dir) => next({ orderBy: [...s.orderBy, [col, dir]] }),
970
1050
  select: (...cols: string[]) => next({ select: [...(s.select ?? []), ...cols] }),
@@ -1075,7 +1155,8 @@ function makeQuery(
1075
1155
  get(target, prop) {
1076
1156
  if (prop === "where") return whereProxy;
1077
1157
  if (prop === STAMP_NAMED_QUERY)
1078
- return (name: string, args: unknown) => makeQuery(meta, s, onMat, { name, args }, guardAst);
1158
+ return (name: string, args: unknown, realtime?: RealtimeQueryLabel) =>
1159
+ makeQuery(meta, s, onMat, { name, args, realtime }, guardAst);
1079
1160
  if (typeof prop === "string" && prop.length > 5 && prop.startsWith("where")) {
1080
1161
  const field = prop[5].toLowerCase() + prop.slice(6);
1081
1162
  return (arg: unknown) => applyField(field, arg);
package/src/store.ts CHANGED
@@ -118,6 +118,12 @@ export class Store<S extends ColsMap> {
118
118
  // Public per-query {@link ResultType} subscribers ({@link subscribeResultType}). `undefined` until
119
119
  // the first subscription. Devtools and any status-driven layer ride this instead of a private tap.
120
120
  private resultTypeListeners?: Set<(qid: QueryId, rt: ResultType) => void>;
121
+ // Whether the backend drives a per-query resultType lifecycle (`onResultType`). When it does, a
122
+ // REMOTE query is marked PENDING (`unknown`) at register (`registerMaterialized`) so its synchronous
123
+ // pre-sync snapshot — the optimistic/wasm backend fetches local, not-yet-synced state INSIDE
124
+ // `registerQuery` — can't be mistaken for the authoritative answer and retire the SSR seed. When it
125
+ // doesn't, every view stays `complete` and the first snapshot IS authoritative (unchanged behavior).
126
+ private readonly hasResultTypeLifecycle: boolean;
121
127
  // Cross-view-atomic notification (the `Backend.onCommitBoundary` contract): while the backend is
122
128
  // delivering one commit's coherent multi-query batch, fold every affected view but DEFER each
123
129
  // view's subscriber notification, collecting the changed views here; at the commit's `end` flush
@@ -137,6 +143,7 @@ export class Store<S extends ColsMap> {
137
143
  constructor(schema: Schema<S>, backend: Backend) {
138
144
  this.schema = schema;
139
145
  this.backend = backend;
146
+ this.hasResultTypeLifecycle = typeof backend.onResultType === "function";
140
147
  this.backend.onEvent((qid, ev) => this.onEvent(qid, ev));
141
148
  // The in-process engine brackets each commit's multi-query delivery so every affected view
142
149
  // folds before any subscriber is notified (see `commitDepth`/`pendingFlush`). A backend with no
@@ -182,6 +189,47 @@ export class Store<S extends ColsMap> {
182
189
  return this.registerMaterialized(query.ast(), remote, opts?.onChanges).view as ReturnType<Q["materialize"]>;
183
190
  }
184
191
 
192
+ /** One-shot AUTHORITATIVE read: materialize `query`, wait until its result is server-authoritative
193
+ * ({@link ResultType} `"complete"`), read the data once, then destroy the view — resolving with the
194
+ * plain result rather than a live subscription. Rejects if the query enters the `"error"` state. Use
195
+ * it for exports, imports, undo snapshots — anywhere that wants the current answer as a value.
196
+ *
197
+ * A synchronous local-first backend (wasm/replica) has already delivered the first snapshot inside
198
+ * {@link materialize}, so the view is `"complete"` on entry and this settles on the next microtask
199
+ * without ever attaching a listener; a remote backend settles when the first live snapshot lands.
200
+ * The query is NEVER left subscribed — the view is destroyed before the promise settles either way.
201
+ * (A remote query that never completes leaves the promise pending, exactly as a `resultType` poll
202
+ * would; race a timeout at the call site if you need one.) */
203
+ readOnce<Q extends Query<any, any, any>>(query: Q): Promise<ReturnType<Q["materialize"]>["data"]> {
204
+ type Data = ReturnType<Q["materialize"]>["data"];
205
+ const view = this.materialize(query);
206
+ return new Promise<Data>((resolve, reject) => {
207
+ let settled = false;
208
+ let detach: (() => void) | undefined;
209
+ const settle = (): boolean => {
210
+ const rt = view.resultType;
211
+ if (rt !== "complete" && rt !== "error") return false;
212
+ settled = true;
213
+ detach?.();
214
+ if (rt === "error") {
215
+ view.destroy();
216
+ reject(new Error("store.readOnce: query entered the error result state"));
217
+ } else {
218
+ const data = view.data as Data;
219
+ view.destroy();
220
+ resolve(data);
221
+ }
222
+ return true;
223
+ };
224
+ // A synchronous backend is already `complete` here — settle now, never attaching a listener.
225
+ // (`subscribeResultType` never replays on attach, so the pre-check is what covers this case.)
226
+ if (settle()) return;
227
+ detach = this.subscribeResultType((qid) => {
228
+ if (!settled && qid === view.qid) settle();
229
+ });
230
+ });
231
+ }
232
+
185
233
  /** True when the backend can retain a remote named query independently from the local
186
234
  * materialized AST view. React uses this to keep one local view per AST while still sending
187
235
  * every mounted `(name,args)` lease through the backend. */
@@ -398,6 +446,19 @@ export class Store<S extends ColsMap> {
398
446
  // Bind the narrator BEFORE `registerQuery` fires (a synchronous backend dispatches this query's
399
447
  // first `hello`+`snapshot` inside it) so the initial snapshot reaches the change listener too.
400
448
  if (onChanges) view.onChanges(onChanges);
449
+ // SSR first paint (SSR-DESIGN.md §6): seed the view — and, for a lifecycle-backed REMOTE query,
450
+ // mark it PENDING (`unknown`) — BEFORE `registerQuery`, because a synchronous optimistic/wasm
451
+ // backend fires this query's first `hello`+`snapshot` from LOCAL, not-yet-synced state INSIDE that
452
+ // call. Doing both here first means (a) that pre-sync snapshot finds the seed already applied (so
453
+ // `data` shows it, not an empty tree) and (b) the view already reads `unknown`, so `retireSeedIfLive`
454
+ // KEEPS the seed through it — on the optimistic backend the real hydration point is the later
455
+ // `catchUp` batch (which flips the query to `complete` FIRST, then folds the authoritative rows).
456
+ // A LOCAL query is authoritative at register (its local snapshot IS the answer), so it keeps the
457
+ // default `complete`; a lifecycle-LESS backend has no `onResultType`, so every view stays `complete`
458
+ // and its first snapshot retires the seed exactly as before.
459
+ const seed = this.seeds.get(stableKey(ast));
460
+ if (seed) view.seed(seed.rows);
461
+ if (remote !== undefined && this.hasResultTypeLifecycle) view.setResultType("unknown");
401
462
  // If the backend rejects the registration (E3: a remote query naming a local-only table), roll
402
463
  // back the per-qid state we just created — otherwise the view + ast entry leak (the caller never
403
464
  // gets a handle to `destroy()` them, since the throw aborts before we return).
@@ -408,11 +469,6 @@ export class Store<S extends ColsMap> {
408
469
  this.asts.delete(qid);
409
470
  throw e;
410
471
  }
411
- // SSR first paint (SSR-DESIGN.md §6): if this AST was preloaded/hydrated, seed the view now.
412
- // A synchronous backend (wasm) already reset the view above, so its live data wins and the
413
- // seed is inert; an async backend (ws) leaves it PENDING, so the seed shows until `hello`.
414
- const seed = this.seeds.get(stableKey(ast));
415
- if (seed) view.seed(seed.rows);
416
472
  // A top-level `.one()` (engine-capped to limit 1) unwraps at the result boundary.
417
473
  return { qid, view: ast.one ? new SingularView(view) : view };
418
474
  }
@@ -441,19 +497,19 @@ export class Store<S extends ColsMap> {
441
497
  const view = this.views.get(qid) ?? this.views.set(qid, new FlatArrayView(undefined, undefined, qid)).get(qid)!;
442
498
  view.reset(ev.schema, types);
443
499
  } else if (ev.type === "snapshot") {
444
- // The first live snapshot IS the query's hydration point (even an empty one 0 rows is an
445
- // authoritative answer): retire the SSR seed now, from the view (so `data` switches to the
446
- // maintained tree) AND from the seeds map (so no later mount re-seeds a now-live query),
447
- // BEFORE folding so the fold's notify already reflects the live tree with no empty gap.
448
- const ast = this.asts.get(qid);
449
- if (ast) this.seeds.delete(stableKey(ast));
450
- this.views.get(qid)?.retireSeed();
451
- this.applyAndTrack(qid, ev.adds, "snapshot");
500
+ // A snapshot is a hydration point retire the SSR seed and fold, gated on the query being
501
+ // AUTHORITATIVE. {@link foldHydration} handles the empty-fold case (a re-hydrate to nothing).
502
+ this.foldHydration(qid, ev.adds, "snapshot");
503
+ } else if (ev.catchUp) {
504
+ // A `catchUp` batch is a query's initial hydration delivered as a delta — on the optimistic /
505
+ // normalized backend THIS (not the earlier pre-sync snapshot) is the real hydration point,
506
+ // arriving right after the query flips to `complete`. Retire the seed + fold, phased as a
507
+ // `snapshot` so a narrator's "what CHANGED" default ignores the initial rows.
508
+ this.foldHydration(qid, ev.events, "snapshot");
452
509
  } else {
453
- // A `catchUp` batch is a query's initial hydration delivered as a delta (the optimistic
454
- // backend hydrates through its reconcile cycle), so phase it as a `snapshot` a narrator's
455
- // "what CHANGED" default then ignores the initial rows instead of narrating each as an add.
456
- this.applyAndTrack(qid, ev.events, ev.catchUp ? "snapshot" : "batch");
510
+ // A plain (non-catchUp) batch is a post-hydration delta the seed is long gone by then, and
511
+ // this is the incremental hot path, so it does no seed work at all.
512
+ this.applyAndTrack(qid, ev.events, "batch");
457
513
  }
458
514
  // Fan the same post-fold frame out to subscribers (narration, devtools, …). Inside a commit
459
515
  // bracket the frames are BUFFERED and delivered together at the boundary (after every view has
@@ -471,6 +527,55 @@ export class Store<S extends ColsMap> {
471
527
  }
472
528
  }
473
529
 
530
+ /** Retire a view's SSR seed — from the view (so `data` switches from the seed to the maintained
531
+ * tree) AND from the seeds map (so no later mount re-seeds a now-live query) — but ONLY once the
532
+ * query is AUTHORITATIVE (`resultType === "complete"`). Called BEFORE the fold it accompanies, so
533
+ * that fold's notify already reflects the live tree with no empty gap. Idempotent.
534
+ *
535
+ * The gate is the fix for the synchronous optimistic/wasm backend: it fires a query's FIRST snapshot
536
+ * from LOCAL, not-yet-synced state while the query is still `unknown` (`registerMaterialized` marks a
537
+ * lifecycle-backed remote view `unknown` up front for exactly this), then delivers the authoritative
538
+ * rows one event later as a `catchUp` batch — having already flipped the query to `complete`. So the
539
+ * seed survives the pre-sync snapshot (`unknown` ⇒ skip) and retires on the catch-up (`complete` ⇒
540
+ * retire). A lifecycle-LESS backend (pure wasm, the SSR one-shot, tests) is `complete` from creation,
541
+ * so its first snapshot retires the seed exactly as before this gate existed. */
542
+ private retireSeedIfLive(qid: QueryId): boolean {
543
+ const view = this.views.get(qid);
544
+ if (!view || view.resultType !== "complete") return false;
545
+ const retired = view.retireSeed(); // idempotent — false once the seed is already retired
546
+ // Drop the map entry (so no later mount re-seeds a now-live query) only when we actually retired,
547
+ // and only pay the `stableKey` hash while a seed is outstanding (the map is empty on a no-SSR app).
548
+ if (retired && this.seeds.size > 0) {
549
+ const ast = this.asts.get(qid);
550
+ if (ast) this.seeds.delete(stableKey(ast));
551
+ }
552
+ return retired;
553
+ }
554
+
555
+ /** Retire the SSR seed (if authoritative) and fold the accompanying hydration delta — BEFORE the
556
+ * fold so its notify already reflects the live tree with no empty gap. The subtlety: a hydration
557
+ * can fold NOTHING — a 0-row authoritative result, or one whose rows are already present in `top`
558
+ * (a query whose result is fully covered by an already-hydrated sibling: the shared rows dedup to
559
+ * zero net base mutations). Then {@link FlatArrayView.applyChanges} notifies nothing, so the
560
+ * seed→tree switch would never reach subscribers and the view freezes on the stale seed. Guard
561
+ * against that: if the seed retired but the fold was a no-op, force the handoff notify (inline, or
562
+ * via the commit-boundary flush). Flash-safe — the forced notify only fires when there was nothing
563
+ * to fold, so `data` is already the correct live tree by then. */
564
+ private foldHydration(qid: QueryId, events: FlatChange[], phase: ChangePhase): void {
565
+ const retired = this.retireSeedIfLive(qid);
566
+ const view = this.views.get(qid);
567
+ if (!view) return;
568
+ const deferring = this.commitDepth > 0;
569
+ const changed = view.applyChanges(events, this.removedSubtreeWanted > 0, deferring, phase);
570
+ if (deferring) {
571
+ // Flush at the boundary if the fold changed the tree OR a retire is owed a notify (flush()
572
+ // always notifies, even with no buffered segments).
573
+ if (changed || retired) this.pendingFlush.add(view);
574
+ } else if (retired && !changed) {
575
+ view.notify(); // the fold notified nothing but the seed retired — land the handoff
576
+ }
577
+ }
578
+
474
579
  /** Fold a batch into its view, then notify now or — inside a commit bracket — defer the view's
475
580
  * notification to the commit boundary, so all sibling views fold first (cross-view-atomic
476
581
  * notification; see `commitDepth`). */
package/src/types.ts CHANGED
@@ -236,6 +236,33 @@ export interface MutationEnvelope {
236
236
  args: unknown;
237
237
  }
238
238
 
239
+ /** A room authority's verdict for a NON-applied mutation (RINDLE-REALTIME-QUERY-ENABLEMENT
240
+ * §3.3, the deopt handshake; Slice H-iv-b server half / H-v client half). Sent on the author's
241
+ * own socket for every mutation the room did NOT apply, always BEFORE the lmid ack that burns
242
+ * the `mid` (same-socket ordering only — the ack may reach the client through another path
243
+ * first, e.g. a replayed lmid snapshot). Applied mutations send NOTHING: silence + lmid
244
+ * coverage ⇒ applied.
245
+ *
246
+ * - `kind: "deopt"` — the room's §3.3 commit gate refused the routed mutation (or its
247
+ * environment fell short, e.g. `tx.query`); the mid is burnt in the ROOM ledger with zero
248
+ * effects and the client re-enqueues the same logical mutation onto the daemon stream.
249
+ * `name`/`args` are echoed so the frame is SELF-CONTAINED: a client that already retired the
250
+ * entry (the burnt-mid confirm won the race, or the frame is a replay re-answer) re-invokes
251
+ * from the frame alone.
252
+ * - `kind: "rejected"` — FINAL (authz/validation): the mid is burnt the same way and the
253
+ * prediction snaps back on the ordinary lmid release; no re-route.
254
+ *
255
+ * `reason` may be absent on a re-answered frame whose record was seeded from journal replay
256
+ * (the verdict is journaled; the reason is not). */
257
+ export interface MutationOutcomeFrame {
258
+ mid: number;
259
+ kind: "deopt" | "rejected";
260
+ reason?: string;
261
+ /** Echoed on DEOPT frames only (self-contained re-invoke — see above). */
262
+ name?: string;
263
+ args?: unknown;
264
+ }
265
+
239
266
  /** The connection-level progress frame (§8.6): advances the coherent-apply release point
240
267
  * (`cvMin`). A pure release signal — mutation confirmation does NOT ride it: `lmid` is a
241
268
  * row in {@link CLIENT_MUTATIONS_TABLE}, delivered through the client's own per-client
@@ -243,6 +270,13 @@ export interface MutationEnvelope {
243
270
  * `cvMin` as the commit's effects (transactionally coherent by construction). */
244
271
  export interface ProgressFrame {
245
272
  cvMin: number;
273
+ /** A ROOM shell's upstream-absorption advert (`301-ECHO-FENCE-DESIGN.md` §1.2, optional —
274
+ * old shells omit both): "I have absorbed upstream daemon commits through cv `upstreamCv`
275
+ * of daemon boot `upstreamBoot`." The client's §301 direction-B pin fence: a pin stamped
276
+ * `(boot, cv)` at its parking daemon release drops once the room advertises coverage under
277
+ * the §2.4 boot rule. The daemon's own progress frames never carry these. */
278
+ upstreamCv?: number;
279
+ upstreamBoot?: string;
246
280
  }
247
281
 
248
282
  /** The replicated bookkeeping table carrying each client's high-water mutation id
@@ -283,4 +317,34 @@ export interface OptimisticSource {
283
317
  * boot id). The backend resets its `cv` watermark so the server's reset `cv` sequence is
284
318
  * accepted rather than dropped as stale. In-process sources never restart and omit it. */
285
319
  onRestart?(handler: () => void): void;
320
+ /** Optional (§301 §2.4): the transport's observed authority BOOT-ID stream — fired on the
321
+ * FIRST observation and on every change (the same signal that drives {@link onRestart}, plus
322
+ * the initial one). The backend keeps client-local ordinals over these opaque ids; the
323
+ * direction-B pin fence's boot rule compares a room's advertised
324
+ * {@link ProgressFrame.upstreamBoot} against them. Sources that cannot observe one omit it —
325
+ * direction-B pins then hold to the state-match fallback across restarts (conservative). */
326
+ onBootId?(handler: (bootId: string) => void): void;
327
+ /** Optional (Slice H-v): the channel's {@link MutationOutcomeFrame} stream — the room deopt
328
+ * handshake's client half. **OUT-OF-BAND BY DESIGN**: the frame carries no `cv` and the source
329
+ * MUST dispatch it immediately on arrival, never behind the cv buffer — a deopt has to migrate
330
+ * its pending entry BEFORE the buffered lmid release that would otherwise retire it as a
331
+ * success (and the §7.3 hold-back trigger, keyed on the entry's confirming domain, would then
332
+ * park its staged writes the wrong way). Sources whose authority never deopts (the in-process
333
+ * native source, a plain daemon) omit it. */
334
+ onMutationOutcome?(handler: (frame: MutationOutcomeFrame) => void): void;
335
+ /** Optional (Slice H-v, the §7.5 rule-3 crash-window closer): fired when the transport
336
+ * RE-establishes its session (reconnect → re-`init`), BEFORE any post-reconnect frame is
337
+ * processed — the ordering is load-bearing: the re-subscribed lmid stream's fresh snapshot may
338
+ * cover a mid whose `mutationOutcome` frame died with the old socket, and once that release
339
+ * retires the entry as an apparent success there is nothing left to re-send. The backend
340
+ * re-sends this domain's unconfirmed pending envelopes with their ORIGINAL mids, in mid order;
341
+ * the source may DEFER their delivery until the session is re-authorized (a room's
342
+ * `pushMutation` requires the lease-token subscribe's subject). Idempotent under the domain's
343
+ * own ledger — a processed mid dedups silently (silence + lmid coverage ⇒ applied), a
344
+ * non-applied mid is re-answered from the authority's recorded-outcome map (resolving even an
345
+ * already-retired entry through the handshake's not-found arm). Distinct from
346
+ * {@link onRestart} (a NEW server incarnation): a same-incarnation socket drop re-syncs
347
+ * without restarting, and envelopes sent into the dead socket are exactly what this recovers.
348
+ * In-process sources never drop a session and omit it. */
349
+ onResync?(handler: () => void): void;
286
350
  }
package/src/view.ts CHANGED
@@ -437,10 +437,15 @@ export class FlatArrayView<R = unknown> implements ArrayView<R> {
437
437
 
438
438
  /** Retire the SSR first-paint seed — the Store calls this as it folds the maintained tree's first
439
439
  * live snapshot, so `data` switches from the seed to the live tree with no empty gap between them
440
- * (the seed deliberately survived the earlier `reset`/`hello`). Idempotent; a no-op once retired.
441
- * Does NOT notify the snapshot fold it accompanies does. */
442
- retireSeed(): void {
440
+ * (the seed deliberately survived the earlier `reset`/`hello`). Idempotent. Does NOT notify — the
441
+ * snapshot fold it accompanies does; BUT when that fold is empty (a 0-row result, or rows already
442
+ * in `top`) it notifies nothing, so the Store forces a {@link notify} on the strength of the `true`
443
+ * return here — else the view reads the live tree yet never re-renders (a frozen seed). Returns
444
+ * whether a live seed was actually cleared (so the Store knows a forced notify is owed). */
445
+ retireSeed(): boolean {
446
+ if (this.seeded === null) return false;
443
447
  this.seeded = null;
448
+ return true;
444
449
  }
445
450
 
446
451
  /** Apply a batch (the hydrate snapshot or one transaction's events) in order, then
@@ -737,7 +742,10 @@ export class FlatArrayView<R = unknown> implements ArrayView<R> {
737
742
  return obj;
738
743
  }
739
744
 
740
- private notify(): void {
745
+ /** Notify data subscribers with the current {@link data}. Normally driven by a fold ({@link
746
+ * applyChanges}/{@link flush}); the Store also calls it directly to land a seed retirement whose
747
+ * accompanying fold was empty (see {@link retireSeed}). */
748
+ notify(): void {
741
749
  const d = this.data;
742
750
  for (const l of this.listeners) l(d);
743
751
  }