@rindle/client 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/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/schema.ts CHANGED
@@ -63,16 +63,25 @@ export interface TableMeta<N extends string = string, C extends AnyCols = AnyCol
63
63
  /** A **local-only** table (`201-LOCAL-ONLY-TABLES-DESIGN.md`): client-authoritative,
64
64
  * never synced/tracked/rebased. The single marker the whole design keys off (§4) — it is
65
65
  * immutable for the table's lifetime (N2) and never crosses the wire (C2). Absent ⇒ an
66
- * ordinary synced table. */
67
- readonly local?: boolean;
66
+ * ordinary synced table.
67
+ *
68
+ * `true` ⇒ eligible for the local-persistence plane (durable + cross-tab when the client
69
+ * enables `persistLocal`, `207-LOCAL-TABLE-PERSISTENCE-DESIGN.md`). `"session"` ⇒ local but
70
+ * EPHEMERAL: outside the plane entirely — never persisted, never replicated across tabs,
71
+ * per-client-instance state that empties on reload (201's original behavior) even when
72
+ * `persistLocal` is on. Every OTHER locality rule (untracked source, M1/M2 guards, E3/Q1
73
+ * wire exclusion) treats both variants identically. */
74
+ readonly local?: boolean | "session";
68
75
  }
69
76
 
70
77
  /** Options for {@link table}. */
71
78
  export interface TableOptions {
72
79
  /** Declare a {@link TableMeta.local local-only} table (selection state, draft text, view
73
80
  * prefs, scratch rows): client-authoritative, never synced or rebased. See
74
- * `201-LOCAL-ONLY-TABLES-DESIGN.md`. */
75
- local?: boolean;
81
+ * `201-LOCAL-ONLY-TABLES-DESIGN.md`. Pass `"session"` for a local table that must stay
82
+ * EPHEMERAL and per-tab even when the client enables `persistLocal` — e.g. selection state
83
+ * that should not follow the user across tabs or reloads (207 §5.4). */
84
+ local?: boolean | "session";
76
85
  }
77
86
 
