@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.
- package/LICENSE +201 -0
- package/README.md +65 -0
- package/dist/ast.d.ts +83 -0
- package/dist/ast.d.ts.map +1 -0
- package/dist/ast.js +5 -0
- package/dist/ast.js.map +1 -0
- package/dist/compare.d.ts +17 -0
- package/dist/compare.d.ts.map +1 -0
- package/dist/compare.js +81 -0
- package/dist/compare.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/key.d.ts +2 -0
- package/dist/key.d.ts.map +1 -0
- package/dist/key.js +26 -0
- package/dist/key.js.map +1 -0
- package/dist/operators.d.ts +39 -0
- package/dist/operators.d.ts.map +1 -0
- package/dist/operators.js +45 -0
- package/dist/operators.js.map +1 -0
- package/dist/query.d.ts +280 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +348 -0
- package/dist/query.js.map +1 -0
- package/dist/schema.d.ts +111 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +92 -0
- package/dist/schema.js.map +1 -0
- package/dist/ssr.d.ts +73 -0
- package/dist/ssr.d.ts.map +1 -0
- package/dist/ssr.js +66 -0
- package/dist/ssr.js.map +1 -0
- package/dist/store.d.ts +90 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +225 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +250 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +20 -0
- package/dist/types.js.map +1 -0
- package/dist/view.d.ts +88 -0
- package/dist/view.d.ts.map +1 -0
- package/dist/view.js +294 -0
- package/dist/view.js.map +1 -0
- package/package.json +36 -0
- package/src/ast.ts +94 -0
- package/src/compare.ts +85 -0
- package/src/index.ts +57 -0
- package/src/key.ts +23 -0
- package/src/operators.ts +68 -0
- package/src/query.ts +734 -0
- package/src/schema.ts +181 -0
- package/src/ssr.ts +115 -0
- package/src/store.ts +279 -0
- package/src/types.ts +234 -0
- 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
|
+
}
|