@rindle/client 0.1.0-rc.5

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.
Files changed (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +65 -0
  3. package/dist/ast.d.ts +83 -0
  4. package/dist/ast.d.ts.map +1 -0
  5. package/dist/ast.js +5 -0
  6. package/dist/ast.js.map +1 -0
  7. package/dist/compare.d.ts +17 -0
  8. package/dist/compare.d.ts.map +1 -0
  9. package/dist/compare.js +81 -0
  10. package/dist/compare.js.map +1 -0
  11. package/dist/index.d.ts +12 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +13 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/key.d.ts +2 -0
  16. package/dist/key.d.ts.map +1 -0
  17. package/dist/key.js +26 -0
  18. package/dist/key.js.map +1 -0
  19. package/dist/operators.d.ts +39 -0
  20. package/dist/operators.d.ts.map +1 -0
  21. package/dist/operators.js +45 -0
  22. package/dist/operators.js.map +1 -0
  23. package/dist/query.d.ts +280 -0
  24. package/dist/query.d.ts.map +1 -0
  25. package/dist/query.js +348 -0
  26. package/dist/query.js.map +1 -0
  27. package/dist/schema.d.ts +111 -0
  28. package/dist/schema.d.ts.map +1 -0
  29. package/dist/schema.js +92 -0
  30. package/dist/schema.js.map +1 -0
  31. package/dist/ssr.d.ts +73 -0
  32. package/dist/ssr.d.ts.map +1 -0
  33. package/dist/ssr.js +66 -0
  34. package/dist/ssr.js.map +1 -0
  35. package/dist/store.d.ts +90 -0
  36. package/dist/store.d.ts.map +1 -0
  37. package/dist/store.js +225 -0
  38. package/dist/store.js.map +1 -0
  39. package/dist/types.d.ts +250 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +20 -0
  42. package/dist/types.js.map +1 -0
  43. package/dist/view.d.ts +88 -0
  44. package/dist/view.d.ts.map +1 -0
  45. package/dist/view.js +294 -0
  46. package/dist/view.js.map +1 -0
  47. package/package.json +36 -0
  48. package/src/ast.ts +94 -0
  49. package/src/compare.ts +85 -0
  50. package/src/index.ts +57 -0
  51. package/src/key.ts +23 -0
  52. package/src/operators.ts +68 -0
  53. package/src/query.ts +734 -0
  54. package/src/schema.ts +181 -0
  55. package/src/ssr.ts +115 -0
  56. package/src/store.ts +279 -0
  57. package/src/types.ts +234 -0
  58. package/src/view.ts +348 -0
package/src/schema.ts ADDED
@@ -0,0 +1,181 @@
1
+ // The typed schema: `table("issue").columns({...}).primaryKey("id")` + `createSchema`.
2
+ //
3
+ // A table definition doubles as a field→condition factory (a runtime Proxy):
4
+ // `issue.priority(gt(8))` produces a `Cond<RowOf<issue>>`. THAT is where the table type is
5
+ // bound — which is why `or`/`exists` are plain top-level functions, no closure needed
6
+ // (WASM-CLIENT-DESIGN.md §6). Schema metadata is stored under a `unique symbol` key so it
7
+ // never collides with a column name.
8
+
9
+ import type { Arg, Cond } from "./operators.ts";
10
+ import { fieldCondition } from "./operators.ts";
11
+ import type { ColType } from "./types.ts";
12
+
13
+ /** A column descriptor. `type` drives the comparator + JSON parsing; `__t` is a phantom. */
14
+ export interface Col<T> {
15
+ readonly type: ColType;
16
+ readonly __t?: T;
17
+ }
18
+ export type ColT<X> = X extends Col<infer T> ? T : never;
19
+ export type AnyCols = Record<string, Col<unknown>>;
20
+ export type RowOf<C extends AnyCols> = { [K in keyof C]: ColT<C[K]> };
21
+
22
+ export const string = (): Col<string> => ({ type: "string" });
23
+ export const number = (): Col<number> => ({ type: "number" });
24
+ export const boolean = (): Col<boolean> => ({ type: "boolean" });
25
+ export const json = <T = unknown>(): Col<T> => ({ type: "json" });
26
+
27
+ /** Metadata key on a {@link TableDef} (a `unique symbol`, so no column name collides). */
28
+ export const SCHEMA: unique symbol = Symbol("rindle.schema");
29
+
30
+ export interface TableMeta<N extends string = string, C extends AnyCols = AnyCols> {
31
+ readonly name: N;
32
+ readonly columns: C;
33
+ // Plain `string[]` (NOT `keyof C`): `keyof C` is contravariant, which would make
34
+ // `TableMeta` non-covariant in `C` and break `TableDef<concrete>` → `AnyTable`. Key
35
+ // validity is enforced on the `primaryKey()` builder method (`K extends keyof C`).
36
+ readonly primaryKey: readonly string[];
37
+ }
38
+
39
+ export type TableDef<N extends string, C extends AnyCols> = {
40
+ readonly [SCHEMA]: TableMeta<N, C>;
41
+ } & {
42
+ readonly [K in keyof C]: (arg: Arg<ColT<C[K]>>) => Cond<RowOf<C>>;
43
+ };
44
+
45
+ /** Any table, for positions that only read its metadata. The field-factory part of a
46
+ * `TableDef` is invariant in `C`, so callers constrain on the `[SCHEMA]` meta only — to
47
+ * which every concrete `TableDef<N, C>` is assignable. */
48
+ export type TableLike<C extends AnyCols> = { readonly [SCHEMA]: TableMeta<string, C> };
49
+ export type AnyTable = TableLike<AnyCols>;
50
+
51
+ /** The row type of a table definition: `Row<typeof issue>` → `{ id: string; … }`. The
52
+ * whole-table ergonomic form of {@link RowOf}, so app code derives its row interfaces from
53
+ * the schema instead of hand-maintaining a parallel twin. */
54
+ export type Row<T extends AnyTable> = RowOf<T[typeof SCHEMA]["columns"]>;
55
+
56
+ /** `table("issue").columns({ id: string(), … }).primaryKey("id")`. */
57
+ export function table<N extends string>(name: N) {
58
+ return {
59
+ columns<C extends AnyCols>(cols: C) {
60
+ return {
61
+ primaryKey<K extends keyof C & string>(...keys: K[]): TableDef<N, C> {
62
+ return makeTableDef<N, C>({ name, columns: cols, primaryKey: keys });
63
+ },
64
+ };
65
+ },
66
+ };
67
+ }
68
+
69
+ function makeTableDef<N extends string, C extends AnyCols>(meta: TableMeta<N, C>): TableDef<N, C> {
70
+ return new Proxy({} as Record<string | symbol, unknown>, {
71
+ get(_target, prop) {
72
+ if (prop === SCHEMA) return meta;
73
+ if (typeof prop === "string") return (arg: unknown) => fieldCondition(prop, arg);
74
+ return undefined;
75
+ },
76
+ }) as unknown as TableDef<N, C>;
77
+ }
78
+
79
+ /** Read a table's metadata (columns / PK / name). */
80
+ export function tableMeta(t: AnyTable): TableMeta {
81
+ return t[SCHEMA];
82
+ }
83
+
84
+ /** name → columns, derived from the tables array (for typing `store.query.<table>`). */
85
+ export type SchemaOf<T extends readonly AnyTable[]> = {
86
+ [E in T[number] as E[typeof SCHEMA]["name"]]: E[typeof SCHEMA]["columns"];
87
+ };
88
+
89
+ /** name → columns, the resolved schema map carried in the {@link Schema} type. */
90
+ export type ColsMap = Record<string, AnyCols>;
91
+
92
+ export interface Schema<S extends ColsMap = ColsMap> {
93
+ readonly tables: Readonly<Record<string, TableMeta>>;
94
+ /** Phantom carrying name→columns for query-root inference (never read at runtime). */
95
+ readonly __cols: S;
96
+ }
97
+
98
+ export function createSchema<const T extends readonly AnyTable[]>(opts: {
99
+ tables: T;
100
+ }): Schema<SchemaOf<T>> {
101
+ const tables: Record<string, TableMeta> = {};
102
+ for (const t of opts.tables) {
103
+ const m = t[SCHEMA];
104
+ tables[m.name] = m;
105
+ }
106
+ return { tables } as Schema<SchemaOf<T>>;
107
+ }
108
+
109
+ // ----------------------------- relationships (FRAGMENT-COMPOSITION-DESIGN §4.2, named edges) -----
110
+ //
111
+ // A relationship is the correlation (`parent.col → child.col`) declared ONCE as a value, so `sub`,
112
+ // `countAs`, and `exists` don't restate `{ parent, child }` keys at every spread/filter site. It is a
113
+ // plain typed value (not registered on the schema), passed where `(child, corr)` used to go.
114
+
115
+ /** Brand on a {@link Relationship} value (a `unique symbol`, distinct from a {@link TableDef}). */
116
+ const RELATIONSHIP_BRAND: unique symbol = Symbol("rindle.relationship");
117
+
118
+ /**
119
+ * A reusable, typed JOIN between two tables — the correlation declared once (design §4). Built with
120
+ * {@link rel}; parameterized by the parent columns `PC` (so a `sub` checks the relationship belongs to
121
+ * the query's table) and the child columns `CC` (which flow into the nested result type). Pass it to
122
+ * `sub`/`countAs`/`exists` in place of an explicit `child` + `{ parent, child }` correlation.
123
+ */
124
+ export interface Relationship<PC extends AnyCols, CC extends AnyCols> {
125
+ /** The child table the relationship points at. */
126
+ readonly child: TableLike<CC>;
127
+ /** Correlation keys: `parent[i]` (a parent column) joins to `child[i]` (a child column). */
128
+ readonly correlation: { readonly parent: readonly string[]; readonly child: readonly string[] };
129
+ /** Phantom binding the parent columns so `Query<C>.sub(alias, rel)` rejects a rel for another table. */
130
+ readonly __parent?: PC;
131
+ readonly [RELATIONSHIP_BRAND]: true;
132
+ }
133
+
134
+ /** Any relationship, for positions that only read its correlation / child table. */
135
+ export type AnyRelationship = Relationship<AnyCols, AnyCols>;
136
+
137
+ /**
138
+ * Declare a relationship once: `rel(issue, user, { ownerId: "id" })` means `issue.ownerId → user.id`.
139
+ * `mapping` is `{ [parentColumn]: childColumn }` (a composite join is multiple entries). The `parent`
140
+ * table is used only to type-check the keys; pass the result to `sub`/`countAs`/`exists`.
141
+ */
142
+ export function rel<PC extends AnyCols, CC extends AnyCols>(
143
+ _parent: TableLike<PC>,
144
+ child: TableLike<CC>,
145
+ mapping: Partial<Record<keyof PC & string, keyof CC & string>>,
146
+ ): Relationship<PC, CC> {
147
+ const parent = Object.keys(mapping);
148
+ const childKeys = parent.map((k) => mapping[k as keyof PC & string] as string);
149
+ return { child, correlation: { parent, child: childKeys }, [RELATIONSHIP_BRAND]: true };
150
+ }
151
+
152
+ /** A typed registry of named {@link Relationship}s — `defineRelationships({ issueOwner: rel(...) })`.
153
+ * A thin identity helper that names the bag and constrains its values; the keys are yours to choose. */
154
+ export function defineRelationships<R extends Record<string, AnyRelationship>>(rels: R): R {
155
+ return rels;
156
+ }
157
+
158
+ /** Runtime guard: is `v` a {@link Relationship} value (not a table or a plain object)? */
159
+ export function isRelationship(v: unknown): v is AnyRelationship {
160
+ return typeof v === "object" && v !== null && (v as Partial<AnyRelationship>)[RELATIONSHIP_BRAND] === true;
161
+ }
162
+
163
+ /** The `SchemaSpec` (`columns` + `primaryKey` indices) the wasm `Db.registerTable` wants. */
164
+ export function tableSpec(meta: TableMeta): { columns: string[]; primaryKey: number[] } {
165
+ const columns = Object.keys(meta.columns);
166
+ const primaryKey = meta.primaryKey.map((k) => columns.indexOf(k));
167
+ return { columns, primaryKey };
168
+ }
169
+
170
+ /** The client's per-table flat schema (name + column order + PK indices), the shape a
171
+ * normalized `hello` advertises (NORMALIZED-CHANGES-DESIGN.md §3). Used to validate a
172
+ * server hello against the CLIENT's own typed schema so a column-order / PK skew is caught
173
+ * instead of silently transposing positional cells (CRIT#4). Sorted by name for stable
174
+ * ordering. */
175
+ export function normalizedTableSchemas<S extends ColsMap>(
176
+ schema: Schema<S>,
177
+ ): { name: string; columns: string[]; primaryKey: number[] }[] {
178
+ return Object.keys(schema.tables)
179
+ .sort()
180
+ .map((name) => ({ name, ...tableSpec(schema.tables[name]) }));
181
+ }
package/src/ssr.ts ADDED
@@ -0,0 +1,115 @@
1
+ // SSR client integration (SSR-DESIGN.md §6). Server-side render is synchronous, so the data
2
+ // must already be in the cache when the render reads it. The seam is the BACKEND, not the hook:
3
+ //
4
+ // - Browser: the normal ws-backed Store — retain → lease → subscribe → live.
5
+ // - Server : a Store over a one-shot REST backend ({@link OneShotBackend}) that never streams.
6
+ // The route loader `preload`s each query (`POST /query`), which seeds the view for an
7
+ // instant correct first paint; `dehydrate` serializes those seeds into the HTML; the
8
+ // browser `store.hydrate(...)`s them and its live `subscribe` reconciles (§5).
9
+ //
10
+ // `useQuery` is byte-for-byte identical in both — only the injected backend (and whether a live
11
+ // subscribe ever happens) differs.
12
+
13
+ import type { Query } from "./query.ts";
14
+ import type { ColsMap, Schema } from "./schema.ts";
15
+ import { type AssembledNode, type DehydratedState, Store } from "./store.ts";
16
+ import type { Backend, ChangeEvent, Mutation, QueryId, RemoteQuery } from "./types.ts";
17
+
18
+ /** A `POST /query` response, as far as the server Store needs it (SSR-DESIGN.md §3.3): the
19
+ * assembled rows plus the `cvMin` baseline they reflect. (Matches `@rindle/daemon-client`'s
20
+ * `QueryOnceOutput`, but `@rindle/client` stays dependency-free — inject the call.) */
21
+ export interface OneShotResult {
22
+ rows: AssembledNode[];
23
+ cvMin?: number;
24
+ }
25
+
26
+ /** The one-shot read the server Store calls to preload a query. Two topologies inject different
27
+ * fns (SSR-DESIGN.md §6):
28
+ *
29
+ * - **Direct to the daemon** (the trusted tier holds the daemon token): use `ast` —
30
+ * `(i) => daemon.query(i)`.
31
+ * - **Through the application's API tier** (the authority resolves names → ASTs, the loader never
32
+ * sends a raw AST): use `name`/`args` — `(i) => fetch('/api/rindle/read', {name: i.name, args: i.args})`.
33
+ *
34
+ * `ast` is always present (the Store seeds the local view by its `viewKey`); `name`/`args` are
35
+ * present when the preloaded query came from `defineQuery`. The Store applies no policy/auth —
36
+ * that lives upstream (the daemon, or the API tier's `authorizeQuery`). */
37
+ export type OneShotQueryFn = (
38
+ input: { ast: unknown; name?: string; args?: unknown; visibilityKey?: string; ttlMs?: number },
39
+ ) => Promise<OneShotResult>;
40
+
41
+ /**
42
+ * A no-op live backend for SSR (SSR-DESIGN.md §6.1): it opens no transport and never streams.
43
+ * `registerQuery`/`mutate` are inert and no `ChangeEvent` is ever pushed, so every view stays
44
+ * PENDING — reading its SSR {@link Store.seedAssembled seed} — and, lacking `onResultType`,
45
+ * reports `complete` (a backend with no server lifecycle leaves every view authoritative).
46
+ */
47
+ export class OneShotBackend implements Backend {
48
+ registerQuery(_qid: QueryId, _ast: unknown, _remote?: RemoteQuery): void {}
49
+ unregisterQuery(_qid: QueryId): void {}
50
+ mutate(_mutations: Mutation[]): Promise<void> {
51
+ return Promise.reject(new Error("the SSR one-shot backend is read-only; mutate on the browser store"));
52
+ }
53
+ onEvent(_handler: (queryId: QueryId, event: ChangeEvent) => void): void {}
54
+ }
55
+
56
+ export interface ServerStoreOptions {
57
+ /** Performs the one-shot `POST /query` read (e.g. a `@rindle/daemon-client` instance's `query`). */
58
+ query: OneShotQueryFn;
59
+ /** Optional RLS/visibility dedup key forwarded to every preload (SSR-DESIGN.md §3.2). The daemon
60
+ * scopes the materialization's dedup `QueryKey` by it, so the same AST under two visibility keys
61
+ * never shares one pipeline; absent ⇒ the daemon's configured default. */
62
+ visibilityKey?: string;
63
+ /** Optional idle TTL (ms) the warm pipeline is left at, forwarded to every preload (SSR-DESIGN.md
64
+ * §3.4). The TTL is NOT part of the dedup key, so a shared materialization keeps the LONGEST TTL
65
+ * any caller requested (max-wins) — `ttlMs` can extend a query's warm-handoff window, never
66
+ * shrink it; absent ⇒ the daemon's default idle TTL. */
67
+ ttlMs?: number;
68
+ }
69
+
70
+ /**
71
+ * The server-side Store wrapper (SSR-DESIGN.md §6.2). Wraps a {@link Store} over a
72
+ * {@link OneShotBackend} and adds the loader-phase `preload` plus `dehydrate`. Pass `.store` to
73
+ * the React `<Rindle>` provider for the synchronous render; return `.dehydrate()` from the loader.
74
+ */
75
+ export class ServerStore<S extends ColsMap> {
76
+ readonly store: Store<S>;
77
+ private readonly opts: ServerStoreOptions;
78
+
79
+ constructor(schema: Schema<S>, opts: ServerStoreOptions) {
80
+ this.store = new Store(schema, new OneShotBackend());
81
+ this.opts = opts;
82
+ }
83
+
84
+ /** Run the one-shot read for `query` and seed its first-paint snapshot (SSR-DESIGN.md §6.2).
85
+ * Call once per query in the route loader, before the synchronous render. */
86
+ async preload(query: Query<any, any, any>): Promise<void> {
87
+ const ast = query.ast();
88
+ // Forward the AST (a direct-to-daemon backend reads it) plus the named identity when this came
89
+ // from `defineQuery` (an API-tier backend resolves `(name, args)` → AST itself, never trusting
90
+ // a client AST). The seed is keyed by the AST's `viewKey` either way, so the browser's
91
+ // `getServerSnapshot` finds it (SSR-DESIGN.md §6.2).
92
+ const named = typeof query.name === "string" ? { name: query.name, args: query.args } : undefined;
93
+ const result = await this.opts.query({
94
+ ast,
95
+ ...named,
96
+ visibilityKey: this.opts.visibilityKey,
97
+ ttlMs: this.opts.ttlMs,
98
+ });
99
+ this.store.seedAssembled(ast, result.rows, result.cvMin ?? 0);
100
+ }
101
+
102
+ /** The dehydrated first-paint cache for every preloaded query — embed in the HTML, then
103
+ * `store.hydrate(...)` it in the browser. */
104
+ dehydrate(): DehydratedState {
105
+ return this.store.dehydrate();
106
+ }
107
+ }
108
+
109
+ /** Construct a {@link ServerStore} — the one-shot REST Store for server-side rendering. */
110
+ export function createServerStore<S extends ColsMap>(
111
+ schema: Schema<S>,
112
+ opts: ServerStoreOptions,
113
+ ): ServerStore<S> {
114
+ return new ServerStore(schema, opts);
115
+ }
package/src/store.ts ADDED
@@ -0,0 +1,279 @@
1
+ // The Store — backend-agnostic glue (WASM-CLIENT-DESIGN.md §2). Holds the typed schema +
2
+ // a `Backend`, exposes `store.query.<table>…materialize()` and `store.write(tx => …)`, and
3
+ // routes the backend's per-query `ChangeEvent` stream into per-query `ArrayView`s.
4
+ //
5
+ // It never knows whether the backend is the in-process WASM engine or a remote server —
6
+ // both speak `registerQuery` / `mutate` / `onEvent`. The Store owns: query-id assignment,
7
+ // building each `ArrayView` (typed from the schema, so json columns parse), dispatching
8
+ // hello/snapshot/batch events, and turning object-shaped writes into positional mutations.
9
+
10
+ import type { Ast } from "./ast.ts";
11
+ import { stableKey } from "./key.ts";
12
+ import { queries, type Query, type QueryRoot } from "./query.ts";
13
+ import type { ColsMap, RowOf, Schema } from "./schema.ts";
14
+ import type { Backend, ChangeEvent, ColType, Mutation, QueryId, RemoteQuery, WireSchema, WireValue } from "./types.ts";
15
+ import { type ArrayView, FlatArrayView, type SingularArrayView, SingularView, type ViewTypes } from "./view.ts";
16
+
17
+ /** One query's SSR snapshot, keyed by its `viewKey` ({@link stableKey} of the AST): the
18
+ * pre-projected first-paint `rows` plus the `cvMin` watermark they reflect (SSR-DESIGN.md §6.2).
19
+ * Serializable as-is into the HTML — `rows` are already JSON values (json columns parsed). */
20
+ export interface DehydratedQuery {
21
+ rows: unknown[];
22
+ cvMin: number;
23
+ }
24
+
25
+ /** The whole dehydrated cache: every preloaded query's snapshot, keyed by `viewKey`. The server
26
+ * builds it with {@link Store.dehydrate}; the browser seeds it with {@link Store.hydrate}. */
27
+ export type DehydratedState = Record<string, DehydratedQuery>;
28
+
29
+ /** A single assembled (nested-by-name) row from `POST /query` (SSR-DESIGN.md §3.3): the cells
30
+ * under `cols`, each in-view relationship inlined by its alias (a nested array / object, or a
31
+ * scalar for a `countAs` aggregate). {@link Store.assembleSnapshot} converts these to the
32
+ * view's projected result shape. */
33
+ export interface AssembledNode {
34
+ cols: Record<string, WireValue>;
35
+ [rel: string]: unknown;
36
+ }
37
+
38
+ /** The write transaction handed to `store.write(tx => …)`. Rows are objects keyed by column;
39
+ * the Store positionalizes them (and stringifies json columns) before the backend sees them. */
40
+ export interface WriteTx<S extends ColsMap> {
41
+ add<N extends keyof S & string>(table: N, row: RowOf<S[N]>): void;
42
+ remove<N extends keyof S & string>(table: N, row: RowOf<S[N]>): void;
43
+ edit<N extends keyof S & string>(table: N, oldRow: RowOf<S[N]>, newRow: RowOf<S[N]>): void;
44
+ }
45
+
46
+ export interface CachedQueryView<Q extends Query<any, any, any>> {
47
+ readonly view: ReturnType<Q["materialize"]>;
48
+ /** Retain this query's named remote footprint and release it later. Ad-hoc local queries
49
+ * return a no-op release function. */
50
+ retain(query: Q): () => void;
51
+ destroy(): void;
52
+ }
53
+
54
+ export class Store<S extends ColsMap> {
55
+ /** Type-safe query entry: `store.query.issue.where.closed(false).materialize()`. */
56
+ readonly query: QueryRoot<S>;
57
+
58
+ private readonly schema: Schema<S>;
59
+ private readonly backend: Backend;
60
+ private nextId = 1;
61
+ private readonly views = new Map<QueryId, FlatArrayView>();
62
+ private readonly asts = new Map<QueryId, Ast>();
63
+ // SSR seeds (SSR-DESIGN.md §6), keyed by `viewKey`: a view materialized for one of these is
64
+ // seeded for first paint; a seed is consumed (dropped) the moment its query's first live
65
+ // `hello` lands, so later mounts don't flash stale SSR data.
66
+ private readonly seeds = new Map<string, DehydratedQuery>();
67
+
68
+ constructor(schema: Schema<S>, backend: Backend) {
69
+ this.schema = schema;
70
+ this.backend = backend;
71
+ this.backend.onEvent((qid, ev) => this.onEvent(qid, ev));
72
+ // 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>;
76
+ }
77
+
78
+ /** Materialize any fluent query object. Named queries subscribe remotely by `(name,args)`;
79
+ * ad-hoc builder queries are local-only for local-first backends. */
80
+ materialize<Q extends Query<any, any, any>>(query: Q): ReturnType<Q["materialize"]> {
81
+ const remote = typeof query.name === "string" ? { name: query.name, args: query.args } : undefined;
82
+ return this.registerMaterialized(query.ast(), remote).view as ReturnType<Q["materialize"]>;
83
+ }
84
+
85
+ /** True when the backend can retain a remote named query independently from the local
86
+ * materialized AST view. React uses this to keep one local view per AST while still sending
87
+ * every mounted `(name,args)` lease through the backend. */
88
+ canRetainRemoteQueries(): boolean {
89
+ return (
90
+ typeof this.backend.retainRemoteQuery === "function" &&
91
+ typeof this.backend.releaseRemoteQuery === "function"
92
+ );
93
+ }
94
+
95
+ /** Build one local AST view, with remote syncing retained separately through the returned
96
+ * handle. This is a lower-level API for UI bindings; ordinary app code should keep using
97
+ * `materialize(query)`. */
98
+ createCachedQueryView<Q extends Query<any, any, any>>(query: Q): CachedQueryView<Q> {
99
+ const { qid, view } = this.registerMaterialized(query.ast(), undefined);
100
+ let destroyed = false;
101
+ return {
102
+ view: view as ReturnType<Q["materialize"]>,
103
+ retain: (nextQuery: Q) => this.retainRemote(nextQuery, qid),
104
+ destroy: () => {
105
+ if (destroyed) return;
106
+ destroyed = true;
107
+ view.destroy();
108
+ },
109
+ };
110
+ }
111
+
112
+ /** Apply a batch of mutations (object rows → positional). Resolves when the backend has
113
+ * accepted them (local: applied; remote: sent). The resulting view updates flow back via
114
+ * the backend's event stream. */
115
+ write(fn: (tx: WriteTx<S>) => void): Promise<void> {
116
+ const muts: Mutation[] = [];
117
+ const tx = {
118
+ add: (t: string, row: Record<string, unknown>) =>
119
+ muts.push({ op: "add", table: t, row: this.positionalize(t, row) }),
120
+ remove: (t: string, row: Record<string, unknown>) =>
121
+ muts.push({ op: "remove", table: t, row: this.positionalize(t, row) }),
122
+ edit: (t: string, o: Record<string, unknown>, n: Record<string, unknown>) =>
123
+ muts.push({ op: "edit", table: t, old: this.positionalize(t, o), new: this.positionalize(t, n) }),
124
+ } as unknown as WriteTx<S>;
125
+ fn(tx);
126
+ return Promise.resolve(this.backend.mutate(muts));
127
+ }
128
+
129
+ // --- SSR (SSR-DESIGN.md §6) ---------------------------------------------------
130
+
131
+ /** Seed a query's first-paint snapshot from a `POST /query` response (server side): convert the
132
+ * assembled rows to the view's projected shape and stash them by `viewKey`. A view materialized
133
+ * for this AST (during the synchronous render) reads the seed; {@link dehydrate} serializes it. */
134
+ seedAssembled(ast: Ast, rows: AssembledNode[], cvMin: number): void {
135
+ this.seeds.set(stableKey(ast), { rows: this.assembleSnapshot(ast, rows), cvMin });
136
+ }
137
+
138
+ /** The dehydrated first-paint cache for every preloaded query — embed it in the HTML and pass it
139
+ * to {@link hydrate} in the browser (SSR-DESIGN.md §6.2). */
140
+ dehydrate(): DehydratedState {
141
+ return Object.fromEntries(this.seeds);
142
+ }
143
+
144
+ /** Seed the browser store from the server's {@link dehydrate} output (SSR-DESIGN.md §6.2): each
145
+ * view materialized for a hydrated AST shows these rows until its first live `hello` reconciles. */
146
+ hydrate(state: DehydratedState): void {
147
+ for (const [key, snap] of Object.entries(state)) this.seeds.set(key, snap);
148
+ }
149
+
150
+ /** A query's hydrated first-paint snapshot, by `viewKey` — what React's `getServerSnapshot`
151
+ * reads so an SSR render (and the matching client hydration pass) sees the seeded rows without
152
+ * opening a subscription. */
153
+ seedSnapshot(viewKey: string): DehydratedQuery | undefined {
154
+ return this.seeds.get(viewKey);
155
+ }
156
+
157
+ /** Convert assembled (nested-by-name) rows (SSR-DESIGN.md §3.3) into the view's projected result
158
+ * shape: spread `cols` (parsing json columns), recurse into each relationship by its alias
159
+ * (plural → array, `.one()` → object/null, `countAs` → bare scalar). */
160
+ assembleSnapshot(ast: Ast, rows: AssembledNode[]): unknown[] {
161
+ return rows.map((row) => this.assembleNode(ast, row));
162
+ }
163
+
164
+ private assembleNode(ast: Ast, node: AssembledNode): Record<string, unknown> {
165
+ const cols = this.columns(ast.table);
166
+ const out: Record<string, unknown> = {};
167
+ for (const [name, v] of Object.entries(node.cols ?? {})) {
168
+ out[name] = cols[name]?.type === "json" && typeof v === "string" ? JSON.parse(v) : v;
169
+ }
170
+ for (const sub of ast.related ?? []) {
171
+ const alias = sub.subquery.alias;
172
+ if (alias === undefined || !(alias in node)) continue;
173
+ const child = node[alias];
174
+ if (Array.isArray(child)) {
175
+ out[alias] = child.map((c) => this.assembleNode(sub.subquery, c as AssembledNode));
176
+ } else if (child !== null && typeof child === "object") {
177
+ out[alias] = this.assembleNode(sub.subquery, child as AssembledNode); // .one() singular
178
+ } else {
179
+ out[alias] = child; // a `countAs` scalar aggregate, or a null singular relationship
180
+ }
181
+ }
182
+ return out;
183
+ }
184
+
185
+ // --- internals ---------------------------------------------------------------
186
+
187
+ private registerMaterialized(
188
+ ast: Ast,
189
+ remote?: RemoteQuery,
190
+ ): { qid: QueryId; view: ArrayView<unknown> | SingularArrayView<unknown> } {
191
+ const qid: QueryId = this.nextId++;
192
+ this.asts.set(qid, ast);
193
+ // Pre-create the view (PENDING) so `materialize` is synchronous for ANY backend — a remote
194
+ // backend's `hello` arrives async (the view reads as `[]` until then). A synchronous backend
195
+ // (wasm/replica) resets it during `registerQuery` below, so it returns already-hydrated.
196
+ const view = this.views.get(qid) ?? this.views.set(qid, new FlatArrayView()).get(qid)!;
197
+ // Wire teardown: `destroy()` must unregister the query from the backend and drop our routing
198
+ // entry. Otherwise the engine keeps emitting events for the destroyed query and the Store
199
+ // routes them to the now-empty view — which throws on the next Child/remove ("parent not
200
+ // found") and, because dispatch is a single loop, aborts delivery to sibling queries too.
201
+ // This bites whenever a query is re-materialized (e.g. a changed limit/filter rebuilds it).
202
+ const baseDestroy = view.destroy.bind(view);
203
+ view.destroy = () => {
204
+ if (this.views.delete(qid)) {
205
+ this.asts.delete(qid);
206
+ this.backend.unregisterQuery(qid);
207
+ }
208
+ baseDestroy();
209
+ };
210
+ this.backend.registerQuery(qid, ast, remote);
211
+ // SSR first paint (SSR-DESIGN.md §6): if this AST was preloaded/hydrated, seed the view now.
212
+ // A synchronous backend (wasm) already reset the view above, so its live data wins and the
213
+ // seed is inert; an async backend (ws) leaves it PENDING, so the seed shows until `hello`.
214
+ const seed = this.seeds.get(stableKey(ast));
215
+ if (seed) view.seed(seed.rows);
216
+ // A top-level `.one()` (engine-capped to limit 1) unwraps at the result boundary.
217
+ return { qid, view: ast.one ? new SingularView(view) : view };
218
+ }
219
+
220
+ private retainRemote<Q extends Query<any, any, any>>(query: Q, localQueryId: QueryId): () => void {
221
+ const remote = typeof query.name === "string" ? { name: query.name, args: query.args } : undefined;
222
+ if (!remote || !this.canRetainRemoteQueries()) return () => {};
223
+ const qid = this.nextId++;
224
+ this.backend.retainRemoteQuery?.(qid, remote, localQueryId);
225
+ let released = false;
226
+ return () => {
227
+ if (released) return;
228
+ released = true;
229
+ this.backend.releaseRemoteQuery?.(qid);
230
+ };
231
+ }
232
+
233
+ private onEvent(qid: QueryId, ev: ChangeEvent): void {
234
+ if (ev.type === "hello") {
235
+ const ast = this.asts.get(qid);
236
+ // First live hello for this view ⇒ retire its SSR seed: the maintained tree now owns the
237
+ // result, so no future mount of the same query should flash the stale first-paint rows.
238
+ if (ast) this.seeds.delete(stableKey(ast));
239
+ const types = ast ? this.viewTypes(ev.schema, ast) : undefined;
240
+ // Reset the (pre-created or existing) view IN PLACE — first hello OR a re-hydrate (new
241
+ // epoch) — so the materialized reference the caller holds survives a re-subscribe.
242
+ const view = this.views.get(qid) ?? this.views.set(qid, new FlatArrayView()).get(qid)!;
243
+ view.reset(ev.schema, types);
244
+ } else if (ev.type === "snapshot") {
245
+ this.views.get(qid)?.applyChanges(ev.adds);
246
+ } else {
247
+ this.views.get(qid)?.applyChanges(ev.events);
248
+ }
249
+ }
250
+
251
+ private columns(table: string): Record<string, { type: ColType }> {
252
+ const meta = this.schema.tables[table];
253
+ if (!meta) throw new Error(`unknown table: ${table}`);
254
+ return meta.columns as unknown as Record<string, { type: ColType }>;
255
+ }
256
+
257
+ /** An object row → a positional cell array in the table's column order (json → string). */
258
+ private positionalize(table: string, obj: Record<string, unknown>): WireValue[] {
259
+ const cols = this.columns(table);
260
+ return Object.keys(cols).map((name) => {
261
+ const v = obj[name];
262
+ if (cols[name].type === "json" && v != null && typeof v === "object") return JSON.stringify(v);
263
+ return (v ?? null) as WireValue;
264
+ });
265
+ }
266
+
267
+ /** The per-level column types parallel to the WireSchema, so the view parses json columns. */
268
+ private viewTypes(ws: WireSchema, ast: Ast): ViewTypes {
269
+ const cols = this.columns(ast.table);
270
+ const columnTypes = ws.columns.map((name) => cols[name]?.type ?? "string");
271
+ const rels: Record<number, ViewTypes> = {};
272
+ for (const rel of ws.relationships) {
273
+ if (!rel.child) continue;
274
+ const sub = (ast.related ?? []).find((r) => r.subquery.alias === rel.name);
275
+ if (sub) rels[rel.slot] = this.viewTypes(rel.child, sub.subquery);
276
+ }
277
+ return { columnTypes, rels };
278
+ }
279
+ }