@rindle/client 0.2.0 → 0.4.2

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 (53) hide show
  1. package/dist/ast.d.ts +11 -0
  2. package/dist/ast.d.ts.map +1 -1
  3. package/dist/index.d.ts +6 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +4 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/mutation-ops.d.ts +140 -0
  8. package/dist/mutation-ops.d.ts.map +1 -0
  9. package/dist/mutation-ops.js +60 -0
  10. package/dist/mutation-ops.js.map +1 -0
  11. package/dist/operators.d.ts +5 -0
  12. package/dist/operators.d.ts.map +1 -1
  13. package/dist/operators.js +5 -0
  14. package/dist/operators.js.map +1 -1
  15. package/dist/query.d.ts +65 -9
  16. package/dist/query.d.ts.map +1 -1
  17. package/dist/query.js +56 -1
  18. package/dist/query.js.map +1 -1
  19. package/dist/resolve.d.ts +40 -0
  20. package/dist/resolve.d.ts.map +1 -0
  21. package/dist/resolve.js +109 -0
  22. package/dist/resolve.js.map +1 -0
  23. package/dist/schema.d.ts +53 -5
  24. package/dist/schema.d.ts.map +1 -1
  25. package/dist/schema.js +65 -4
  26. package/dist/schema.js.map +1 -1
  27. package/dist/ssr.d.ts +14 -0
  28. package/dist/ssr.d.ts.map +1 -1
  29. package/dist/ssr.js +15 -0
  30. package/dist/ssr.js.map +1 -1
  31. package/dist/store.d.ts +47 -15
  32. package/dist/store.d.ts.map +1 -1
  33. package/dist/store.js +163 -25
  34. package/dist/store.js.map +1 -1
  35. package/dist/types.d.ts +37 -0
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js.map +1 -1
  38. package/dist/view.d.ts +43 -5
  39. package/dist/view.d.ts.map +1 -1
  40. package/dist/view.js +87 -22
  41. package/dist/view.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/ast.ts +11 -0
  44. package/src/index.ts +29 -1
  45. package/src/mutation-ops.ts +190 -0
  46. package/src/operators.ts +5 -0
  47. package/src/query.ts +142 -13
  48. package/src/resolve.ts +136 -0
  49. package/src/schema.ts +130 -5
  50. package/src/ssr.ts +21 -0
  51. package/src/store.ts +157 -34
  52. package/src/types.ts +35 -1
  53. package/src/view.ts +103 -22
package/src/query.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // - `where<Field>(…)` camelCase sugar (`whereClosed(false)`) is intercepted too.
7
7
  // Both are fully typed via mapped types + template-literal keys.
8
8
 
9
- import type { Ast, Bound, Condition, CorrelatedSubquery, Dir, ExistsOp, LitValue, OrderPart } from "./ast.ts";
9
+ import type { Ast, Bound, Condition, CorrelatedSubquery, Dir, ExistsOp, LitValue, OrderPart, SimpleOp } from "./ast.ts";
10
10
  import { stableKey } from "./key.ts";
11
11
  import type { Arg, Cond } from "./operators.ts";
12
12
  import { fieldCondition } from "./operators.ts";
@@ -49,11 +49,36 @@ type Projected<C extends AnyCols, Sel extends string> = [Sel] extends [never]
49
49
  ? RowOf<C>
50
50
  : Pick<RowOf<C>, Sel & keyof C>;
51
51
 
52
+ /**
53
+ * The accumulating result row of a top-level aggregate (REDUCE-DESIGN.md §8). `Agg` is `false`
54
+ * until {@link QueryBase.count}/{@link QueryBase.groupBy} reshapes the query; then it is the
55
+ * group-by columns intersected with the synthetic `{ count: number }`. {@link AggAcc} treats the
56
+ * `false` state as `{}` so the intersections in `count`/`groupBy` compose either way.
57
+ */
58
+ type AggAcc<Agg> = [Agg] extends [false] ? {} : Agg;
59
+
60
+ /** `having`'s field binder: one accessor per aggregate-OUTPUT column (the group-by columns plus the
61
+ * synthetic numeric `count`), each producing a {@link Cond} over that aggregate row. It is the only
62
+ * way to name `count` in a predicate (it lives on no base table); compose several with `and`/`or`. */
63
+ type HavingProxy<Row> = { [K in keyof Row & string]: (arg: Arg<Row[K]>) => Cond<Row> };
64
+
65
+ /** The `countAs` relationship aliases of a query — the {@link Rels} keys whose value is the scalar
66
+ * `number` a count aggregate surfaces. These are the only aliases {@link QueryBase.having}'s
67
+ * parent-by-child-aggregate overload accepts (a plain `sub` alias carries a row object, not a
68
+ * number, so it is rejected). */
69
+ type AggregateAlias<Rels> = { [K in keyof Rels]: Rels[K] extends number ? K & string : never }[keyof Rels];
70
+
71
+ /** What a query materializes: the post-aggregation row once reshaped by {@link QueryBase.count}
72
+ * (`Agg`), else the projected base row plus its relationship values. */
73
+ type ResultRow<C extends AnyCols, Rels, Sel extends string, Agg> = [Agg] extends [false]
74
+ ? Projected<C, Sel> & Rels
75
+ : Agg;
76
+
52
77
  type FragmentEdge<C extends AnyCols, Rels, Sel extends string, LocalRels> = (
53
78
  q: Query<C, Rels, false, Sel, LocalRels>,
54
79
  ) => Query<C, Rels, boolean, Sel, LocalRels>;
