@rindle/client 0.1.6 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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";
@@ -20,18 +20,18 @@ import type { ArrayView, SingularArrayView } from "./view.ts";
20
20
  // (`data: R[]`) to a `SingularArrayView` (`data: R | null`). It threads through every chained
21
21
  // method so the unwrap survives `.one().where(…)`, etc. Defaults `false` (plural).
22
22
 
23
- type FieldFn<C extends AnyCols, K extends keyof C, Rels, One extends boolean, Sel extends string> = (
23
+ type FieldFn<C extends AnyCols, K extends keyof C, Rels, One extends boolean, Sel extends string, LocalRels> = (
24
24
  arg: Arg<ColT<C[K]>>,
25
- ) => Query<C, Rels, One, Sel>;
25
+ ) => Query<C, Rels, One, Sel, LocalRels>;
26
26
 
27
27
  /** `where.closed(false)`, `where.priority(gt(3))`. */
28
- type WhereProxy<C extends AnyCols, Rels, One extends boolean, Sel extends string> = {
29
- [K in keyof C]: FieldFn<C, K, Rels, One, Sel>;
28
+ type WhereProxy<C extends AnyCols, Rels, One extends boolean, Sel extends string, LocalRels> = {
29
+ [K in keyof C]: FieldFn<C, K, Rels, One, Sel, LocalRels>;
30
30
  };
31
31
 
32
32
  /** `whereClosed(false)`, `wherePriority(gt(3))`. */
33
- type WhereSugar<C extends AnyCols, Rels, One extends boolean, Sel extends string> = {
34
- [K in keyof C as `where${Capitalize<string & K>}`]: FieldFn<C, K, Rels, One, Sel>;
33
+ type WhereSugar<C extends AnyCols, Rels, One extends boolean, Sel extends string, LocalRels> = {
34
+ [K in keyof C as `where${Capitalize<string & K>}`]: FieldFn<C, K, Rels, One, Sel, LocalRels>;
35
35
  };
36
36
 
37
37
  /** What `materialize()` returns: singular (`R | null`) for a top-level `.one()`, else plural. */
@@ -49,27 +49,56 @@ 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
- interface QueryBase<C extends AnyCols, Rels, One extends boolean, Sel extends string> {
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
+
77
+ type FragmentEdge<C extends AnyCols, Rels, Sel extends string, LocalRels> = (
78
+ q: Query<C, Rels, false, Sel, LocalRels>,
79
+ ) => Query<C, Rels, boolean, Sel, LocalRels>;
80
+
81
+ interface QueryBase<C extends AnyCols, Rels, One extends boolean, Sel extends string, LocalRels, Agg = false> {
53
82
  /** Present when this local query came from `defineQuery`; used as the remote identity. */
54
83
  readonly name?: string;
55
84
  /** Present when this local query came from `defineQuery`; sent with `name` upstream. */
56
85
  readonly args?: unknown;
57
86
  /** Condition form — consumes `or()`/`and()`/`exists()`/field conditions (AND-ed across calls).
58
87
  * Filters over the FULL column set (`RowOf<C>`), independent of what's `select`ed/masked. */
59
- where(cond: Cond<RowOf<C>>): Query<C, Rels, One, Sel>;
60
- orderBy<K extends keyof C>(col: K, dir: Dir): Query<C, Rels, One, Sel>;
88
+ where(cond: Cond<RowOf<C>>): Query<C, Rels, One, Sel, LocalRels>;
89
+ orderBy<K extends keyof C>(col: K, dir: Dir): Query<C, Rels, One, Sel, LocalRels>;
61
90
  /** Project a subset of columns (PROJECTION-SUPPORT-DESIGN.md §6, masking §6/§10.6). Chainable
62
91
  * (each call unions into `Sel`); omit to select all. Drives what the server syncs, what the
63
92
  * view reports, AND — once any column is named — narrows the result row TYPE to exactly the
64
93
  * selection (masking). `where`/`orderBy`/`start`/`sub` still see the full column set. */
65
- select<K extends keyof C & string>(...cols: K[]): Query<C, Rels, One, Sel | K>;
66
- limit(n: number): Query<C, Rels, One, Sel>;
94
+ select<K extends keyof C & string>(...cols: K[]): Query<C, Rels, One, Sel | K, LocalRels>;
95
+ limit(n: number): Query<C, Rels, One, Sel, LocalRels>;
67
96
  /** Cursor paging: start at (or, with `exclusive`, after) the partial `cursor` row over the
68
97
  * sort columns. Lowers to a `Skip` in the engine. */
69
- start(cursor: Partial<RowOf<C>>, opts?: { exclusive?: boolean }): Query<C, Rels, One, Sel>;
98
+ start(cursor: Partial<RowOf<C>>, opts?: { exclusive?: boolean }): Query<C, Rels, One, Sel, LocalRels>;
70
99
  /** Return a single row: `materialize()` yields a {@link SingularArrayView} (`data: R | null`)
71
100
  * and the engine caps the query to `limit = 1`. */
72
- one(): Query<C, Rels, true, Sel>;
101
+ one(): Query<C, Rels, true, Sel, LocalRels>;
73
102
  /** **Merge** a {@link Fragment} into THIS node — Relay's "spread on the same type" (§5). The
74
103
  * fragment must be over the same table (enforced in the result type: a fragment over a different
75
104
  * table yields `never`, plus a runtime throw). Unions the projection (`Sel | FSel`), folds in the
@@ -84,9 +113,90 @@ interface QueryBase<C extends AnyCols, Rels, One extends boolean, Sel extends st
84
113
  * `include` keeps `C` out of any contravariant position — preserving `Query<Concrete>`'s
85
114
  * assignability to `Query<AnyCols>` (needed by `Store<ColsMap>`/`QueryRoot`). Same-table is then
86
115
  * checked as the mutual-assignability of `C` and `FC` in the return type. */
87
- include<FC extends AnyCols, FRels = {}, FSel extends string = never>(
88
- fragment: Fragment<FC, FRels, FSel>,
89
- ): [C] extends [FC] ? ([FC] extends [C] ? Query<C, Rels & FRels, One, Sel | FSel> : never) : never;
116
+ include<FC extends AnyCols, FRels = {}, FSel extends string = never, FLocalRels = FRels>(
117
+ fragment: Fragment<FC, FRels, FSel, FLocalRels>,
118
+ ): [C] extends [FC] ? ([FC] extends [C] ? Query<C, Rels & FRels, One, Sel | FSel, LocalRels & FLocalRels> : never) : never;
119
+ /** Nest a child fragment as an opaque local-read ref. The coverage query still composes the
120
+ * child's full AST at runtime; the React-facing fragment data exposes only refs at this
121
+ * boundary so the child component owns its local subscription. */
122
+ sub<
123
+ A extends string,
124
+ CC extends AnyCols,
125
+ CRels = {},
126
+ CSel extends string = never,
127
+ CLocalRels = CRels,
128
+ F extends Fragment<CC, CRels, CSel, CLocalRels> = Fragment<CC, CRels, CSel, CLocalRels>,
129
+ >(
130
+ alias: A,
131
+ child: TableLike<CC>,
132
+ corr: { parent: Array<keyof C & string>; child: Array<keyof CC & string> },
133
+ build: F,
134
+ edge: FragmentEdge<CC, CRels, CSel, CLocalRels>,
135
+ ): Query<
136
+ C,
137
+ Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> },
138
+ One,
139
+ Sel,
140
+ LocalRels & { [P in A]: Array<FragmentRef<F>> }
141
+ >;
142
+ sub<
143
+ A extends string,
144
+ CC extends AnyCols,
145
+ CRels = {},
146
+ CSel extends string = never,
147
+ CLocalRels = CRels,
148
+ F extends Fragment<CC, CRels, CSel, CLocalRels> = Fragment<CC, CRels, CSel, CLocalRels>,
149
+ >(
150
+ alias: A,
151
+ child: TableLike<CC>,
152
+ corr: { parent: Array<keyof C & string>; child: Array<keyof CC & string> },
153
+ build: F,
154
+ ): Query<
155
+ C,
156
+ Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> },
157
+ One,
158
+ Sel,
159
+ LocalRels & { [P in A]: Array<FragmentRef<F>> }
160
+ >;
161
+ /** Relationship-value form of the fragment-ref overload above. */
162
+ sub<
163
+ A extends string,
164
+ CC extends AnyCols,
165
+ CRels = {},
166
+ CSel extends string = never,
167
+ CLocalRels = CRels,
168
+ F extends Fragment<CC, CRels, CSel, CLocalRels> = Fragment<CC, CRels, CSel, CLocalRels>,
169
+ >(
170
+ alias: A,
171
+ relationship: Relationship<C, CC>,
172
+ build: F,
173
+ edge: FragmentEdge<CC, CRels, CSel, CLocalRels>,
174
+ ): Query<
175
+ C,
176
+ Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> },
177
+ One,
178
+ Sel,
179
+ LocalRels & { [P in A]: Array<FragmentRef<F>> }
180
+ >;
181
+ /** Relationship-value form of the fragment-ref overload above. */
182
+ sub<
183
+ A extends string,
184
+ CC extends AnyCols,
185
+ CRels = {},
186
+ CSel extends string = never,
187
+ CLocalRels = CRels,
188
+ F extends Fragment<CC, CRels, CSel, CLocalRels> = Fragment<CC, CRels, CSel, CLocalRels>,
189
+ >(
190
+ alias: A,
191
+ relationship: Relationship<C, CC>,
192
+ build: F,
193
+ ): Query<
194
+ C,
195
+ Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> },
196
+ One,
197
+ Sel,
198
+ LocalRels & { [P in A]: Array<FragmentRef<F>> }
199
+ >;
90
200
  /** Nest a child by EXPLICIT correlation (no schema relationship). `alias` is the result key.
91
201
  * The child's own projection (`CSel`) masks the nested row, so a fragment spread here
92
202
  * contributes exactly the columns it declared. */