78
87
  export type TableDef<N extends string, C extends AnyCols, PK extends string = string> = {
@@ -235,7 +244,7 @@ export function extendSchema<S extends ColsMap, P extends Record<string, string>
235
244
  const tables: Record<string, TableMeta> = { ...base.tables };
236
245
  for (const t of opts.tables) {
237
246
  const m = t[SCHEMA];
238
- if (m.local !== true) {
247
+ if (!m.local) {
239
248
  throw new Error(
240
249
  `extendSchema: table "${m.name}" is not local-only — synced tables must be generated from the daemon schema.`,
241
250
  );
@@ -364,7 +373,9 @@ function assertRefinementMatches(m: TableMeta, baseMeta: TableMeta): void {
364
373
  cols.every((c) => baseMeta.columns[c] !== undefined && m.columns[c].type === baseMeta.columns[c].type) &&
365
374
  m.primaryKey.length === baseMeta.primaryKey.length &&
366
375
  m.primaryKey.every((k, i) => baseMeta.primaryKey[i] === k) &&
367
- (m.local === true) === (baseMeta.local === true);
376
+ // Locality must match EXACTLY, including the persisted-vs-session variant (a refinement
377
+ // flipping `true` ↔ `"session"` would silently change the table's durability).
378
+ (m.local ?? false) === (baseMeta.local ?? false);
368
379
  if (!matches) {
369
380
  throw new Error(
370
381
  `refineSchema: table "${m.name}" does not match the schema's table of that name — pass the ` +
@@ -375,16 +386,46 @@ function assertRefinementMatches(m: TableMeta, baseMeta: TableMeta): void {
375
386
  }
376
387
 
377
388
  /** Whether `table` is a {@link TableMeta.local local-only} table in `schema` (an unknown table
378
- * reads as non-local). The single locality predicate the backends key off. */
389
+ * reads as non-local). The single locality predicate the backends key off. BOTH variants —
390
+ * `true` and `"session"` — are local here; the persisted/ephemeral split matters only to the
391
+ * persistence plane ({@link persistedLocalTableNames}). */
379
392
  export function isLocalTable<S extends ColsMap>(schema: Schema<S>, table: string): boolean {
380
- return schema.tables[table]?.local === true;
393
+ return Boolean(schema.tables[table]?.local);
381
394
  }
382
395
 
383
- /** The set of local-only table names in `schema` (`201-LOCAL-ONLY-TABLES-DESIGN.md` §4). */
396
+ /** The set of local-only table names in `schema` (`201-LOCAL-ONLY-TABLES-DESIGN.md` §4) — BOTH
397
+ * variants (`true` and `"session"`); every locality rule except persistence keys off this set. */
384
398
  export function localTableNames<S extends ColsMap>(schema: Schema<S>): Set<string> {
385
399
  return new Set(Object.keys(schema.tables).filter((n) => schema.tables[n].local));
386
400
  }
387
401
 
402
+ /** The subset of {@link localTableNames} eligible for the persistence plane — `local: true` only.
403
+ * A `local: "session"` table stays outside it: never persisted, never replicated across tabs
404
+ * (`207-LOCAL-TABLE-PERSISTENCE-DESIGN.md` §5.4). */
405
+ export function persistedLocalTableNames<S extends ColsMap>(schema: Schema<S>): Set<string> {
406
+ return new Set(Object.keys(schema.tables).filter((n) => schema.tables[n].local === true));
407
+ }
408
+
409
+ /** A stable fingerprint of the schema's PERSISTED local tables only — the persistence gate's
410
+ * `schemaHash` (`207-LOCAL-TABLE-PERSISTENCE-DESIGN.md` §3.3 / P7). Per `local: true` table:
411
+ * `(name, ordered column names + types + optionality, pk columns)`. Column order is kept (rows are
412
+ * positional); tables are sorted by name so registration order can't skew it; synced-table AND
413
+ * `local: "session"` changes never move it (reshaping an ephemeral table must not wipe durable
414
+ * data). The value is the canonical descriptor itself, not a digest — local-table sets are tiny,
415
+ * and exactness (no collision can ever skip a P7 clear) beats compactness here. */
416
+ export function localSchemaHash<S extends ColsMap>(schema: Schema<S>): string {
417
+ // Derived from persistedLocalTableNames — the hash's contract is "fingerprints exactly the
418
+ // tables the plane persists", so the two must share one predicate, not two copies of it.
419
+ const tables = [...persistedLocalTableNames(schema)]
420
+ .sort()
421
+ .map((n) => {
422
+ const meta = schema.tables[n];
423
+ const cols = Object.keys(meta.columns).map((c) => [c, meta.columns[c].type, meta.columns[c].optional === true]);
424
+ return [n, cols, meta.primaryKey];
425
+ });
426
+ return `v1:${JSON.stringify(tables)}`;
427
+ }
428
+
388
429
  // ----------------------------- relationships (FRAGMENT-COMPOSITION-DESIGN §4.2, named edges) -----
389
430
  //
390
431
  // A relationship is the correlation (`parent.col → child.col`) declared ONCE as a value, so `sub`,
package/src/store.ts CHANGED
@@ -12,7 +12,7 @@ import { stableKey } from "./key.ts";
12
12
  import { queries, type Query, type QueryRoot } from "./query.ts";
13
13
  import type { ColsMap, InsertOf, RowOf, Schema } from "./schema.ts";
14
14
  import type { Backend, ChangeEvent, ColType, FlatChange, Mutation, QueryId, RemoteQuery, ResultType, WireSchema, WireValue } from "./types.ts";
15
- import { type ArrayView, FlatArrayView, type SingularArrayView, SingularView, type ViewTypes } from "./view.ts";
15
+ import { type ArrayView, type ChangePhase, FlatArrayView, type SingularArrayView, SingularView, type ViewChangeListener, type ViewTypes } from "./view.ts";
16
16
 
17
17
  /** One query's SSR snapshot, keyed by its `viewKey` ({@link stableKey} of the AST): the
18
18
  * pre-projected first-paint `rows` plus the `cvMin` watermark they reflect (SSR-DESIGN.md §6.2).
@@ -101,7 +101,9 @@ export class Store<S extends ColsMap> {
101
101
  private readonly syncLeases = new Map<QueryId, SyncLeaseState>();
102
102
  // SSR seeds (SSR-DESIGN.md §6), keyed by `viewKey`: a view materialized for one of these is
103
103
  // seeded for first paint; a seed is consumed (dropped) the moment its query's first live
104
- // `hello` lands, so later mounts don't flash stale SSR data.
104
+ // SNAPSHOT lands — NOT its `hello` so the seed bridges the `hello`→snapshot gap (the live
105
+ // data arrives on the snapshot, a round-trip after the hello) and later mounts of a now-live
106
+ // query don't re-seed stale SSR data.
105
107
  private readonly seeds = new Map<string, DehydratedQuery>();
106
108
  // Public per-query change subscribers ({@link subscribeChanges}). `undefined` until the first
107
109
  // subscription, so an app that never narrates (nor attaches devtools) pays one undefined-check per
@@ -116,6 +118,12 @@ export class Store<S extends ColsMap> {
116
118
  // Public per-query {@link ResultType} subscribers ({@link subscribeResultType}). `undefined` until
117
119
  // the first subscription. Devtools and any status-driven layer ride this instead of a private tap.
118
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;
119
127
  // Cross-view-atomic notification (the `Backend.onCommitBoundary` contract): while the backend is
120
128
  // delivering one commit's coherent multi-query batch, fold every affected view but DEFER each
121
129
  // view's subscriber notification, collecting the changed views here; at the commit's `end` flush
@@ -135,6 +143,7 @@ export class Store<S extends ColsMap> {
135
143
  constructor(schema: Schema<S>, backend: Backend) {
136
144
  this.schema = schema;
137
145
  this.backend = backend;
146
+ this.hasResultTypeLifecycle = typeof backend.onResultType === "function";
138
147
  this.backend.onEvent((qid, ev) => this.onEvent(qid, ev));
139
148
  // The in-process engine brackets each commit's multi-query delivery so every affected view
140
149
  // folds before any subscriber is notified (see `commitDepth`/`pendingFlush`). A backend with no
@@ -166,10 +175,59 @@ export class Store<S extends ColsMap> {
166
175
  }
167
176
 
168
177
  /** Materialize any fluent query object. Named queries subscribe remotely by `(name,args)`;
169
- * ad-hoc builder queries are local-only for local-first backends. */
170
- materialize<Q extends Query<any, any, any>>(query: Q): ReturnType<Q["materialize"]> {
178
+ * ad-hoc builder queries are local-only for local-first backends.
179
+ *
180
+ * `opts.onChanges` binds a narrator to this view's DIFF stream ({@link ArrayView.onChanges}) — the
181
+ * per-view seam that replaces filtering the store-global {@link subscribeChanges} by `qid`. It is
182
+ * wired BEFORE the backend registers the query, so a synchronous backend's first `snapshot` (fired
183
+ * inside `registerQuery`, before this returns) is delivered too. */
184
+ materialize<Q extends Query<any, any, any>>(
185
+ query: Q,
186
+ opts?: { onChanges?: ViewChangeListener },
187
+ ): ReturnType<Q["materialize"]> {
171
188
  const remote = typeof query.name === "string" ? { name: query.name, args: query.args } : undefined;
172
- return this.registerMaterialized(query.ast(), remote).view as ReturnType<Q["materialize"]>;
189
+ return this.registerMaterialized(query.ast(), remote, opts?.onChanges).view as ReturnType<Q["materialize"]>;
190
+ }
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
+ });
173
231
  }
174
232
 
175
233
  /** True when the backend can retain a remote named query independently from the local
@@ -362,6 +420,7 @@ export class Store<S extends ColsMap> {
362
420
  private registerMaterialized(
363
421
  ast: Ast,
364
422
  remote?: RemoteQuery,
423
+ onChanges?: ViewChangeListener,
365
424
  ): { qid: QueryId; view: ArrayView<unknown> | SingularArrayView<unknown> } {
366
425
  const qid: QueryId = this.nextId++;
367
426
  this.asts.set(qid, ast);
@@ -384,6 +443,22 @@ export class Store<S extends ColsMap> {
384
443
  }
385
444
  baseDestroy();
386
445
  };
446
+ // Bind the narrator BEFORE `registerQuery` fires (a synchronous backend dispatches this query's
447
+ // first `hello`+`snapshot` inside it) so the initial snapshot reaches the change listener too.
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");
387
462
  // If the backend rejects the registration (E3: a remote query naming a local-only table), roll
388
463
  // back the per-qid state we just created — otherwise the view + ast entry leak (the caller never
389
464
  // gets a handle to `destroy()` them, since the throw aborts before we return).
@@ -394,11 +469,6 @@ export class Store<S extends ColsMap> {
394
469
  this.asts.delete(qid);
395
470
  throw e;
396
471
  }
397
- // SSR first paint (SSR-DESIGN.md §6): if this AST was preloaded/hydrated, seed the view now.
398
- // A synchronous backend (wasm) already reset the view above, so its live data wins and the
399
- // seed is inert; an async backend (ws) leaves it PENDING, so the seed shows until `hello`.
400
- const seed = this.seeds.get(stableKey(ast));
401
- if (seed) view.seed(seed.rows);
402
472
  // A top-level `.one()` (engine-capped to limit 1) unwraps at the result boundary.
403
473
  return { qid, view: ast.one ? new SingularView(view) : view };
404
474
  }
@@ -419,18 +489,27 @@ export class Store<S extends ColsMap> {
419
489
  private onEvent(qid: QueryId, ev: ChangeEvent): void {
420
490
  if (ev.type === "hello") {
421
491
  const ast = this.asts.get(qid);
422
- // First live hello for this view ⇒ retire its SSR seed: the maintained tree now owns the
423
- // result, so no future mount of the same query should flash the stale first-paint rows.
424
- if (ast) this.seeds.delete(stableKey(ast));
425
492
  const types = ast ? this.viewTypes(ev.schema, ast) : undefined;
426
493
  // Reset the (pre-created or existing) view IN PLACE — first hello OR a re-hydrate (new
427
- // epoch) — so the materialized reference the caller holds survives a re-subscribe.
494
+ // epoch) — so the materialized reference the caller holds survives a re-subscribe. The SSR
495
+ // seed is KEPT across the reset and retired on the first `snapshot` below (the live data
496
+ // lands then, not on the hello), so a seeded query bridges the gap instead of flashing empty.
428
497
  const view = this.views.get(qid) ?? this.views.set(qid, new FlatArrayView(undefined, undefined, qid)).get(qid)!;
429
498
  view.reset(ev.schema, types);
430
499
  } else if (ev.type === "snapshot") {
431
- this.applyAndTrack(qid, ev.adds);
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");
432
509
  } else {
433
- this.applyAndTrack(qid, ev.events);
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");
434
513
  }
435
514
  // Fan the same post-fold frame out to subscribers (narration, devtools, …). Inside a commit
436
515
  // bracket the frames are BUFFERED and delivered together at the boundary (after every view has
@@ -448,14 +527,63 @@ export class Store<S extends ColsMap> {
448
527
  }
449
528
  }
450
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
+
451
579
  /** Fold a batch into its view, then notify now or — inside a commit bracket — defer the view's
452
580
  * notification to the commit boundary, so all sibling views fold first (cross-view-atomic
453
581
  * notification; see `commitDepth`). */
454
- private applyAndTrack(qid: QueryId, events: FlatChange[]): void {
582
+ private applyAndTrack(qid: QueryId, events: FlatChange[], phase: ChangePhase): void {
455
583
  const view = this.views.get(qid);
456
584
  if (!view) return;
457
585
  const deferring = this.commitDepth > 0;
458
- if (view.applyChanges(events, this.removedSubtreeWanted > 0, deferring) && deferring) {
586
+ if (view.applyChanges(events, this.removedSubtreeWanted > 0, deferring, phase) && deferring) {
459
587
  this.pendingFlush.add(view);
460
588
  }
461
589
  }
package/src/types.ts CHANGED
@@ -75,7 +75,12 @@ export interface FlatChange {
75
75
  export type ChangeEvent =
76
76
  | { type: "hello"; schema: WireSchema; comparatorVersion: number }
77
77
  | { type: "snapshot"; adds: FlatChange[]; last: boolean }
78
- | { type: "batch"; events: FlatChange[] };
78
+ // `catchUp` marks a batch that is really a query's INITIAL hydration delivered as a delta — the
79
+ // optimistic backend hydrates a fresh query through its reconcile cycle (a `serverBatchEnd`), so
80
+ // the whole first result set arrives as a `batch`, not a `snapshot`. The Store maps a catch-up
81
+ // batch to the `snapshot` change-phase so a narrator's "what CHANGED" default ignores the initial
82
+ // rows instead of narrating every one as a fresh add. A normal incremental batch omits it.
83
+ | { type: "batch"; events: FlatChange[]; catchUp?: boolean };
79
84
 
80
85
  export type Mutation =
81
86
  | { op: "add"; table: string; row: WireValue[] }
@@ -173,8 +178,13 @@ export interface Backend {
173
178
  /** Optional DIRECT-COMMIT path for LOCAL-only tables (`201-LOCAL-ONLY-TABLES-DESIGN.md` §6):
174
179
  * applies the writes straight to the engine, OUTSIDE the optimistic pending stack (a local
175
180
  * table is untracked, so it never rebases). The backend rejects any synced/tracked table (M2).
176
- * Backends with no local-table support (a plain remote sync backend) omit it. */
177
- writeLocal?(mutations: Mutation[]): void;
181
+ * Backends with no local-table support (a plain remote sync backend) omit it.
182
+ *
183
+ * `onCommitted` (207 §5.1) runs after the engine commit is applied but BEFORE subscriber
184
+ * delivery: a subscriber throwing during delivery re-raises out of this call, and a caller
185
+ * that must stay coherent with the engine (the persistence tap) cannot tell that throw from
186
+ * a pre-commit rejection — the callback can, because it fires exactly when the commit is in. */
187
+ writeLocal?(mutations: Mutation[], onCommitted?: () => void): void;
178
188
  onEvent(handler: (queryId: QueryId, event: ChangeEvent) => void): void;
179
189
  /** Optional: the backend pushes per-query {@link ResultType} changes here and the core routes
180
190
  * each to the matching view (so `view.resultType` tracks it). Backends with no lifecycle (the
@@ -226,6 +236,33 @@ export interface MutationEnvelope {
226
236
  args: unknown;
227
237
  }
228
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
+
229
266
  /** The connection-level progress frame (§8.6): advances the coherent-apply release point
230
267
  * (`cvMin`). A pure release signal — mutation confirmation does NOT ride it: `lmid` is a
231
268
  * row in {@link CLIENT_MUTATIONS_TABLE}, delivered through the client's own per-client
@@ -233,6 +270,13 @@ export interface MutationEnvelope {
233
270
  * `cvMin` as the commit's effects (transactionally coherent by construction). */
234
271
  export interface ProgressFrame {
235
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;
236
280
  }
237
281
 
238
282
  /** The replicated bookkeeping table carrying each client's high-water mutation id
@@ -273,4 +317,34 @@ export interface OptimisticSource {
273
317
  * boot id). The backend resets its `cv` watermark so the server's reset `cv` sequence is
274
318
  * accepted rather than dropped as stale. In-process sources never restart and omit it. */
275
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;
276
350
  }