55
80
 
56
- interface QueryBase<C extends AnyCols, Rels, One extends boolean, Sel extends string, LocalRels> {
81
+ interface QueryBase<C extends AnyCols, Rels, One extends boolean, Sel extends string, LocalRels, Agg = false> {
57
82
  /** Present when this local query came from `defineQuery`; used as the remote identity. */
58
83
  readonly name?: string;
59
84
  /** Present when this local query came from `defineQuery`; sent with `name` upstream. */
@@ -219,12 +244,49 @@ interface QueryBase<C extends AnyCols, Rels, One extends boolean, Sel extends st
219
244
  relationship: Relationship<C, CC>,
220
245
  build?: (q: Query<CC>) => Query<CC, unknown>,
221
246
  ): Query<C, Rels & { [P in A]: number }, One, Sel, LocalRels & { [P in A]: number }>;
247
+ /** Reshape this query into a top-level `count(*)` aggregate (`REDUCE-DESIGN.md` §8) — the SQL
248
+ * `SELECT count(*) FROM table [GROUP BY …] [HAVING …]`. Without {@link groupBy} it is a GLOBAL
249
+ * count (one `{ count }` row, value `0` even on empty input); with it, one `{ …group, count }`
250
+ * row per distinct group. The result row becomes the aggregate's OUTPUT — the group-by columns
251
+ * plus a numeric `count` — so chain {@link having} to filter it. Distinct from {@link countAs}
252
+ * (a child-relationship scalar attached to the parent row); this reshapes the query ITSELF. The
253
+ * engine rejects pairing a root aggregate with `select`/`sub`/`countAs`/`orderBy`/`limit`/`one`. */
254
+ count(): Query<C, Rels, One, Sel, LocalRels, AggAcc<Agg> & { count: number }>;
255
+ /** Add a top-level `GROUP BY` column (chain for a compound key); only meaningful with
256
+ * {@link count}. Each grouped column joins the aggregate result row, keyed + sorted by the
257
+ * group key. */
258
+ groupBy<K extends keyof C & string>(
259
+ col: K,
260
+ ): Query<C, Rels, One, Sel, LocalRels, AggAcc<Agg> & Pick<RowOf<C>, K>>;
261
+ /** `HAVING (…)` — filter the **post-aggregation** rows of a {@link count} query (`REDUCE-DESIGN.md`
262
+ * §4: a filter directly above the reduce). `build` receives a field binder over the aggregate's
263
+ * OUTPUT columns — the {@link groupBy} columns and the synthetic `count` — and returns a
264
+ * {@link Cond}; compose several with `and`/`or`. E.g.
265
+ * `.groupBy("status").count().having((h) => h.count(gt(3)))`. Distinct from {@link where}, which
266
+ * filters base rows BELOW the reduce. */
267
+ having(build: (h: HavingProxy<AggAcc<Agg>>) => Cond<AggAcc<Agg>>): Query<C, Rels, One, Sel, LocalRels, Agg>;
268
+ /** `HAVING count(child) <op> n` — filter THIS parent by a child relationship aggregate's count
269
+ * (`PARENT-AGGREGATE-FILTER-DESIGN.md`). `alias` must name a {@link countAs} relationship already
270
+ * on this query; this drops parents whose child count fails `<op> val`, maintained incrementally
271
+ * (a child add/remove crossing the threshold adds/removes the parent). The display `countAs` is
272
+ * untouched — a survivor still shows its real count. Distinct from the {@link having} overload
273
+ * above, which filters a top-level {@link count}'s own output rows.
274
+ *
275
+ * **v1: high-pass predicates only** — predicates *false* at count 0 (`>`, `>=`/`=`/`!=` for
276
+ * `n ≥ 1`). A childless parent forms no group, so the engine rejects (at build) a predicate *true*
277
+ * at count 0 (`<=`, `< n` for `n ≥ 1`, `= 0`, `>= 0`); those need row-widening (deferred). */
278
+ having<A extends AggregateAlias<Rels>>(
279
+ alias: A,
280
+ op: SimpleOp,
281
+ val: number,
282
+ ): Query<C, Rels, One, Sel, LocalRels, Agg>;
222
283
  /** The compiled Zero-wire AST (what a backend's `query` consumes). */
223
284
  ast(): Ast;
224
285
  /** Materialize into a live, typed view. Wired by the Store/backend. A top-level `.one()`
225
- * yields a {@link SingularArrayView}; otherwise an {@link ArrayView}. The row is masked to the
226
- * projection ({@link Projected}) full {@link RowOf} until a column is `select`ed. */
227
- materialize(): MaterializedView<Projected<C, Sel> & Rels, One>;
286
+ * yields a {@link SingularArrayView}; otherwise an {@link ArrayView}. The row is the aggregate
287
+ * output once {@link count} reshaped the query, else masked to the projection ({@link Projected})
288
+ * full {@link RowOf} until a column is `select`ed. */
289
+ materialize(): MaterializedView<ResultRow<C, Rels, Sel, Agg>, One>;
228
290
  }
