@rindle/client 0.1.1 → 0.1.3
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 +52 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/query.d.ts +17 -2
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +64 -9
- package/dist/query.js.map +1 -1
- package/dist/schema.d.ts +45 -3
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +64 -5
- package/dist/schema.js.map +1 -1
- package/dist/ssr.d.ts +2 -1
- package/dist/ssr.d.ts.map +1 -1
- package/dist/ssr.js +8 -0
- package/dist/ssr.js.map +1 -1
- package/dist/store.d.ts +55 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +95 -5
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/query.ts +74 -7
- package/src/schema.ts +90 -5
- package/src/ssr.ts +8 -1
- package/src/store.ts +132 -6
- package/src/types.ts +5 -0
package/src/schema.ts
CHANGED
|
@@ -34,6 +34,19 @@ export interface TableMeta<N extends string = string, C extends AnyCols = AnyCol
|
|
|
34
34
|
// `TableMeta` non-covariant in `C` and break `TableDef<concrete>` → `AnyTable`. Key
|
|
35
35
|
// validity is enforced on the `primaryKey()` builder method (`K extends keyof C`).
|
|
36
36
|
readonly primaryKey: readonly string[];
|
|
37
|
+
/** A **local-only** table (`201-LOCAL-ONLY-TABLES-DESIGN.md`): client-authoritative,
|
|
38
|
+
* never synced/tracked/rebased. The single marker the whole design keys off (§4) — it is
|
|
39
|
+
* immutable for the table's lifetime (N2) and never crosses the wire (C2). Absent ⇒ an
|
|
40
|
+
* ordinary synced table. */
|
|
41
|
+
readonly local?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Options for {@link table}. */
|
|
45
|
+
export interface TableOptions {
|
|
46
|
+
/** Declare a {@link TableMeta.local local-only} table (selection state, draft text, view
|
|
47
|
+
* prefs, scratch rows): client-authoritative, never synced or rebased. See
|
|
48
|
+
* `201-LOCAL-ONLY-TABLES-DESIGN.md`. */
|
|
49
|
+
local?: boolean;
|
|
37
50
|
}
|
|
38
51
|
|
|
39
52
|
export type TableDef<N extends string, C extends AnyCols> = {
|
|
@@ -53,13 +66,14 @@ export type AnyTable = TableLike<AnyCols>;
|
|
|
53
66
|
* the schema instead of hand-maintaining a parallel twin. */
|
|
54
67
|
export type Row<T extends AnyTable> = RowOf<T[typeof SCHEMA]["columns"]>;
|
|
55
68
|
|
|
56
|
-
/** `table("issue").columns({ id: string(), … }).primaryKey("id")`.
|
|
57
|
-
|
|
69
|
+
/** `table("issue").columns({ id: string(), … }).primaryKey("id")`. Pass `{ local: true }` for a
|
|
70
|
+
* {@link TableMeta.local local-only} table (`201-LOCAL-ONLY-TABLES-DESIGN.md`). */
|
|
71
|
+
export function table<N extends string>(name: N, opts?: TableOptions) {
|
|
58
72
|
return {
|
|
59
73
|
columns<C extends AnyCols>(cols: C) {
|
|
60
74
|
return {
|
|
61
75
|
primaryKey<K extends keyof C & string>(...keys: K[]): TableDef<N, C> {
|
|
62
|
-
return makeTableDef<N, C>({ name, columns: cols, primaryKey: keys });
|
|
76
|
+
return makeTableDef<N, C>({ name, columns: cols, primaryKey: keys, local: opts?.local });
|
|
63
77
|
},
|
|
64
78
|
};
|
|
65
79
|
},
|
|
@@ -95,17 +109,83 @@ export interface Schema<S extends ColsMap = ColsMap> {
|
|
|
95
109
|
readonly __cols: S;
|
|
96
110
|
}
|
|
97
111
|
|
|
112
|
+
/** Table-name prefixes reserved by the engine for SYNTHETIC tables — `__agg_<fnv>` aggregate
|
|
113
|
+
* bases (`AGGREGATE-SYNC-DESIGN.md` §3.3) and `_rindle_*` system tables (e.g. the lmid table).
|
|
114
|
+
* A user table (synced OR local) under one of these would shadow a synthetic source — and since
|
|
115
|
+
* `registerTable` is idempotent-on-name and base tables register before synthetics, the later
|
|
116
|
+
* synthetic registration would silently no-op and the agg join would read the user table as the
|
|
117
|
+
* count (silent corruption, `201-LOCAL-ONLY-TABLES-DESIGN.md` N1). The ban makes that
|
|
118
|
+
* structurally impossible — checked once, statically, at {@link createSchema}. */
|
|
119
|
+
export const RESERVED_TABLE_PREFIXES = ["__agg_", "_rindle_"] as const;
|
|
120
|
+
|
|
121
|
+
/** Whether `name` collides with an engine-reserved synthetic prefix ({@link RESERVED_TABLE_PREFIXES}). */
|
|
122
|
+
export function isReservedTableName(name: string): boolean {
|
|
123
|
+
return RESERVED_TABLE_PREFIXES.some((p) => name.startsWith(p));
|
|
124
|
+
}
|
|
125
|
+
|
|
98
126
|
export function createSchema<const T extends readonly AnyTable[]>(opts: {
|
|
99
127
|
tables: T;
|
|
100
128
|
}): Schema<SchemaOf<T>> {
|
|
101
129
|
const tables: Record<string, TableMeta> = {};
|
|
102
130
|
for (const t of opts.tables) {
|
|
103
131
|
const m = t[SCHEMA];
|
|
104
|
-
tables
|
|
132
|
+
addTableMeta(tables, m, "createSchema");
|
|
105
133
|
}
|
|
106
134
|
return { tables } as Schema<SchemaOf<T>>;
|
|
107
135
|
}
|
|
108
136
|
|
|
137
|
+
/** Extend a generated/synced schema with client-authoritative local-only tables.
|
|
138
|
+
*
|
|
139
|
+
* This is the ergonomic path for SQL-first apps: keep `schema.gen.ts` fully generated, define
|
|
140
|
+
* private UI tables in a tiny hand-written file, then hand the combined schema to the browser
|
|
141
|
+
* client. The added tables MUST be `table(name, { local: true })`: `extendSchema` deliberately
|
|
142
|
+
* refuses to append ordinary synced tables, because those need to come from daemon introspection
|
|
143
|
+
* (`rindle schema gen`) so the server and client cannot drift. */
|
|
144
|
+
export function extendSchema<S extends ColsMap, const T extends readonly AnyTable[]>(
|
|
145
|
+
base: Schema<S>,
|
|
146
|
+
opts: { tables: T },
|
|
147
|
+
): Schema<S & SchemaOf<T>> {
|
|
148
|
+
const tables: Record<string, TableMeta> = { ...base.tables };
|
|
149
|
+
for (const t of opts.tables) {
|
|
150
|
+
const m = t[SCHEMA];
|
|
151
|
+
if (m.local !== true) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`extendSchema: table "${m.name}" is not local-only — synced tables must be generated from the daemon schema.`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
addTableMeta(tables, m, "extendSchema");
|
|
157
|
+
}
|
|
158
|
+
return { tables } as Schema<S & SchemaOf<T>>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function addTableMeta(tables: Record<string, TableMeta>, m: TableMeta, caller: "createSchema" | "extendSchema") {
|
|
162
|
+
// N1: ban reserved synthetic prefixes for ANY user table (synced or local). One source per
|
|
163
|
+
// name, statically disjoint from the dynamic `__agg_*`/`_rindle_*` namespace.
|
|
164
|
+
if (isReservedTableName(m.name)) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`${caller}: table "${m.name}" uses a reserved prefix (${RESERVED_TABLE_PREFIXES.join(", ")}) — ` +
|
|
167
|
+
`these namespaces are owned by the engine's synthetic tables (201-LOCAL-ONLY-TABLES-DESIGN.md N1).`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
// N1/N2: one name → one source. A duplicate (incl. a local/synced name clash, since both
|
|
171
|
+
// register from this one array) is a genuine collision, not a silent overwrite.
|
|
172
|
+
if (Object.prototype.hasOwnProperty.call(tables, m.name)) {
|
|
173
|
+
throw new Error(`${caller}: duplicate table "${m.name}" — table names must be unique.`);
|
|
174
|
+
}
|
|
175
|
+
tables[m.name] = m;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Whether `table` is a {@link TableMeta.local local-only} table in `schema` (an unknown table
|
|
179
|
+
* reads as non-local). The single locality predicate the backends key off. */
|
|
180
|
+
export function isLocalTable<S extends ColsMap>(schema: Schema<S>, table: string): boolean {
|
|
181
|
+
return schema.tables[table]?.local === true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The set of local-only table names in `schema` (`201-LOCAL-ONLY-TABLES-DESIGN.md` §4). */
|
|
185
|
+
export function localTableNames<S extends ColsMap>(schema: Schema<S>): Set<string> {
|
|
186
|
+
return new Set(Object.keys(schema.tables).filter((n) => schema.tables[n].local));
|
|
187
|
+
}
|
|
188
|
+
|
|
109
189
|
// ----------------------------- relationships (FRAGMENT-COMPOSITION-DESIGN §4.2, named edges) -----
|
|
110
190
|
//
|
|
111
191
|
// A relationship is the correlation (`parent.col → child.col`) declared ONCE as a value, so `sub`,
|
|
@@ -171,11 +251,16 @@ export function tableSpec(meta: TableMeta): { columns: string[]; primaryKey: num
|
|
|
171
251
|
* normalized `hello` advertises (NORMALIZED-CHANGES-DESIGN.md §3). Used to validate a
|
|
172
252
|
* server hello against the CLIENT's own typed schema so a column-order / PK skew is caught
|
|
173
253
|
* instead of silently transposing positional cells (CRIT#4). Sorted by name for stable
|
|
174
|
-
* ordering.
|
|
254
|
+
* ordering.
|
|
255
|
+
*
|
|
256
|
+
* Local-only tables are **omitted** (`201-LOCAL-ONLY-TABLES-DESIGN.md` E1): the client never
|
|
257
|
+
* claims them to the server, the server never expects/ships them, and they stay out of the schema
|
|
258
|
+
* fingerprint (`normalizedFp`) so a local table can't skew it vs. the server's. */
|
|
175
259
|
export function normalizedTableSchemas<S extends ColsMap>(
|
|
176
260
|
schema: Schema<S>,
|
|
177
261
|
): { name: string; columns: string[]; primaryKey: number[] }[] {
|
|
178
262
|
return Object.keys(schema.tables)
|
|
263
|
+
.filter((name) => !schema.tables[name].local)
|
|
179
264
|
.sort()
|
|
180
265
|
.map((name) => ({ name, ...tableSpec(schema.tables[name]) }));
|
|
181
266
|
}
|
package/src/ssr.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// `useQuery` is byte-for-byte identical in both — only the injected backend (and whether a live
|
|
11
11
|
// subscribe ever happens) differs.
|
|
12
12
|
|
|
13
|
-
import type
|
|
13
|
+
import { assertNoLocalTables, type Query } from "./query.ts";
|
|
14
14
|
import type { ColsMap, Schema } from "./schema.ts";
|
|
15
15
|
import { type AssembledNode, type DehydratedState, Store } from "./store.ts";
|
|
16
16
|
import type { Backend, ChangeEvent, Mutation, QueryId, RemoteQuery } from "./types.ts";
|
|
@@ -74,10 +74,12 @@ export interface ServerStoreOptions {
|
|
|
74
74
|
*/
|
|
75
75
|
export class ServerStore<S extends ColsMap> {
|
|
76
76
|
readonly store: Store<S>;
|
|
77
|
+
private readonly schema: Schema<S>;
|
|
77
78
|
private readonly opts: ServerStoreOptions;
|
|
78
79
|
|
|
79
80
|
constructor(schema: Schema<S>, opts: ServerStoreOptions) {
|
|
80
81
|
this.store = new Store(schema, new OneShotBackend());
|
|
82
|
+
this.schema = schema;
|
|
81
83
|
this.opts = opts;
|
|
82
84
|
}
|
|
83
85
|
|
|
@@ -85,6 +87,11 @@ export class ServerStore<S extends ColsMap> {
|
|
|
85
87
|
* Call once per query in the route loader, before the synchronous render. */
|
|
86
88
|
async preload(query: Query<any, any, any>): Promise<void> {
|
|
87
89
|
const ast = query.ast();
|
|
90
|
+
// E3 backstop: a local-only table must never be forwarded to the daemon. The `OneShotBackend`'s
|
|
91
|
+
// `registerQuery` is a no-op (no engine-side E3 check runs on the SSR path), and a query built
|
|
92
|
+
// from the LOCAL builder permits local tables — so the AST is re-checked here regardless of how
|
|
93
|
+
// it was built (201-LOCAL-ONLY-TABLES-DESIGN.md E3).
|
|
94
|
+
assertNoLocalTables(ast, this.schema);
|
|
88
95
|
// Forward the AST (a direct-to-daemon backend reads it) plus the named identity when this came
|
|
89
96
|
// from `defineQuery` (an API-tier backend resolves `(name, args)` → AST itself, never trusting
|
|
90
97
|
// a client AST). The seed is keyed by the AST's `viewKey` either way, so the browser's
|
package/src/store.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { Ast } from "./ast.ts";
|
|
|
11
11
|
import { stableKey } from "./key.ts";
|
|
12
12
|
import { queries, type Query, type QueryRoot } from "./query.ts";
|
|
13
13
|
import type { ColsMap, RowOf, Schema } from "./schema.ts";
|
|
14
|
-
import type { Backend, ChangeEvent, ColType, Mutation, QueryId, RemoteQuery, WireSchema, WireValue } from "./types.ts";
|
|
14
|
+
import type { Backend, ChangeEvent, ColType, Mutation, QueryId, RemoteQuery, ResultType, WireSchema, WireValue } from "./types.ts";
|
|
15
15
|
import { type ArrayView, FlatArrayView, type SingularArrayView, SingularView, type ViewTypes } from "./view.ts";
|
|
16
16
|
|
|
17
17
|
/** One query's SSR snapshot, keyed by its `viewKey` ({@link stableKey} of the AST): the
|
|
@@ -51,6 +51,39 @@ export interface CachedQueryView<Q extends Query<any, any, any>> {
|
|
|
51
51
|
destroy(): void;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** One live materialized view's read-only summary for a devtools pane (DEBUG-TOOLS-BROWSER-DESIGN
|
|
55
|
+
* §4.2 — "surface, not instrument"). All fields are already held by the {@link Store}; this is the
|
|
56
|
+
* single read-only accessor over the otherwise-private `views`/`asts` maps. */
|
|
57
|
+
export interface QueryInspect {
|
|
58
|
+
/** The Store-assigned query id (also the backend's qid — the Store passes it straight through). */
|
|
59
|
+
qid: QueryId;
|
|
60
|
+
/** The query's AST (`Store.asts`), for the inspector's pretty-print / table-derivation. */
|
|
61
|
+
ast: Ast;
|
|
62
|
+
/** The view's SERVER-CHANNEL state (`unknown` while loading, `complete` once authoritative). */
|
|
63
|
+
resultType: ResultType;
|
|
64
|
+
/** Current materialized row count (`view.data.length`). */
|
|
65
|
+
rowCount: number;
|
|
66
|
+
/** A capped peek at the projected rows (reference-stable objects off the live view). */
|
|
67
|
+
sample: readonly unknown[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A frozen snapshot of the Store's live query state for a devtools pane ({@link Store.__inspect}). */
|
|
71
|
+
export interface StoreInspect {
|
|
72
|
+
queries: QueryInspect[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A dev-only passive tap over the Store's per-query streams (DEBUG-TOOLS-BROWSER-DESIGN §6.2).
|
|
76
|
+
* Registered via {@link Store.__attachDevtools}; it ADDS a listener and never displaces the
|
|
77
|
+
* single `backend.onEvent`/`onResultType` handlers the Store already owns, so attaching devtools
|
|
78
|
+
* cannot perturb the app. Both hooks are optional. */
|
|
79
|
+
export interface StoreDevObserver {
|
|
80
|
+
/** The raw per-query {@link ChangeEvent} (hello / snapshot / batch) the Store just routed to its
|
|
81
|
+
* view — the source of the devtools delta stream (§4.3). Fired AFTER the view has folded it. */
|
|
82
|
+
onDelta?(qid: QueryId, ev: ChangeEvent): void;
|
|
83
|
+
/** A per-query {@link ResultType} transition the Store just applied to its view (§4.2). */
|
|
84
|
+
onResultType?(qid: QueryId, rt: ResultType): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
54
87
|
export class Store<S extends ColsMap> {
|
|
55
88
|
/** Type-safe query entry: `store.query.issue.where.closed(false).materialize()`. */
|
|
56
89
|
readonly query: QueryRoot<S>;
|
|
@@ -64,15 +97,26 @@ export class Store<S extends ColsMap> {
|
|
|
64
97
|
// seeded for first paint; a seed is consumed (dropped) the moment its query's first live
|
|
65
98
|
// `hello` lands, so later mounts don't flash stale SSR data.
|
|
66
99
|
private readonly seeds = new Map<string, DehydratedQuery>();
|
|
100
|
+
// Dev-only devtools taps (DEBUG-TOOLS-BROWSER-DESIGN §6.2). `undefined` until the first
|
|
101
|
+
// `__attachDevtools`, so a production app that never attaches a pane pays one undefined-check per
|
|
102
|
+
// routed event and nothing else — there is no global singleton and no hot-path instrumentation.
|
|
103
|
+
private devObservers?: Set<StoreDevObserver>;
|
|
67
104
|
|
|
68
105
|
constructor(schema: Schema<S>, backend: Backend) {
|
|
69
106
|
this.schema = schema;
|
|
70
107
|
this.backend = backend;
|
|
71
108
|
this.backend.onEvent((qid, ev) => this.onEvent(qid, ev));
|
|
72
109
|
// Route the backend's per-query lifecycle onto its view (`view.resultType`). Backends without a
|
|
73
|
-
// lifecycle omit `onResultType`, leaving every view `complete`.
|
|
74
|
-
|
|
75
|
-
this.
|
|
110
|
+
// lifecycle omit `onResultType`, leaving every view `complete`. A devtools tap mirrors the
|
|
111
|
+
// transition (it never displaces this single handler).
|
|
112
|
+
this.backend.onResultType?.((qid, rt) => {
|
|
113
|
+
this.views.get(qid)?.setResultType(rt);
|
|
114
|
+
if (this.devObservers) for (const o of this.devObservers) o.onResultType?.(qid, rt);
|
|
115
|
+
});
|
|
116
|
+
// `store.query` is the LOCAL builder (201-LOCAL-ONLY-TABLES-DESIGN.md §5): it scopes over
|
|
117
|
+
// synced AND local-only tables, so a local query can join the two. Server/named queries use
|
|
118
|
+
// the synced-only `newQueryBuilder`, which excludes local tables.
|
|
119
|
+
this.query = queries(this.schema, (query) => this.materialize(query), { includeLocal: true }) as QueryRoot<S>;
|
|
76
120
|
}
|
|
77
121
|
|
|
78
122
|
/** Materialize any fluent query object. Named queries subscribe remotely by `(name,args)`;
|
|
@@ -113,6 +157,42 @@ export class Store<S extends ColsMap> {
|
|
|
113
157
|
* accepted them (local: applied; remote: sent). The resulting view updates flow back via
|
|
114
158
|
* the backend's event stream. */
|
|
115
159
|
write(fn: (tx: WriteTx<S>) => void): Promise<void> {
|
|
160
|
+
return Promise.resolve(this.backend.mutate(this.collectMutations(fn)));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Direct-commit write to LOCAL-only tables (`201-LOCAL-ONLY-TABLES-DESIGN.md` §6): the
|
|
164
|
+
* client-authoritative path for selection state, draft text, view prefs, scratch rows. It
|
|
165
|
+
* bypasses the optimistic pending stack entirely — a local table is untracked, so it never
|
|
166
|
+
* rebases, reverts, or waits on a server confirmation (it "moves on its own").
|
|
167
|
+
*
|
|
168
|
+
* Rejects a synced/tracked table (M2): a direct write to one would be un-applied on the very
|
|
169
|
+
* next server rewind. Local writes also must NOT live inside a replayable mutator (M1) — the
|
|
170
|
+
* server runs the mutator from `args` alone and cannot see local tables; use this instead.
|
|
171
|
+
* Same keyed `WriteTx` shape as {@link write}. `async` so the no-seam / M2 guards surface as a
|
|
172
|
+
* REJECTED promise (the `Promise<void>` contract) rather than a synchronous throw that escapes a
|
|
173
|
+
* caller's `.catch` and crashes the event handler / render frame. */
|
|
174
|
+
async writeLocal(fn: (tx: WriteTx<S>) => void): Promise<void> {
|
|
175
|
+
if (!this.backend.writeLocal) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
"store.writeLocal: this backend has no local-write seam (local-only tables need the wasm/optimistic backend).",
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
const muts = this.collectMutations(fn);
|
|
181
|
+
// Defense in depth (M2): a clear Store-level rejection of any non-local table, before the
|
|
182
|
+
// backend's own chokepoint guard. Local writes are authoritative-for-themselves only.
|
|
183
|
+
for (const m of muts) {
|
|
184
|
+
if (!this.schema.tables[m.table]?.local) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`store.writeLocal: "${m.table}" is not a local-only table — use store.write or a named mutator (M2).`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
this.backend.writeLocal(muts);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Drain a keyed `WriteTx` callback into positional {@link Mutation}s (shared by
|
|
194
|
+
* {@link write} / {@link writeLocal}; json columns stringified, rows in column order). */
|
|
195
|
+
private collectMutations(fn: (tx: WriteTx<S>) => void): Mutation[] {
|
|
116
196
|
const muts: Mutation[] = [];
|
|
117
197
|
const tx = {
|
|
118
198
|
add: (t: string, row: Record<string, unknown>) =>
|
|
@@ -123,7 +203,7 @@ export class Store<S extends ColsMap> {
|
|
|
123
203
|
muts.push({ op: "edit", table: t, old: this.positionalize(t, o), new: this.positionalize(t, n) }),
|
|
124
204
|
} as unknown as WriteTx<S>;
|
|
125
205
|
fn(tx);
|
|
126
|
-
return
|
|
206
|
+
return muts;
|
|
127
207
|
}
|
|
128
208
|
|
|
129
209
|
// --- SSR (SSR-DESIGN.md §6) ---------------------------------------------------
|
|
@@ -207,7 +287,16 @@ export class Store<S extends ColsMap> {
|
|
|
207
287
|
}
|
|
208
288
|
baseDestroy();
|
|
209
289
|
};
|
|
210
|
-
|
|
290
|
+
// If the backend rejects the registration (E3: a remote query naming a local-only table), roll
|
|
291
|
+
// back the per-qid state we just created — otherwise the view + ast entry leak (the caller never
|
|
292
|
+
// gets a handle to `destroy()` them, since the throw aborts before we return).
|
|
293
|
+
try {
|
|
294
|
+
this.backend.registerQuery(qid, ast, remote);
|
|
295
|
+
} catch (e) {
|
|
296
|
+
this.views.delete(qid);
|
|
297
|
+
this.asts.delete(qid);
|
|
298
|
+
throw e;
|
|
299
|
+
}
|
|
211
300
|
// SSR first paint (SSR-DESIGN.md §6): if this AST was preloaded/hydrated, seed the view now.
|
|
212
301
|
// A synchronous backend (wasm) already reset the view above, so its live data wins and the
|
|
213
302
|
// seed is inert; an async backend (ws) leaves it PENDING, so the seed shows until `hello`.
|
|
@@ -246,6 +335,43 @@ export class Store<S extends ColsMap> {
|
|
|
246
335
|
} else {
|
|
247
336
|
this.views.get(qid)?.applyChanges(ev.events);
|
|
248
337
|
}
|
|
338
|
+
// Mirror the raw delta to any devtools tap, AFTER the view has folded it (so a pane re-reading
|
|
339
|
+
// `__inspect` sees the post-apply state). No-op (one undefined-check) when no pane is attached.
|
|
340
|
+
if (this.devObservers) for (const o of this.devObservers) o.onDelta?.(qid, ev);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// --- dev-only introspection (DEBUG-TOOLS-BROWSER-DESIGN §2/§6.2) --------------
|
|
344
|
+
// A single read-only accessor + a passive tap per the design's "surface, not instrument" rule.
|
|
345
|
+
// Only ever called by `@rindle/devtools` (imported in dev); inert otherwise.
|
|
346
|
+
|
|
347
|
+
/** Register a devtools {@link StoreDevObserver}; returns a detach function. Multiple panes may
|
|
348
|
+
* attach. The observer is ADDITIVE — it does not touch the backend's single handler slots. */
|
|
349
|
+
__attachDevtools(observer: StoreDevObserver): () => void {
|
|
350
|
+
(this.devObservers ??= new Set()).add(observer);
|
|
351
|
+
return () => {
|
|
352
|
+
this.devObservers?.delete(observer);
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** A read-only snapshot of every live materialized view (DEBUG-TOOLS-BROWSER-DESIGN §4.2): its
|
|
357
|
+
* qid, AST, {@link ResultType}, row count, and a capped row sample. Built fresh on each call from
|
|
358
|
+
* the live `views`/`asts` maps — never cached, never mutating. `sampleRows` caps the per-query
|
|
359
|
+
* peek (default 50) so a large view doesn't bloat the snapshot. */
|
|
360
|
+
__inspect(sampleRows = 50): StoreInspect {
|
|
361
|
+
const queries: QueryInspect[] = [];
|
|
362
|
+
for (const [qid, view] of this.views) {
|
|
363
|
+
const ast = this.asts.get(qid);
|
|
364
|
+
if (!ast) continue; // a view mid-teardown (ast already dropped) — skip
|
|
365
|
+
const data = view.data;
|
|
366
|
+
queries.push({
|
|
367
|
+
qid,
|
|
368
|
+
ast,
|
|
369
|
+
resultType: view.resultType,
|
|
370
|
+
rowCount: data.length,
|
|
371
|
+
sample: sampleRows >= data.length ? data : data.slice(0, sampleRows),
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
return { queries };
|
|
249
375
|
}
|
|
250
376
|
|
|
251
377
|
private columns(table: string): Record<string, { type: ColType }> {
|
package/src/types.ts
CHANGED
|
@@ -148,6 +148,11 @@ export interface Backend {
|
|
|
148
148
|
releaseRemoteQuery?(queryId: QueryId): void;
|
|
149
149
|
/** Local: applies now (changes flow back on the stream). Remote: sends to the server. */
|
|
150
150
|
mutate(mutations: Mutation[]): Promise<void>;
|
|
151
|
+
/** Optional DIRECT-COMMIT path for LOCAL-only tables (`201-LOCAL-ONLY-TABLES-DESIGN.md` §6):
|
|
152
|
+
* applies the writes straight to the engine, OUTSIDE the optimistic pending stack (a local
|
|
153
|
+
* table is untracked, so it never rebases). The backend rejects any synced/tracked table (M2).
|
|
154
|
+
* Backends with no local-table support (a plain remote sync backend) omit it. */
|
|
155
|
+
writeLocal?(mutations: Mutation[]): void;
|
|
151
156
|
onEvent(handler: (queryId: QueryId, event: ChangeEvent) => void): void;
|
|
152
157
|
/** Optional: the backend pushes per-query {@link ResultType} changes here and the core routes
|
|
153
158
|
* each to the matching view (so `view.resultType` tracks it). Backends with no lifecycle (the
|