@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/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
- export function table<N extends string>(name: N) {
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[m.name] = m;
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 { Query } from "./query.ts";
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
- this.backend.onResultType?.((qid, rt) => this.views.get(qid)?.setResultType(rt));
75
- this.query = queries(this.schema, (query) => this.materialize(query)) as QueryRoot<S>;
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 Promise.resolve(this.backend.mutate(muts));
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
- this.backend.registerQuery(qid, ast, remote);
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