@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/README.md +31 -161
- 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/schema.d.ts +30 -6
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +35 -5
- package/dist/schema.js.map +1 -1
- package/dist/store.d.ts +46 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +151 -20
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +71 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/view.d.ts +63 -8
- package/dist/view.d.ts.map +1 -1
- package/dist/view.js +328 -18
- 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/schema.ts +50 -9
- package/src/store.ts +146 -18
- package/src/types.ts +77 -3
- package/src/view.ts +325 -18
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/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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|