229
291
 
230
292
  export type Query<
@@ -233,18 +295,21 @@ export type Query<
233
295
  One extends boolean = false,
234
296
  Sel extends string = never,
235
297
  LocalRels = Rels,
298
+ Agg = false,
236
299
  > =
237
- & Omit<QueryBase<C, Rels, One, Sel, LocalRels>, "where">
238
- & { where: QueryBase<C, Rels, One, Sel, LocalRels>["where"] & WhereProxy<C, Rels, One, Sel, LocalRels> }
300
+ & Omit<QueryBase<C, Rels, One, Sel, LocalRels, Agg>, "where">
301
+ & { where: QueryBase<C, Rels, One, Sel, LocalRels, Agg>["where"] & WhereProxy<C, Rels, One, Sel, LocalRels> }
239
302
  & WhereSugar<C, Rels, One, Sel, LocalRels>;
240
303
 
241
- export type AnyQuery = Query<any, any, any, any, any>;
304
+ export type AnyQuery = Query<any, any, any, any, any, any>;
242
305
 
243
306
  export type QueryLocalData<Q extends AnyQuery> =
244
- Q extends Query<infer C, any, infer One, infer Sel, infer LocalRels>
245
- ? One extends true
246
- ? (Projected<C, Sel> & LocalRels) | null
247
- : readonly (Projected<C, Sel> & LocalRels)[]
307
+ Q extends Query<infer C, any, infer One, infer Sel, infer LocalRels, infer Agg>
308
+ ? [Agg] extends [false]
309
+ ? One extends true
310
+ ? (Projected<C, Sel> & LocalRels) | null
311
+ : readonly (Projected<C, Sel> & LocalRels)[]
312
+ : readonly Agg[]
248
313
  : never;
249
314
  const STAMP_NAMED_QUERY: unique symbol = Symbol("rindle.stampNamedQuery");
250
315
 
@@ -632,6 +697,12 @@ interface State {
632
697
  limit?: number;
633
698
  one: boolean;
634
699
  select?: string[];
700
+ // Top-level aggregate (REDUCE-DESIGN.md §8): `aggregate` reshapes the query into a `count(*)`;
701
+ // `groupBy` partitions it; `having` filters the post-aggregation rows. All three are read by the
702
+ // engine only together (see the guard in `compile`).
703
+ aggregate?: "count";
704
+ groupBy: string[];
705
+ having?: Condition;
635
706
  }
636
707
 
