@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/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/query.d.ts +34 -3
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +31 -8
- package/dist/query.js.map +1 -1
- package/dist/store.d.ts +36 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +130 -19
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +63 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/view.d.ts +10 -4
- package/dist/view.d.ts.map +1 -1
- package/dist/view.js +11 -2
- package/dist/view.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/query.ts +91 -10
- package/src/store.ts +122 -17
- package/src/types.ts +64 -0
- package/src/view.ts +12 -4
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>(
|
|
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>(
|
|
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
|
-
|
|
491
|
+
maybeBuildOrOptions?: ((...a: any[]) => any) | DefineQueryOptions<any>,
|
|
492
|
+
maybeOptions?: DefineQueryOptions<any>,
|
|
423
493
|
): NamedQuery<any, any, AnyQuery> {
|
|
424
|
-
const hasValidator =
|
|
494
|
+
const hasValidator = typeof maybeBuildOrOptions === "function";
|
|
425
495
|
const validate = (hasValidator ? validateOrBuild : (raw: unknown) => raw) as (raw: unknown) => unknown;
|
|
426
|
-
const build = (hasValidator ?
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
445
|
-
//
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
|
454
|
-
//
|
|
455
|
-
|
|
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
|
|
441
|
-
*
|
|
442
|
-
|
|
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
|
-
|
|
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
|
}
|