@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.
- package/dist/ast.d.ts +11 -0
- package/dist/ast.d.ts.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/mutation-ops.d.ts +140 -0
- package/dist/mutation-ops.d.ts.map +1 -0
- package/dist/mutation-ops.js +60 -0
- package/dist/mutation-ops.js.map +1 -0
- package/dist/operators.d.ts +5 -0
- package/dist/operators.d.ts.map +1 -1
- package/dist/operators.js +5 -0
- package/dist/operators.js.map +1 -1
- package/dist/query.d.ts +65 -9
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +56 -1
- package/dist/query.js.map +1 -1
- package/dist/resolve.d.ts +40 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +109 -0
- package/dist/resolve.js.map +1 -0
- package/dist/schema.d.ts +53 -5
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +65 -4
- package/dist/schema.js.map +1 -1
- package/dist/ssr.d.ts +14 -0
- package/dist/ssr.d.ts.map +1 -1
- package/dist/ssr.js +15 -0
- package/dist/ssr.js.map +1 -1
- package/dist/store.d.ts +47 -15
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +163 -25
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +37 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/view.d.ts +43 -5
- package/dist/view.d.ts.map +1 -1
- package/dist/view.js +87 -22
- package/dist/view.js.map +1 -1
- package/package.json +1 -1
- package/src/ast.ts +11 -0
- package/src/index.ts +29 -1
- package/src/mutation-ops.ts +190 -0
- package/src/operators.ts +5 -0
- package/src/query.ts +142 -13
- package/src/resolve.ts +136 -0
- package/src/schema.ts +130 -5
- package/src/ssr.ts +21 -0
- package/src/store.ts +157 -34
- package/src/types.ts +35 -1
- 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
|
|
226
|
-
*
|
|
227
|
-
|
|
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
|
-
?
|
|
246
|
-
?
|
|
247
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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. */
|