@@ -95,7 +205,13 @@ interface QueryBase<C extends AnyCols, Rels, One extends boolean, Sel extends st
95
205
  child: TableLike<CC>,
96
206
  corr: { parent: Array<keyof C & string>; child: Array<keyof CC & string> },
97
207
  build?: (q: Query<CC>) => Query<CC, CRels, boolean, CSel>,
98
- ): Query<C, Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> }, One, Sel>;
208
+ ): Query<
209
+ C,
210
+ Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> },
211
+ One,
212
+ Sel,
213
+ LocalRels & { [P in A]: Array<Projected<CC, CSel> & CRels> }
214
+ >;
99
215
  /** Nest a child by a named {@link Relationship} (`rel(parent, child, {...})`) — the correlation comes
100
216
  * from the relationship, so no `{ parent, child }` keys are restated. The relationship must belong to
101
217
  * THIS table (its parent columns are checked against `C`). `alias` is still the result key. */
@@ -103,7 +219,13 @@ interface QueryBase<C extends AnyCols, Rels, One extends boolean, Sel extends st
103
219
  alias: A,
104
220
  relationship: Relationship<C, CC>,
105
221
  build?: (q: Query<CC>) => Query<CC, CRels, boolean, CSel>,
106
- ): Query<C, Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> }, One, Sel>;
222
+ ): Query<
223
+ C,
224
+ Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> },
225
+ One,
226
+ Sel,
227
+ LocalRels & { [P in A]: Array<Projected<CC, CSel> & CRels> }
228
+ >;
107
229
  /** Add a **relationship aggregate** — `issue.countAs("commentCount", comment, …)`
108
230
  * (`REDUCE-DESIGN.md` §9). Like {@link sub} (explicit correlation, optional child
109
231
  * `build` for a filtered `count(child WHERE …)`), but the relationship surfaces a single
@@ -114,28 +236,81 @@ interface QueryBase<C extends AnyCols, Rels, One extends boolean, Sel extends st
114
236
  child: TableLike<CC>,
115
237
  corr: { parent: Array<keyof C & string>; child: Array<keyof CC & string> },
116
238
  build?: (q: Query<CC>) => Query<CC, unknown>,
117
- ): Query<C, Rels & { [P in A]: number }, One, Sel>;
239
+ ): Query<C, Rels & { [P in A]: number }, One, Sel, LocalRels & { [P in A]: number }>;
118
240
  /** `countAs` by a named {@link Relationship} — like the explicit form, but the correlation comes from
119
241
  * `rel(...)` instead of being restated. */