637
708
  interface NamedQueryState {
@@ -640,7 +711,7 @@ interface NamedQueryState {
640
711
  }
641
712
 
642
713
  function emptyState(table: string): State {
643
- return { table, wheres: [], orderBy: [], related: [], one: false };
714
+ return { table, wheres: [], orderBy: [], related: [], one: false, groupBy: [] };
644
715
  }
645
716
 
646
717
  function compile(s: State): Ast {
@@ -654,6 +725,18 @@ function compile(s: State): Ast {
654
725
  if (s.limit !== undefined) ast.limit = s.limit;
655
726
  if (s.one) ast.one = true;
656
727
  if (s.select && s.select.length > 0) ast.select = s.select;
728
+ if (s.aggregate !== undefined) ast.aggregate = s.aggregate;
729
+ if (s.groupBy.length > 0) ast.groupBy = s.groupBy;
730
+ if (s.having !== undefined) ast.having = s.having;
731
+ // The engine takes the aggregate lowering ONLY when `aggregate` is set (REDUCE-DESIGN.md §8 /
732
+ // builder `build_pipeline`); `groupBy`/`having` on the row spine would be silently ignored. Fail
733
+ // loudly so a forgotten `.count()` is a clear error, not a query that quietly returns all rows.
734
+ if ((ast.groupBy !== undefined || ast.having !== undefined) && ast.aggregate === undefined) {
735
+ throw new Error(
736
+ "groupBy()/having() require count(): a top-level GROUP BY / HAVING is only honored on an " +
737
+ "aggregate query (REDUCE-DESIGN.md §8) — add .count().",
738
+ );
739
+ }
657
740
  return ast;
658
741
  }
659
742
 
@@ -923,6 +1006,52 @@ function makeQuery(
923
1006
  };
924
1007
  return next({ related: [...s.related, csq] });
925
1008
  },
1009
+ // Top-level aggregate (REDUCE-DESIGN §8): `count` reshapes this query into a `count(*)`;
1010
+ // `groupBy` partitions it; `having` filters the post-aggregation rows. `having`'s callback gets
1011
+ // a field binder over the aggregate's output columns (group cols + the synthetic `count`), so a
1012
+ // predicate can name `count` — which lives on no base table — exactly like `where.<field>(…)`.
1013
+ count: () => next({ aggregate: "count" }),
1014
+ groupBy: (col: string) => next({ groupBy: [...s.groupBy, col] }),
1015
+ having: (a: unknown, op?: SimpleOp, val?: number) => {
1016
+ // Overload 1 — top-level aggregate HAVING: `having((h) => h.count(gt(3)))`. The callback
1017
+ // binds the aggregate's output columns (group cols + the synthetic `count`).
1018
+ if (typeof a === "function") {
1019
+ const build = a as (h: Record<string, (arg: unknown) => Condition>) => Condition;
1020
+ const h = new Proxy({} as Record<string, (arg: unknown) => Condition>, {
1021
+ get: (_t, prop) => (typeof prop === "string" ? (arg: unknown) => fieldCondition(prop, arg) : undefined),
1022
+ });
1023
+ return next({ having: build(h) });
1024
+ }
1025
+ // Overload 2 — filter THIS parent by a child aggregate's count:
1026
+ // `.having("commentCount", ">", 10)` (PARENT-AGGREGATE-FILTER-DESIGN.md). Bind the existing
1027
+ // `countAs(alias, …)` relationship and push a hidden EXISTS whose subquery clones the same
1028
+ // correlation + child + `count` aggregate, plus a `HAVING count <op> val`. The engine lowers
1029
+ // it to an EXISTS over a HAVING-filtered reduce; the display `countAs` is untouched.
1030
+ const alias = a as string;
1031
+ const display = s.related.find(
1032
+ (r) => r.subquery.alias === alias && r.subquery.aggregate === "count",
1033
+ );
1034
+ if (!display) {
1035
+ throw new Error(
1036
+ `having("${alias}", …): this query has no countAs("${alias}", …) relationship to filter ` +
1037
+ `on — attach the child count aggregate first.`,
1038
+ );
1039
+ }
1040
+ const gate: CorrelatedSubquery = {
1041
+ correlation: display.correlation,
1042
+ subquery: {
1043
+ ...display.subquery,
1044
+ alias: `__having_${alias}`,
1045
+ having: {
1046
+ type: "simple",
1047
+ op: op as SimpleOp,
1048
+ left: { type: "column", name: "count" },
1049
+ right: { type: "literal", value: val as LitValue },
1050
+ },
1051
+ },
1052
+ };
1053
+ return next({ wheres: [...s.wheres, { type: "correlatedSubquery", op: "EXISTS", related: gate }] });
1054
+ },
926
1055
  ast: () => {
927
1056
  const a = compile(s);
928
1057
  guardAst?.(a);
package/src/resolve.ts ADDED
@@ -0,0 +1,136 @@
1
+ // Positional → named change resolution: the inverse of the wire encoder.
2
+ //
3
+ // A backend ships per-query `FlatChange`s (types.ts): `{ path: PathSeg[], op: add|remove|edit }`,
4
+ // where `path` indexes into the view's relationship tree and rows are POSITIONAL cells. The view
5
+ // (view.ts) folds those into the materialized tree positionally; this module instead LIFTS one
6
+ // change out of positional/indexed wire form into NAMED rows, resolving it against the query's OWN
7
+ // `WireSchema` (the per-subscription view schema the engine ships once on its `hello` frame): per
8
+ // level the column NAMES in wire order, and the relationships in SLOT order (a gating exists/notExists
9
+ // slot is `child: null`; a `countAs` slot carries a `project` annotation). So `path[i].rel` indexes
10
+ // `schema.relationships[i]` directly, and a positional `row` names against `schema.columns` — both
11
+ // authoritative, straight from the engine.
12
+ //
13
+ // Resolving against the hello schema (rather than re-deriving positions from the query `Ast`) means
14
+ // we assume NOTHING about slot ordering and NOTHING about column order: it stays correct under
15
+ // `.select()` projections and any future slot layout. The result — named rows + an aggregate's exact
16
+ // new value — is what a higher layer (e.g. @rindle/narrator) renders into prose, but it is broadly
17
+ // useful to anything consuming a `FlatChange` (logging, devtools, change-driven overlays).
18
+ //
19
+ // NOTE on aggregates (`countAs`): the slot's `project.col` names the EXACT count cell in the child
20
+ // row, so a count change reports the exact new value (and previous, on an edit) — not a best-effort
21
+ // guess. A remove of the aggregate row means the count fell to its identity (`project.identity`).
22
+
23
+ import type { FlatChange, PathSeg, WireNode, WireRel, WireSchema, WireValue } from "./types.ts";
24
+
25
+ /** A row named against its level's wire columns. */
26
+ export type NamedRow = Record<string, WireValue>;
27
+
28
+ /** One resolved change: a `FlatChange` lifted out of positional/indexed wire form into names,
29
+ * using the query's `WireSchema` (from `hello`) as the sole position→name source. */
30
+ export interface ResolvedChange {
31
+ /** Relationship-alias chain from the query root to the changed level (`[]` ⇒ the root rows). */
32
+ aliasChain: string[];
33
+ /** The alias of the changed level (`""` ⇒ root), i.e. the last of `aliasChain`. */
34
+ alias: string;
35
+ op: "add" | "remove" | "edit";
36
+ /** The affected row, named. For `edit` this is the NEW row; see `old` for the prior one. */
37
+ row: NamedRow;
38
+ /** The prior row, named (present only for `edit`). */
39
+ old?: NamedRow;
40
+ /** The PARENT row (named), for a nested/aggregate change — e.g. the `ticket_type` whose `sold`
41
+ * count moved. Taken from the path's last `parentRow`; absent for a root-level change. */
42
+ parent?: NamedRow;
43
+ /** Set when the changed level is a `countAs`/aggregate slot. The value is EXACT — read from the
44
+ * slot's projected count column (`WireRel.project.col`). */
45
+ aggregate?: { alias: string; value: WireValue; previous?: WireValue };
46
+ /** The raw node whose children a consumer can dig a named sub-row out of (via {@link subRow}). On
47
+ * an `add` the engine always ships it; on a `remove` it is present only when the consumer opted
48
+ * into the removed subtree (see the `op` mapping below). */
49
+ node?: WireNode;
50
+ /** The changed level's `WireSchema` — used by {@link subRow} to resolve a named sub of `node`. */
51
+ levelSchema: WireSchema;
52
+ }
53
+
54
+ /** Name positional `cells` against a level's wire `columns` (insertion = wire order). */
55
+ function nameRow(cells: WireValue[] | undefined, cols: string[]): NamedRow {
56
+ const out: NamedRow = {};
57
+ if (!cells) return out;
58
+ for (let i = 0; i < cols.length; i++) out[cols[i]] = cells[i] ?? null;
59
+ return out;
60
+ }
61
+
62
+ /** Walk a `path` from the root `WireSchema` down the relationship tree. Returns the reached level,
63
+ * the alias chain, and the LAST relationship traversed (its `project` marks an aggregate slot).
64
+ * `null` if a hop addresses an unknown or gating (`child: null`) slot. */
65
+ function descend(
66
+ root: WireSchema,
67
+ path: PathSeg[],
68
+ ): { level: WireSchema; lastRel: WireRel | null; aliasChain: string[] } | null {
69
+ let level = root;
70
+ let lastRel: WireRel | null = null;
71
+ const aliasChain: string[] = [];
72
+ for (const seg of path) {
73
+ const rel = level.relationships[seg.rel];
74
+ if (!rel || !rel.child) return null; // unknown / gating slot — not a materialized level
75
+ lastRel = rel;
76
+ level = rel.child;
77
+ aliasChain.push(rel.name);
78
+ }
79
+ return { level, lastRel, aliasChain };
80
+ }
81
+
82
+ /** Lift one `FlatChange` into a {@link ResolvedChange} against the query's `WireSchema`, or `null`
83
+ * if its path doesn't resolve to a materialized level. */
84
+ export function resolveChange(schema: WireSchema, change: FlatChange): ResolvedChange | null {
85
+ const here = descend(schema, change.path);
86
+ if (!here) return null;
87
+ const { level, lastRel, aliasChain } = here;
88
+ const cols = level.columns;
89
+ const alias = aliasChain.length ? aliasChain[aliasChain.length - 1] : "";
90
+ const base: Omit<ResolvedChange, "op" | "row"> = { aliasChain, alias, levelSchema: level };
91
+ // The parent row (for a nested/aggregate change) sits in the last path seg, named against the
92
+ // level one hop up.
93
+ if (change.path.length) {
94
+ const up = descend(schema, change.path.slice(0, -1));
95
+ const parentCells = change.path[change.path.length - 1].parentRow;
96
+ if (up) base.parent = nameRow(parentCells, up.level.columns);
97
+ }
98
+
99
+ // A `countAs` slot carries a scalar `project` annotation on the relationship we descended through:
100
+ // `project.col` is the exact count cell, `project.identity` the empty value (0 for count).
101
+ const proj = lastRel?.project ?? null;
102
+ const agg = (cells: WireValue[] | undefined): WireValue => (proj && cells ? (cells[proj.col] ?? null) : null);
103
+
104
+ const op = change.op;
105
+ if (op.tag === "add") {
106
+ const r: ResolvedChange = { ...base, op: "add", row: nameRow(op.node.row, cols), node: op.node };
107
+ if (proj) r.aggregate = { alias, value: agg(op.node.row) };
108
+ return r;
109
+ }
110
+ if (op.tag === "remove") {
111
+ const r: ResolvedChange = { ...base, op: "remove", row: nameRow(op.row, cols) };
112
+ // The removed subtree rides along only when the consumer opted into it (the ArrayView attaches
113
+ // it client-side — `Store.subscribeChanges(_, { removedSubtree: true })`). When present, `subRow`
114
+ // resolves a removed row's nested subs exactly as on an `add`; absent, a remove is row-only.
115
+ if (op.node) r.node = op.node;
116
+ if (proj) r.aggregate = { alias, value: proj.identity };
117
+ return r;
118
+ }
119
+ // edit
120
+ const r: ResolvedChange = { ...base, op: "edit", row: nameRow(op.new, cols), old: nameRow(op.old, cols) };
121
+ if (proj) r.aggregate = { alias, value: agg(op.new), previous: agg(op.old) };
122
+ return r;
123
+ }
124
+
125
+ /** Read a named sub-row off a change's `node` by relationship alias (e.g. the `guest` under an
126
+ * `rsvp`) — the `add` node, or a `remove`'s subtree when the consumer opted into it. The alias →
127
+ * wire slot mapping comes from the changed level's `WireSchema`. `null` when no node rode along. */
128
+ export function subRow(rc: ResolvedChange, alias: string): NamedRow | null {
129
+ if (!rc.node) return null;
130
+ const rel = rc.levelSchema.relationships.find((r) => r.name === alias);
131
+ if (!rel || !rel.child) return null;
132
+ const slot = rc.node.rels.find((s) => s.rel === rel.slot);
133
+ const child = slot?.children[0];
134
+ if (!child) return null;
135
+ return nameRow(child.row, rel.child.columns);
136
+ }
package/src/schema.ts CHANGED
@@ -10,7 +10,9 @@ import type { Arg, Cond } from "./operators.ts";
10
10
  import { fieldCondition } from "./operators.ts";
11
11
  import type { ColType } from "./types.ts";
12
12
 
13
- /** A column descriptor. `type` drives the comparator + JSON parsing; `__t` is a phantom. */
13
+ /** A column descriptor. `type` drives the comparator + JSON parsing; `__t` is a phantom. A
14
+ * nullable column is a `Col<T | null>` — that is the ONLY thing nullability changes at the type
15
+ * level, so `RowOf` (and the field-condition factory) widen automatically. */
14
16
  export interface Col<T> {
15
17
  readonly type: ColType;
16
18
  readonly __t?: T;
@@ -19,10 +21,21 @@ export type ColT<X> = X extends Col<infer T> ? T : never;
19
21
  export type AnyCols = Record<string, Col<unknown>>;
20
22
  export type RowOf<C extends AnyCols> = { [K in keyof C]: ColT<C[K]> };
21
23
 
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" });
24
+ /** The chainable form returned by the column factories: a {@link Col} plus `.nullable()`.
25
+ *
26
+ * `.nullable()` widens the column's value type to `T | null` — its `Row<…>` field becomes
27
+ * `T | null`, and (design 206 Phase 2) it may be omitted from an insert. `rindle schema gen` emits
28
+ * `.nullable()` for every nullable (non-`NOT NULL`) SQL column; you can also call it by hand on a
29
+ * local-only table's columns. It is idempotent and stays chainable. */
30
+ export interface ColBuilder<T> extends Col<T> {
31
+ nullable(): ColBuilder<T | null>;
32
+ }
33
+ const makeCol = <T>(type: ColType): ColBuilder<T> => ({ type, nullable: () => makeCol<T | null>(type) });
34
+
35
+ export const string = <T extends string = string>(): ColBuilder<T> => makeCol<T>("string");
36
+ export const number = <T extends number = number>(): ColBuilder<T> => makeCol<T>("number");
37
+ export const boolean = (): ColBuilder<boolean> => makeCol<boolean>("boolean");
38
+ export const json = <T = unknown>(): ColBuilder<T> => makeCol<T>("json");
26
39
 
27
40
  /** Metadata key on a {@link TableDef} (a `unique symbol`, so no column name collides). */
28
41
  export const SCHEMA: unique symbol = Symbol("rindle.schema");
@@ -175,6 +188,118 @@ function addTableMeta(tables: Record<string, TableMeta>, m: TableMeta, caller: "
175
188
  tables[m.name] = m;
176
189
  }
177
190
 
191
+ // ----------------------------- refinement (narrowing generated column types) ---------------------
192
+ //
193
+ // SQL carries a column's KIND (string/number/boolean/json) but not a refinement WITHIN a kind — the
194
+ // element type of `json<T>()`, or a string/number literal union. `refineTable` re-types those
195
+ // columns on a generated `TableDef` (so its field conditions, `rel(...)`s, and `Row<typeof t>` all
196
+ // narrow), and `refineSchema` swaps the refined defs into the generated schema (so query roots and
197
+ // result rows narrow too). Both are runtime-validated IDENTITIES: a refinement narrows the phantom
198
+ // TS type but can never change a column's runtime kind, so the def still matches the daemon's wire
199
+ // schema exactly. Keep refinements in a hand-written module (beside `extendSchema`'s local tables);
200
+ // `schema.gen.ts` stays a pure, wholesale-regenerated artifact.
201
+
202
+ /** Per-column narrowings for {@link refineTable}: each entry must keep the column's kind and narrow
203
+ * its TS type (`Col<T2>` with `T2 extends T`) — `json<Meta>()` on a json column, a literal-union
204
+ * `string<"a" | "b">()` on a string column. Kind changes are rejected (cross-kind at compile time,
205
+ * same-phantom kind flips like `string()` on a json column at runtime). */
206
+ export type ColRefinements<C extends AnyCols> = {
207
+ readonly [K in keyof C]?: Col<ColT<C[K]>>;
208
+ };
209
+
210
+ /** `C` with the columns named in `R` re-typed to their refined `Col`s. */
211
+ export type RefinedCols<C extends AnyCols, R extends ColRefinements<C>> = {
212
+ [K in keyof C]: K extends keyof R ? (R[K] extends Col<unknown> ? R[K] : C[K]) : C[K];
213
+ };
214
+
215
+ /** Narrow a generated table's column TYPES without touching its runtime shape.
216
+ *
217
+ * Returns the SAME def, re-typed (identity, after validating that every refined column exists and
218
+ * keeps its kind) — so conditions built from it, `rel(...)`s anchored on it, and `Row<typeof t>`
219
+ * all see the narrowed types. Pass the result to {@link refineSchema} so query roots narrow too. */
220
+ export function refineTable<N extends string, C extends AnyCols, R extends ColRefinements<C>>(
221
+ base: TableDef<N, C>,
222
+ cols: R,
223
+ ): TableDef<N, RefinedCols<C, R>> {
224
+ const meta = base[SCHEMA];
225
+ for (const [name, col] of Object.entries(cols) as [string, Col<unknown> | undefined][]) {
226
+ if (col === undefined) continue;
227
+ const baseCol = meta.columns[name];
228
+ if (baseCol === undefined) {
229
+ throw new Error(`refineTable: table "${meta.name}" has no column "${name}".`);
230
+ }
231
+ if (col.type !== baseCol.type) {
232
+ throw new Error(
233
+ `refineTable: column "${meta.name}.${name}" is ${baseCol.type}, not ${col.type} — a refinement ` +
234
+ `may only narrow the TS type WITHIN a column's kind, never change the kind itself.`,
235
+ );
236
+ }
237
+ }
238
+ return base as unknown as TableDef<N, RefinedCols<C, R>>;
239
+ }
240
+
241
+ /** A table def acceptable to {@link refineSchema} over `Schema<S>`: its NAME must be one of `S`'s
242
+ * tables (a def for an unknown table is a compile error at the call site). */
243
+ export type RefinableTable<S extends ColsMap> = {
244
+ readonly [SCHEMA]: TableMeta<Extract<keyof S, string>, AnyCols>;
245
+ };
246
+
247
+ /** `S` with each table named in `T` re-typed to that def's (refined) columns. (The inner
248
+ * `extends AnyCols` guard is how the checker proves the remapped `SchemaOf<T>[K]` is a column
249
+ * map while `T` is still generic; it always holds for a concrete `T`.) */
250
+ export type RefinedColsMap<S extends ColsMap, T extends readonly AnyTable[]> = {
251
+ [K in keyof S]: K extends keyof SchemaOf<T> ? (SchemaOf<T>[K] extends AnyCols ? SchemaOf<T>[K] : S[K]) : S[K];
252
+ };
253
+
254
+ /** Swap {@link refineTable}-narrowed table defs into a generated schema, re-typing those tables for
255
+ * everything downstream of the schema (`newQueryBuilder`/`queries` roots, store row types).
256
+ *
257
+ * Runtime-validated identity: each def must name a table already in the schema and match its
258
+ * runtime shape exactly (same columns, kinds, primary key, and locality) — refinement narrows TS
259
+ * types, never what's on the wire. Composes with {@link extendSchema} in either order. */
260
+ export function refineSchema<S extends ColsMap, const T extends readonly RefinableTable<S>[]>(
261
+ base: Schema<S>,
262
+ opts: { tables: T },
263
+ ): Schema<RefinedColsMap<S, T>> {
264
+ const seen = new Set<string>();
265
+ for (const t of opts.tables) {
266
+ const m = t[SCHEMA];
267
+ const baseMeta = base.tables[m.name];
268
+ if (baseMeta === undefined) {
269
+ throw new Error(
270
+ `refineSchema: schema has no table "${m.name}" — refinement re-types existing tables only ` +
271
+ `(add local-only tables with extendSchema).`,
272
+ );
273
+ }
274
+ if (seen.has(m.name)) {
275
+ throw new Error(`refineSchema: table "${m.name}" is refined twice.`);
276
+ }
277
+ seen.add(m.name);
278
+ assertRefinementMatches(m, baseMeta);
279
+ }
280
+ return base as unknown as Schema<RefinedColsMap<S, T>>;
281
+ }
282
+
283
+ /** The refined def must be the same table the schema already carries — identical column set, kinds,
284
+ * primary key, and locality — so swapping its TYPE in cannot change any runtime behavior. */
285
+ function assertRefinementMatches(m: TableMeta, baseMeta: TableMeta): void {
286
+ const cols = Object.keys(m.columns);
287
+ const baseCols = Object.keys(baseMeta.columns);
288
+ const matches =
289
+ cols.length === baseCols.length &&
290
+ cols.every((c) => baseMeta.columns[c] !== undefined && m.columns[c].type === baseMeta.columns[c].type) &&
291
+ m.primaryKey.length === baseMeta.primaryKey.length &&
292
+ m.primaryKey.every((k, i) => baseMeta.primaryKey[i] === k) &&
293
+ (m.local === true) === (baseMeta.local === true);
294
+ if (!matches) {
295
+ throw new Error(
296
+ `refineSchema: table "${m.name}" does not match the schema's table of that name — pass the ` +
297
+ `output of refineTable over the SAME generated def (identical columns, kinds, primary key, ` +
298
+ `and locality).`,
299
+ );
300
+ }
301
+ }
302
+
178
303
  /** Whether `table` is a {@link TableMeta.local local-only} table in `schema` (an unknown table
179
304
  * reads as non-local). The single locality predicate the backends key off. */
180
305
  export function isLocalTable<S extends ColsMap>(schema: Schema<S>, table: string): boolean {
package/src/ssr.ts CHANGED
@@ -111,6 +111,27 @@ export class ServerStore<S extends ColsMap> {
111
111
  dehydrate(): DehydratedState {
112
112
  return this.store.dehydrate();
113
113
  }
114
+
115
+ /**
116
+ * Loader-phase convenience over {@link preload} + {@link dehydrate}: preload EVERY query (reads run
117
+ * concurrently) and return the dehydrated first-paint cache. Composition keeps this to one read per
118
+ * composed root query — no request waterfall (SSR-DESIGN.md §6.2).
119
+ *
120
+ * A failed read degrades to NO seed for that one query — `onError` fires and the browser's live
121
+ * engine fills it in right after hydration — rather than rejecting the whole batch (which would trip
122
+ * the route's error boundary). The seed is a first-paint optimization; the live `subscribe` is the
123
+ * source of truth, so a missing seed never affects correctness. Without `onError` a failed read is
124
+ * swallowed silently — pass one to log.
125
+ */
126
+ async preloadAll(
127
+ queries: Array<Query<any, any, any>>,
128
+ opts: { onError?: (query: Query<any, any, any>, err: unknown) => void } = {},
129
+ ): Promise<DehydratedState> {
130
+ await Promise.all(
131
+ queries.map((query) => this.preload(query).catch((err) => opts.onError?.(query, err))),
132
+ );
133
+ return this.dehydrate();
134
+ }
114
135
  }
115
136
 
116
137
  /** Construct a {@link ServerStore} — the one-shot REST Store for server-side rendering. */