120
242
  countAs<A extends string, CC extends AnyCols>(
121
243
  alias: A,
122
244
  relationship: Relationship<C, CC>,
123
245
  build?: (q: Query<CC>) => Query<CC, unknown>,
124
- ): Query<C, Rels & { [P in A]: number }, One, Sel>;
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>;
125
283
  /** The compiled Zero-wire AST (what a backend's `query` consumes). */
126
284
  ast(): Ast;
127
285
  /** Materialize into a live, typed view. Wired by the Store/backend. A top-level `.one()`
128
- * yields a {@link SingularArrayView}; otherwise an {@link ArrayView}. The row is masked to the
129
- * projection ({@link Projected}) full {@link RowOf} until a column is `select`ed. */
130
- 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>;
131
290
  }
132
291
 
133
- export type Query<C extends AnyCols, Rels = {}, One extends boolean = false, Sel extends string = never> =
134
- & Omit<QueryBase<C, Rels, One, Sel>, "where">
135
- & { where: QueryBase<C, Rels, One, Sel>["where"] & WhereProxy<C, Rels, One, Sel> }
136
- & WhereSugar<C, Rels, One, Sel>;
292
+ export type Query<
293
+ C extends AnyCols,
294
+ Rels = {},
295
+ One extends boolean = false,
296
+ Sel extends string = never,
297
+ LocalRels = Rels,
298
+ Agg = false,
299
+ > =
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> }
302
+ & WhereSugar<C, Rels, One, Sel, LocalRels>;
137
303
 
138
- type AnyQuery = Query<any, any, any>;
304
+ export type AnyQuery = Query<any, any, any, any, any, any>;
305
+
306
+ export type QueryLocalData<Q extends AnyQuery> =
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[]
313
+ : never;
139
314
  const STAMP_NAMED_QUERY: unique symbol = Symbol("rindle.stampNamedQuery");
140
315
 
141
316
  interface QueryInternals {
@@ -259,6 +434,8 @@ export function defineQuery(
259
434
  // ----------------------------- fragments (FRAGMENT-COMPOSITION-DESIGN.md, Phase 0) -----------------------------
260
435
 
261
436
  const FRAGMENT_BRAND: unique symbol = Symbol("rindle.fragment");
437
+ const LOCAL_FRAGMENT_REF_BRAND: unique symbol = Symbol("rindle.localFragmentRef");
438
+ const FRAGMENT_REL_BRAND: unique symbol = Symbol("rindle.fragmentRelationship");
262
439
 
263
440
  /**
264
441
  * A reusable, typed *selection over a table* — Relay's "fragment", promoted to a first-class
@@ -276,22 +453,38 @@ const FRAGMENT_BRAND: unique symbol = Symbol("rindle.fragment");
276
453
  * fragments assembles ONE {@link Ast} → one materialization → one `/query` — the whole point
277
454
  * (no request waterfall; design §1).
278
455
  */
279
- export interface Fragment<C extends AnyCols, Rels = {}, Sel extends string = never> {
280
- (q: Query<C>): Query<C, Rels, false, Sel>;
456
+ export interface Fragment<C extends AnyCols, Rels = {}, Sel extends string = never, LocalRels = Rels> {
457
+ (q: Query<C>): Query<C, Rels, false, Sel, LocalRels>;
281
458
  readonly table: TableLike<C>;
282
459
  readonly [FRAGMENT_BRAND]: true;
283
460
  }
284
461
 
285
462
  /**
286
- * The "fragment ref" a parent passes down for a {@link Fragment} read: the already-projected
287
- * node(s) at the fragment's alias in the shared root view — i.e. the fragment's result shape.
288
- * It is **masked** ({@link Projected}) to the fragment's own `select` a fragment reads only the
289
- * columns it declared. A fragment with no `select` stays the full `RowOf<C> & Rels` (§10.6).
463
+ * The data returned by `useFragment(fragment, ref)`: the fragment's own selected columns plus
464
+ * immediate relationship values. Relationships whose builder is another fragment surface as
465
+ * opaque {@link FragmentRef}s, so child-owned payload is read only by the child fragment reader.
290
466
  */
291
- export type FragmentRef<F> = F extends Fragment<infer C, infer Rels, infer Sel>
292
- ? Projected<C, Sel> & Rels
467
+ export type FragmentData<F> = F extends Fragment<infer C, unknown, infer Sel, infer LocalRels>
468
+ ? Projected<C, Sel> & LocalRels
293
469
  : never;
294
470
 
471
+ export interface FragmentCoverage<Q extends AnyQuery = AnyQuery> {
472
+ readonly key: string;
473
+ readonly query: Q;
474
+ }
475
+
476
+ export interface LocalFragmentRef<F extends Fragment<any, any, any, any> = Fragment<any, any, any, any>> {
477
+ readonly [LOCAL_FRAGMENT_REF_BRAND]: true;
478
+ readonly source: "local";
479
+ readonly table: string;
480
+ readonly pk: Readonly<Record<string, LitValue>>;
481
+ readonly coverage: FragmentCoverage;
482
+ readonly __fragment?: F;
483
+ }
484
+
485
+ /** The opaque token a parent passes to a component that reads {@link FragmentData} for `F`. */
486
+ export type FragmentRef<F> = F extends Fragment<any, any, any, any> ? LocalFragmentRef<F> : never;
487
+
295
488
  /**
296
489
  * Define a co-located, composable {@link Fragment}: a named selection over `table`. The returned
297
490
  * value is callable (the `build` transform), so it threads through the existing `sub`/`Rels`
@@ -304,17 +497,192 @@ export type FragmentRef<F> = F extends Fragment<infer C, infer Rels, infer Sel>
304
497
  * .sub("author", schema.user, { parent: ["authorId"], child: ["id"] }, UserAvatar));
305
498
  * ```
306
499
  */
307
- export function defineFragment<C extends AnyCols, Rels = {}, Sel extends string = never>(
500
+ export function defineFragment<C extends AnyCols, Rels = {}, Sel extends string = never, LocalRels = Rels>(
308
501
  table: TableLike<C>,
309
- build: (q: Query<C>) => Query<C, Rels, boolean, Sel>,
310
- ): Fragment<C, Rels, Sel> {
311
- const frag = (q: Query<C>): Query<C, Rels, false, Sel> => build(q) as Query<C, Rels, false, Sel>;
312
- return Object.assign(frag, { table, [FRAGMENT_BRAND]: true as const }) as unknown as Fragment<C, Rels, Sel>;
502
+ build: (q: Query<C>) => Query<C, Rels, boolean, Sel, LocalRels>,
503
+ ): Fragment<C, Rels, Sel, LocalRels> {
504
+ const frag = (q: Query<C>): Query<C, Rels, false, Sel, LocalRels> => build(q) as Query<C, Rels, false, Sel, LocalRels>;
505
+ return Object.assign(frag, { table, [FRAGMENT_BRAND]: true as const }) as unknown as Fragment<C, Rels, Sel, LocalRels>;
313
506
  }
314
507
 
315
508
  /** Runtime guard: is `v` a {@link Fragment} (a `defineFragment` value, not a plain build fn)? */
316
- export function isFragment(v: unknown): v is Fragment<AnyCols, unknown> {
317
- return typeof v === "function" && (v as Partial<Fragment<AnyCols>>)[FRAGMENT_BRAND] === true;
509
+ export function isFragment(v: unknown): v is Fragment<AnyCols, unknown, never, unknown> {
510
+ return typeof v === "function" && (v as Partial<Fragment<AnyCols, unknown, never, unknown>>)[FRAGMENT_BRAND] === true;
511
+ }
512
+
513
+ function isLocalFragmentRef(v: unknown): v is LocalFragmentRef {
514
+ return typeof v === "object" && v !== null && (v as Partial<LocalFragmentRef>)[LOCAL_FRAGMENT_REF_BRAND] === true;
515
+ }
516
+
517
+ type FragmentRelationship = CorrelatedSubquery & { [FRAGMENT_REL_BRAND]?: true };
518
+
519
+ function markFragmentRelationship<T extends CorrelatedSubquery>(rel: T): T {
520
+ Object.defineProperty(rel, FRAGMENT_REL_BRAND, { value: true });
521
+ return rel;
522
+ }
523
+
524
+ export function isFragmentRelationship(rel: CorrelatedSubquery): boolean {
525
+ return (rel as FragmentRelationship)[FRAGMENT_REL_BRAND] === true;
526
+ }
527
+
528
+ export function fragmentKey(ref: FragmentRef<any>): string {
529
+ if (!isLocalFragmentRef(ref)) throw new Error("fragmentKey(): expected an opaque fragment ref.");
530
+ return stableKey({ table: ref.table, pk: ref.pk });
531
+ }
532
+
533
+ export function createLocalFragmentRef<F extends Fragment<any, any, any>>(
534
+ fragment: F,
535
+ pk: Record<string, LitValue>,
536
+ coverage: FragmentCoverage,
537
+ ): LocalFragmentRef<F> {
538
+ return createLocalFragmentRefForTable(fragment.table[SCHEMA].name, pk, coverage);
539
+ }
540
+
541
+ export function createLocalFragmentRefForTable<F extends Fragment<any, any, any>>(
542
+ table: string,
543
+ pk: Record<string, LitValue>,
544
+ coverage: FragmentCoverage,
545
+ ): LocalFragmentRef<F> {
546
+ return {
547
+ [LOCAL_FRAGMENT_REF_BRAND]: true,
548
+ source: "local",
549
+ table,
550
+ pk: { ...pk },
551
+ coverage,
552
+ };
553
+ }
554
+
555
+ export function createRootFragmentRef<F extends Fragment<any, any, any>, Q extends AnyQuery>(
556
+ fragment: F,
557
+ query: Q,
558
+ coverageKey = stableKey({
559
+ ast: query.ast(),
560
+ remote: typeof query.name === "string" ? { name: query.name, args: query.args } : null,
561
+ }),
562
+ ): LocalFragmentRef<F> {
563
+ const ast = query.ast();
564
+ const table = fragment.table[SCHEMA];
565
+ if (ast.table !== table.name) {
566
+ throw new Error(
567
+ `useRoot(): the coverage query is over "${ast.table}" but the fragment is over "${table.name}".`,
568
+ );
569
+ }
570
+ const pk = primaryKeyFromWhere(ast, table.primaryKey);
571
+ return createLocalFragmentRef(fragment, pk, { key: coverageKey, query });
572
+ }
573
+
574
+ export function fragmentAst<F extends Fragment<any, any, any>>(fragment: F): Ast {
575
+ return childAst(fragment.table as AnyTable, fragment as unknown as (q: unknown) => unknown);
576
+ }
577
+
578
+ export function localFragmentReadAst<F extends Fragment<any, any, any>>(
579
+ fragment: F,
580
+ ref: LocalFragmentRef<F>,
581
+ primaryKeyFor: (table: string) => readonly string[],
582
+ ): Ast {
583
+ const table = fragment.table[SCHEMA].name;
584
+ if (ref.table !== table) {
585
+ throw new Error(`useFragment(): the ref is for "${ref.table}" but the fragment is over "${table}".`);
586
+ }
587
+ const ast = localizeFragmentAst(fragmentAst(fragment), primaryKeyFor);
588
+ ast.where = andConditions(pkConditions(ref.pk), ast.where);
589
+ ast.one = true;
590
+ return ast;
591
+ }
592
+
593
+ export function localRootFragmentRefsAst<F extends Fragment<any, any, any>>(
594
+ fragment: F,
595
+ query: AnyQuery,
596
+ primaryKeyFor: (table: string) => readonly string[],
597
+ ): Ast {
598
+ const ast = query.ast();
599
+ const table = fragment.table[SCHEMA].name;
600
+ if (ast.table !== table) {
601
+ throw new Error(
602
+ `useRoot(): the coverage query is over "${ast.table}" but the fragment is over "${table}".`,
603
+ );
604
+ }
605
+ const out: Ast = { ...ast, select: uniqSort([...primaryKeyFor(table)]) };
606
+ delete out.related;
607
+ return out;
608
+ }
609
+
610
+ export function localQueryReadAst(
611
+ query: AnyQuery,
612
+ primaryKeyFor: (table: string) => readonly string[],
613
+ ): Ast {
614
+ return localizeFragmentAst(query.ast(), primaryKeyFor);
615
+ }
616
+
617
+ export function queryFromAst(ast: Ast): AnyQuery {
618
+ return {
619
+ ast: () => ast,
620
+ materialize: () => {
621
+ throw new Error("queryFromAst(): materialize() requires a Store; pass this query through Store/React.");
622
+ },
623
+ } as unknown as AnyQuery;
624
+ }
625
+
626
+ function primaryKeyFromWhere(ast: Ast, primaryKey: readonly string[]): Record<string, LitValue> {
627
+ const found = new Map<string, LitValue>();
628
+ collectLiteralEquals(ast.where, found);
629
+ const pk: Record<string, LitValue> = {};
630
+ for (const col of primaryKey) {
631
+ if (!found.has(col)) {
632
+ throw new Error(
633
+ `useRoot(): the coverage query must constrain primary key column "${col}" with a literal equality.`,
634
+ );
635
+ }
636
+ pk[col] = found.get(col)!;
637
+ }
638
+ return pk;
639
+ }
640
+
641
+ function collectLiteralEquals(cond: Condition | undefined, out: Map<string, LitValue>): void {
642
+ if (!cond) return;
643
+ if (cond.type === "and") {
644
+ for (const c of cond.conditions) collectLiteralEquals(c, out);
645
+ return;
646
+ }
647
+ if (cond.type !== "simple" || cond.op !== "=") return;
648
+ const leftCol = cond.left.type === "column" ? cond.left.name : undefined;
649
+ const rightCol = cond.right.type === "column" ? cond.right.name : undefined;
650
+ if (leftCol !== undefined && cond.right.type === "literal") out.set(leftCol, cond.right.value);
651
+ else if (rightCol !== undefined && cond.left.type === "literal") out.set(rightCol, cond.left.value);
652
+ }
653
+
654
+ function pkConditions(pk: Readonly<Record<string, LitValue>>): Condition[] {
655
+ return Object.keys(pk)
656
+ .sort()
657
+ .map((name) => fieldCondition(name, pk[name]));
658
+ }
659
+
660
+ function andConditions(conditions: Condition[], tail: Condition | undefined): Condition | undefined {
661
+ const all = tail ? [...conditions, tail] : conditions;
662
+ if (all.length === 0) return undefined;
663
+ if (all.length === 1) return all[0];
664
+ return { type: "and", conditions: all };
665
+ }
666
+
667
+ function localizeFragmentAst(ast: Ast, primaryKeyFor: (table: string) => readonly string[]): Ast {
668
+ const out: Ast = { ...ast };
669
+ if (ast.select !== undefined) out.select = uniqSort([...ast.select, ...primaryKeyFor(ast.table)]);
670
+ if (ast.related !== undefined) {
671
+ out.related = ast.related.map((rel) => {
672
+ if (rel.subquery.aggregate !== undefined) return rel;
673
+ if (!isFragmentRelationship(rel)) {
674
+ return { ...rel, subquery: localizeFragmentAst(rel.subquery, primaryKeyFor) };
675
+ }
676
+ const childPk = primaryKeyFor(rel.subquery.table);
677
+ const subquery: Ast = {
678
+ ...rel.subquery,
679
+ select: uniqSort([...childPk]),
680
+ };
681
+ delete subquery.related;
682
+ return markFragmentRelationship({ ...rel, subquery });
683
+ });
684
+ }
685
+ return out;
318
686
  }
319
687
 
320
688
  // ----------------------------- the runtime builder -----------------------------
@@ -329,6 +697,12 @@ interface State {
329
697
  limit?: number;
330
698
  one: boolean;
331
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;
332
706
  }
333
707
 
334
708
  interface NamedQueryState {
@@ -337,7 +711,7 @@ interface NamedQueryState {
337
711
  }
338
712
 
339
713
  function emptyState(table: string): State {
340
- return { table, wheres: [], orderBy: [], related: [], one: false };
714
+ return { table, wheres: [], orderBy: [], related: [], one: false, groupBy: [] };
341
715
  }
342
716
 
343
717
  function compile(s: State): Ast {
@@ -351,6 +725,18 @@ function compile(s: State): Ast {
351
725
  if (s.limit !== undefined) ast.limit = s.limit;
352
726
  if (s.one) ast.one = true;
353
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
+ }
354
740
  return ast;
355
741
  }
356
742
 
@@ -401,22 +787,42 @@ interface ResolvedCorrelated {
401
787
  child: AnyTable;
402
788
  corr: { parent: string[]; child: string[] };
403
789
  build: ((q: unknown) => unknown) | undefined;
790
+ fragment: boolean;
791
+ }
792
+
793
+ function composeCorrelatedBuild(
794
+ build: ((q: unknown) => unknown) | undefined,
795
+ edge: ((q: unknown) => unknown) | undefined,
796
+ ): ((q: unknown) => unknown) | undefined {
797
+ if (edge === undefined) return build;
798
+ if (!isFragment(build)) {
799
+ throw new Error("sub(): an edge callback is only supported when the child build is a Fragment.");
800
+ }
801
+ return (q: unknown) => edge(build(q as Query<AnyCols>));
404
802
  }
405
803
 
406
804
  /** Resolve the two `sub`/`countAs`/`exists` call shapes to a common `{ child, corr, build }`:
407
805
  * either a named {@link Relationship} (`(rel, build?)`) or an explicit `(child, corr, build?)`. */
408
- function resolveCorrelated(a: AnyTable | AnyRelationship, b: unknown, c: unknown): ResolvedCorrelated {
806
+ function resolveCorrelated(a: AnyTable | AnyRelationship, b: unknown, c: unknown, d?: unknown): ResolvedCorrelated {
409
807
  if (isRelationship(a)) {
410
808
  return {
411
809
  child: a.child as AnyTable,
412
810
  corr: { parent: [...a.correlation.parent], child: [...a.correlation.child] },
413
- build: b as ((q: unknown) => unknown) | undefined,
811
+ build: composeCorrelatedBuild(
812
+ b as ((q: unknown) => unknown) | undefined,
813
+ c as ((q: unknown) => unknown) | undefined,
814
+ ),
815
+ fragment: isFragment(b),
414
816
  };
415
817
  }
416
818
  return {
417
819
  child: a,
418
820
  corr: b as { parent: string[]; child: string[] },
419
- build: c as ((q: unknown) => unknown) | undefined,
821
+ build: composeCorrelatedBuild(
822
+ c as ((q: unknown) => unknown) | undefined,
823
+ d as ((q: unknown) => unknown) | undefined,
824
+ ),
825
+ fragment: isFragment(c),
420
826
  };
421
827
  }
422
828
 
@@ -507,7 +913,8 @@ function mergeRelatedLists(base: CorrelatedSubquery[], add: CorrelatedSubquery[]
507
913
  `or align them (FRAGMENT-COMPOSITION-DESIGN §10.2).`,
508
914
  );
509
915
  }
510
- byAlias.set(alias, { ...existing, subquery: mergeSubqueryAst(existing.subquery, csq.subquery) });
916
+ const merged = { ...existing, subquery: mergeSubqueryAst(existing.subquery, csq.subquery) };
917
+ byAlias.set(alias, isFragmentRelationship(existing) || isFragmentRelationship(csq) ? markFragmentRelationship(merged) : merged);
511
918
  }
512
919
  return [...byAlias.values()].sort((a, b) => {
513
920
  const x = aliasOf(a);
@@ -576,15 +983,15 @@ function makeQuery(
576
983
  const fragAst = childAst(fragTable as AnyTable, fragment as unknown as (q: unknown) => unknown);
577
984
  return makeQuery(meta, mergeIncludedFragment(s, fragAst), onMat, named, guardAst);
578
985
  },
579
- sub: (alias: string, childOrRel: AnyTable | AnyRelationship, b?: unknown, c?: unknown) => {
580
- const { child, corr, build } = resolveCorrelated(childOrRel, b, c);
986
+ sub: (alias: string, childOrRel: AnyTable | AnyRelationship, b?: unknown, c?: unknown, d?: unknown) => {
987
+ const { child, corr, build, fragment } = resolveCorrelated(childOrRel, b, c, d);
581
988
  const sub = childAst(child, build);
582
989
  sub.alias = alias;
583
990
  const csq: CorrelatedSubquery = {
584
991
  correlation: { parentField: corr.parent, childField: corr.child },
585
992
  subquery: sub,
586
993
  };
587
- return next({ related: [...s.related, csq] });
994
+ return next({ related: [...s.related, fragment ? markFragmentRelationship(csq) : csq] });
588
995
  },
589
996
  countAs: (alias: string, childOrRel: AnyTable | AnyRelationship, b?: unknown, c?: unknown) => {
590
997
  // Same correlated-child mechanism as `sub`, but mark the child a `count` aggregate:
@@ -599,6 +1006,52 @@ function makeQuery(
599
1006
  };
600
1007
  return next({ related: [...s.related, csq] });
601
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
+ },
602
1055
  ast: () => {
603
1056
  const a = compile(s);
604
1057
  guardAst?.(a);