@rindle/client 0.1.5 → 0.2.0

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
@@ -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,31 @@ 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
+ type FragmentEdge<C extends AnyCols, Rels, Sel extends string, LocalRels> = (
53
+ q: Query<C, Rels, false, Sel, LocalRels>,
54
+ ) => Query<C, Rels, boolean, Sel, LocalRels>;
55
+
56
+ interface QueryBase<C extends AnyCols, Rels, One extends boolean, Sel extends string, LocalRels> {
53
57
  /** Present when this local query came from `defineQuery`; used as the remote identity. */
54
58
  readonly name?: string;
55
59
  /** Present when this local query came from `defineQuery`; sent with `name` upstream. */
56
60
  readonly args?: unknown;
57
61
  /** Condition form — consumes `or()`/`and()`/`exists()`/field conditions (AND-ed across calls).
58
62
  * 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>;
63
+ where(cond: Cond<RowOf<C>>): Query<C, Rels, One, Sel, LocalRels>;
64
+ orderBy<K extends keyof C>(col: K, dir: Dir): Query<C, Rels, One, Sel, LocalRels>;
61
65
  /** Project a subset of columns (PROJECTION-SUPPORT-DESIGN.md §6, masking §6/§10.6). Chainable
62
66
  * (each call unions into `Sel`); omit to select all. Drives what the server syncs, what the
63
67
  * view reports, AND — once any column is named — narrows the result row TYPE to exactly the
64
68
  * 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>;
69
+ select<K extends keyof C & string>(...cols: K[]): Query<C, Rels, One, Sel | K, LocalRels>;
70
+ limit(n: number): Query<C, Rels, One, Sel, LocalRels>;
67
71
  /** Cursor paging: start at (or, with `exclusive`, after) the partial `cursor` row over the
68
72
  * sort columns. Lowers to a `Skip` in the engine. */
69
- start(cursor: Partial<RowOf<C>>, opts?: { exclusive?: boolean }): Query<C, Rels, One, Sel>;
73
+ start(cursor: Partial<RowOf<C>>, opts?: { exclusive?: boolean }): Query<C, Rels, One, Sel, LocalRels>;
70
74
  /** Return a single row: `materialize()` yields a {@link SingularArrayView} (`data: R | null`)
71
75
  * and the engine caps the query to `limit = 1`. */
72
- one(): Query<C, Rels, true, Sel>;
76
+ one(): Query<C, Rels, true, Sel, LocalRels>;
73
77
  /** **Merge** a {@link Fragment} into THIS node — Relay's "spread on the same type" (§5). The
74
78
  * fragment must be over the same table (enforced in the result type: a fragment over a different
75
79
  * table yields `never`, plus a runtime throw). Unions the projection (`Sel | FSel`), folds in the
@@ -84,9 +88,90 @@ interface QueryBase<C extends AnyCols, Rels, One extends boolean, Sel extends st
84
88
  * `include` keeps `C` out of any contravariant position — preserving `Query<Concrete>`'s
85
89
  * assignability to `Query<AnyCols>` (needed by `Store<ColsMap>`/`QueryRoot`). Same-table is then
86
90
  * 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;
91
+ include<FC extends AnyCols, FRels = {}, FSel extends string = never, FLocalRels = FRels>(
92
+ fragment: Fragment<FC, FRels, FSel, FLocalRels>,
93
+ ): [C] extends [FC] ? ([FC] extends [C] ? Query<C, Rels & FRels, One, Sel | FSel, LocalRels & FLocalRels> : never) : never;
94
+ /** Nest a child fragment as an opaque local-read ref. The coverage query still composes the
95
+ * child's full AST at runtime; the React-facing fragment data exposes only refs at this
96
+ * boundary so the child component owns its local subscription. */
97
+ sub<
98
+ A extends string,
99
+ CC extends AnyCols,
100
+ CRels = {},
101
+ CSel extends string = never,
102
+ CLocalRels = CRels,
103
+ F extends Fragment<CC, CRels, CSel, CLocalRels> = Fragment<CC, CRels, CSel, CLocalRels>,
104
+ >(
105
+ alias: A,
106
+ child: TableLike<CC>,
107
+ corr: { parent: Array<keyof C & string>; child: Array<keyof CC & string> },
108
+ build: F,
109
+ edge: FragmentEdge<CC, CRels, CSel, CLocalRels>,
110
+ ): Query<
111
+ C,
112
+ Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> },
113
+ One,
114
+ Sel,
115
+ LocalRels & { [P in A]: Array<FragmentRef<F>> }
116
+ >;
117
+ sub<
118
+ A extends string,
119
+ CC extends AnyCols,
120
+ CRels = {},
121
+ CSel extends string = never,
122
+ CLocalRels = CRels,
123
+ F extends Fragment<CC, CRels, CSel, CLocalRels> = Fragment<CC, CRels, CSel, CLocalRels>,
124
+ >(
125
+ alias: A,
126
+ child: TableLike<CC>,
127
+ corr: { parent: Array<keyof C & string>; child: Array<keyof CC & string> },
128
+ build: F,
129
+ ): Query<
130
+ C,
131
+ Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> },
132
+ One,
133
+ Sel,
134
+ LocalRels & { [P in A]: Array<FragmentRef<F>> }
135
+ >;
136
+ /** Relationship-value form of the fragment-ref overload above. */
137
+ sub<
138
+ A extends string,
139
+ CC extends AnyCols,
140
+ CRels = {},
141
+ CSel extends string = never,
142
+ CLocalRels = CRels,
143
+ F extends Fragment<CC, CRels, CSel, CLocalRels> = Fragment<CC, CRels, CSel, CLocalRels>,
144
+ >(
145
+ alias: A,
146
+ relationship: Relationship<C, CC>,
147
+ build: F,
148
+ edge: FragmentEdge<CC, CRels, CSel, CLocalRels>,
149
+ ): Query<
150
+ C,
151
+ Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> },
152
+ One,
153
+ Sel,
154
+ LocalRels & { [P in A]: Array<FragmentRef<F>> }
155
+ >;
156
+ /** Relationship-value form of the fragment-ref overload above. */
157
+ sub<
158
+ A extends string,
159
+ CC extends AnyCols,
160
+ CRels = {},
161
+ CSel extends string = never,
162
+ CLocalRels = CRels,
163
+ F extends Fragment<CC, CRels, CSel, CLocalRels> = Fragment<CC, CRels, CSel, CLocalRels>,
164
+ >(
165
+ alias: A,
166
+ relationship: Relationship<C, CC>,
167
+ build: F,
168
+ ): Query<
169
+ C,
170
+ Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> },
171
+ One,
172
+ Sel,
173
+ LocalRels & { [P in A]: Array<FragmentRef<F>> }
174
+ >;
90
175
  /** Nest a child by EXPLICIT correlation (no schema relationship). `alias` is the result key.
91
176
  * The child's own projection (`CSel`) masks the nested row, so a fragment spread here
92
177
  * contributes exactly the columns it declared. */
@@ -95,7 +180,13 @@ interface QueryBase<C extends AnyCols, Rels, One extends boolean, Sel extends st
95
180
  child: TableLike<CC>,
96
181
  corr: { parent: Array<keyof C & string>; child: Array<keyof CC & string> },
97
182
  build?: (q: Query<CC>) => Query<CC, CRels, boolean, CSel>,
98
- ): Query<C, Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> }, One, Sel>;
183
+ ): Query<
184
+ C,
185
+ Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> },
186
+ One,
187
+ Sel,
188
+ LocalRels & { [P in A]: Array<Projected<CC, CSel> & CRels> }
189
+ >;
99
190
  /** Nest a child by a named {@link Relationship} (`rel(parent, child, {...})`) — the correlation comes
100
191
  * from the relationship, so no `{ parent, child }` keys are restated. The relationship must belong to
101
192
  * THIS table (its parent columns are checked against `C`). `alias` is still the result key. */
@@ -103,7 +194,13 @@ interface QueryBase<C extends AnyCols, Rels, One extends boolean, Sel extends st
103
194
  alias: A,
104
195
  relationship: Relationship<C, CC>,
105
196
  build?: (q: Query<CC>) => Query<CC, CRels, boolean, CSel>,
106
- ): Query<C, Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> }, One, Sel>;
197
+ ): Query<
198
+ C,
199
+ Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> },
200
+ One,
201
+ Sel,
202
+ LocalRels & { [P in A]: Array<Projected<CC, CSel> & CRels> }
203
+ >;
107
204
  /** Add a **relationship aggregate** — `issue.countAs("commentCount", comment, …)`
108
205
  * (`REDUCE-DESIGN.md` §9). Like {@link sub} (explicit correlation, optional child
109
206
  * `build` for a filtered `count(child WHERE …)`), but the relationship surfaces a single
@@ -114,14 +211,14 @@ interface QueryBase<C extends AnyCols, Rels, One extends boolean, Sel extends st
114
211
  child: TableLike<CC>,
115
212
  corr: { parent: Array<keyof C & string>; child: Array<keyof CC & string> },
116
213
  build?: (q: Query<CC>) => Query<CC, unknown>,
117
- ): Query<C, Rels & { [P in A]: number }, One, Sel>;
214
+ ): Query<C, Rels & { [P in A]: number }, One, Sel, LocalRels & { [P in A]: number }>;
118
215
  /** `countAs` by a named {@link Relationship} — like the explicit form, but the correlation comes from
119
216
  * `rel(...)` instead of being restated. */
120
217
  countAs<A extends string, CC extends AnyCols>(
121
218
  alias: A,
122
219
  relationship: Relationship<C, CC>,
123
220
  build?: (q: Query<CC>) => Query<CC, unknown>,
124
- ): Query<C, Rels & { [P in A]: number }, One, Sel>;
221
+ ): Query<C, Rels & { [P in A]: number }, One, Sel, LocalRels & { [P in A]: number }>;
125
222
  /** The compiled Zero-wire AST (what a backend's `query` consumes). */
126
223
  ast(): Ast;
127
224
  /** Materialize into a live, typed view. Wired by the Store/backend. A top-level `.one()`
@@ -130,12 +227,25 @@ interface QueryBase<C extends AnyCols, Rels, One extends boolean, Sel extends st
130
227
  materialize(): MaterializedView<Projected<C, Sel> & Rels, One>;
131
228
  }
132
229
 
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>;
230
+ export type Query<
231
+ C extends AnyCols,
232
+ Rels = {},
233
+ One extends boolean = false,
234
+ Sel extends string = never,
235
+ LocalRels = Rels,
236
+ > =
237
+ & Omit<QueryBase<C, Rels, One, Sel, LocalRels>, "where">
238
+ & { where: QueryBase<C, Rels, One, Sel, LocalRels>["where"] & WhereProxy<C, Rels, One, Sel, LocalRels> }
239
+ & WhereSugar<C, Rels, One, Sel, LocalRels>;
137
240
 
138
- type AnyQuery = Query<any, any, any>;
241
+ export type AnyQuery = Query<any, any, any, any, any>;
242
+
243
+ 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)[]
248
+ : never;
139
249
  const STAMP_NAMED_QUERY: unique symbol = Symbol("rindle.stampNamedQuery");
140
250
 
141
251
  interface QueryInternals {
@@ -259,6 +369,8 @@ export function defineQuery(
259
369
  // ----------------------------- fragments (FRAGMENT-COMPOSITION-DESIGN.md, Phase 0) -----------------------------
260
370
 
261
371
  const FRAGMENT_BRAND: unique symbol = Symbol("rindle.fragment");
372
+ const LOCAL_FRAGMENT_REF_BRAND: unique symbol = Symbol("rindle.localFragmentRef");
373
+ const FRAGMENT_REL_BRAND: unique symbol = Symbol("rindle.fragmentRelationship");
262
374
 
263
375
  /**
264
376
  * A reusable, typed *selection over a table* — Relay's "fragment", promoted to a first-class
@@ -276,22 +388,38 @@ const FRAGMENT_BRAND: unique symbol = Symbol("rindle.fragment");
276
388
  * fragments assembles ONE {@link Ast} → one materialization → one `/query` — the whole point
277
389
  * (no request waterfall; design §1).
278
390
  */
279
- export interface Fragment<C extends AnyCols, Rels = {}, Sel extends string = never> {
280
- (q: Query<C>): Query<C, Rels, false, Sel>;
391
+ export interface Fragment<C extends AnyCols, Rels = {}, Sel extends string = never, LocalRels = Rels> {
392
+ (q: Query<C>): Query<C, Rels, false, Sel, LocalRels>;
281
393
  readonly table: TableLike<C>;
282
394
  readonly [FRAGMENT_BRAND]: true;
283
395
  }
284
396
 
285
397
  /**
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).
398
+ * The data returned by `useFragment(fragment, ref)`: the fragment's own selected columns plus
399
+ * immediate relationship values. Relationships whose builder is another fragment surface as
400
+ * opaque {@link FragmentRef}s, so child-owned payload is read only by the child fragment reader.
290
401
  */
291
- export type FragmentRef<F> = F extends Fragment<infer C, infer Rels, infer Sel>
292
- ? Projected<C, Sel> & Rels
402
+ export type FragmentData<F> = F extends Fragment<infer C, unknown, infer Sel, infer LocalRels>
403
+ ? Projected<C, Sel> & LocalRels
293
404
  : never;
294
405
 
406
+ export interface FragmentCoverage<Q extends AnyQuery = AnyQuery> {
407
+ readonly key: string;
408
+ readonly query: Q;
409
+ }
410
+
411
+ export interface LocalFragmentRef<F extends Fragment<any, any, any, any> = Fragment<any, any, any, any>> {
412
+ readonly [LOCAL_FRAGMENT_REF_BRAND]: true;
413
+ readonly source: "local";
414
+ readonly table: string;
415
+ readonly pk: Readonly<Record<string, LitValue>>;
416
+ readonly coverage: FragmentCoverage;
417
+ readonly __fragment?: F;
418
+ }
419
+
420
+ /** The opaque token a parent passes to a component that reads {@link FragmentData} for `F`. */
421
+ export type FragmentRef<F> = F extends Fragment<any, any, any, any> ? LocalFragmentRef<F> : never;
422
+
295
423
  /**
296
424
  * Define a co-located, composable {@link Fragment}: a named selection over `table`. The returned
297
425
  * value is callable (the `build` transform), so it threads through the existing `sub`/`Rels`
@@ -304,17 +432,192 @@ export type FragmentRef<F> = F extends Fragment<infer C, infer Rels, infer Sel>
304
432
  * .sub("author", schema.user, { parent: ["authorId"], child: ["id"] }, UserAvatar));
305
433
  * ```
306
434
  */
307
- export function defineFragment<C extends AnyCols, Rels = {}, Sel extends string = never>(
435
+ export function defineFragment<C extends AnyCols, Rels = {}, Sel extends string = never, LocalRels = Rels>(
308
436
  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>;
437
+ build: (q: Query<C>) => Query<C, Rels, boolean, Sel, LocalRels>,
438
+ ): Fragment<C, Rels, Sel, LocalRels> {
439
+ const frag = (q: Query<C>): Query<C, Rels, false, Sel, LocalRels> => build(q) as Query<C, Rels, false, Sel, LocalRels>;
440
+ return Object.assign(frag, { table, [FRAGMENT_BRAND]: true as const }) as unknown as Fragment<C, Rels, Sel, LocalRels>;
313
441
  }
314
442
 
315
443
  /** 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;
444
+ export function isFragment(v: unknown): v is Fragment<AnyCols, unknown, never, unknown> {
445
+ return typeof v === "function" && (v as Partial<Fragment<AnyCols, unknown, never, unknown>>)[FRAGMENT_BRAND] === true;
446
+ }
447
+
448
+ function isLocalFragmentRef(v: unknown): v is LocalFragmentRef {
449
+ return typeof v === "object" && v !== null && (v as Partial<LocalFragmentRef>)[LOCAL_FRAGMENT_REF_BRAND] === true;
450
+ }
451
+
452
+ type FragmentRelationship = CorrelatedSubquery & { [FRAGMENT_REL_BRAND]?: true };
453
+
454
+ function markFragmentRelationship<T extends CorrelatedSubquery>(rel: T): T {
455
+ Object.defineProperty(rel, FRAGMENT_REL_BRAND, { value: true });
456
+ return rel;
457
+ }
458
+
459
+ export function isFragmentRelationship(rel: CorrelatedSubquery): boolean {
460
+ return (rel as FragmentRelationship)[FRAGMENT_REL_BRAND] === true;
461
+ }
462
+
463
+ export function fragmentKey(ref: FragmentRef<any>): string {
464
+ if (!isLocalFragmentRef(ref)) throw new Error("fragmentKey(): expected an opaque fragment ref.");
465
+ return stableKey({ table: ref.table, pk: ref.pk });
466
+ }
467
+
468
+ export function createLocalFragmentRef<F extends Fragment<any, any, any>>(
469
+ fragment: F,
470
+ pk: Record<string, LitValue>,
471
+ coverage: FragmentCoverage,
472
+ ): LocalFragmentRef<F> {
473
+ return createLocalFragmentRefForTable(fragment.table[SCHEMA].name, pk, coverage);
474
+ }
475
+
476
+ export function createLocalFragmentRefForTable<F extends Fragment<any, any, any>>(
477
+ table: string,
478
+ pk: Record<string, LitValue>,
479
+ coverage: FragmentCoverage,
480
+ ): LocalFragmentRef<F> {
481
+ return {
482
+ [LOCAL_FRAGMENT_REF_BRAND]: true,
483
+ source: "local",
484
+ table,
485
+ pk: { ...pk },
486
+ coverage,
487
+ };
488
+ }
489
+
490
+ export function createRootFragmentRef<F extends Fragment<any, any, any>, Q extends AnyQuery>(
491
+ fragment: F,
492
+ query: Q,
493
+ coverageKey = stableKey({
494
+ ast: query.ast(),
495
+ remote: typeof query.name === "string" ? { name: query.name, args: query.args } : null,
496
+ }),
497
+ ): LocalFragmentRef<F> {
498
+ const ast = query.ast();
499
+ const table = fragment.table[SCHEMA];
500
+ if (ast.table !== table.name) {
501
+ throw new Error(
502
+ `useRoot(): the coverage query is over "${ast.table}" but the fragment is over "${table.name}".`,
503
+ );
504
+ }
505
+ const pk = primaryKeyFromWhere(ast, table.primaryKey);
506
+ return createLocalFragmentRef(fragment, pk, { key: coverageKey, query });
507
+ }
508
+
509
+ export function fragmentAst<F extends Fragment<any, any, any>>(fragment: F): Ast {
510
+ return childAst(fragment.table as AnyTable, fragment as unknown as (q: unknown) => unknown);
511
+ }
512
+
513
+ export function localFragmentReadAst<F extends Fragment<any, any, any>>(
514
+ fragment: F,
515
+ ref: LocalFragmentRef<F>,
516
+ primaryKeyFor: (table: string) => readonly string[],
517
+ ): Ast {
518
+ const table = fragment.table[SCHEMA].name;
519
+ if (ref.table !== table) {
520
+ throw new Error(`useFragment(): the ref is for "${ref.table}" but the fragment is over "${table}".`);
521
+ }
522
+ const ast = localizeFragmentAst(fragmentAst(fragment), primaryKeyFor);
523
+ ast.where = andConditions(pkConditions(ref.pk), ast.where);
524
+ ast.one = true;
525
+ return ast;
526
+ }
527
+
528
+ export function localRootFragmentRefsAst<F extends Fragment<any, any, any>>(
529
+ fragment: F,
530
+ query: AnyQuery,
531
+ primaryKeyFor: (table: string) => readonly string[],
532
+ ): Ast {
533
+ const ast = query.ast();
534
+ const table = fragment.table[SCHEMA].name;
535
+ if (ast.table !== table) {
536
+ throw new Error(
537
+ `useRoot(): the coverage query is over "${ast.table}" but the fragment is over "${table}".`,
538
+ );
539
+ }
540
+ const out: Ast = { ...ast, select: uniqSort([...primaryKeyFor(table)]) };
541
+ delete out.related;
542
+ return out;
543
+ }
544
+
545
+ export function localQueryReadAst(
546
+ query: AnyQuery,
547
+ primaryKeyFor: (table: string) => readonly string[],
548
+ ): Ast {
549
+ return localizeFragmentAst(query.ast(), primaryKeyFor);
550
+ }
551
+
552
+ export function queryFromAst(ast: Ast): AnyQuery {
553
+ return {
554
+ ast: () => ast,
555
+ materialize: () => {
556
+ throw new Error("queryFromAst(): materialize() requires a Store; pass this query through Store/React.");
557
+ },
558
+ } as unknown as AnyQuery;
559
+ }
560
+
561
+ function primaryKeyFromWhere(ast: Ast, primaryKey: readonly string[]): Record<string, LitValue> {
562
+ const found = new Map<string, LitValue>();
563
+ collectLiteralEquals(ast.where, found);
564
+ const pk: Record<string, LitValue> = {};
565
+ for (const col of primaryKey) {
566
+ if (!found.has(col)) {
567
+ throw new Error(
568
+ `useRoot(): the coverage query must constrain primary key column "${col}" with a literal equality.`,
569
+ );
570
+ }
571
+ pk[col] = found.get(col)!;
572
+ }
573
+ return pk;
574
+ }
575
+
576
+ function collectLiteralEquals(cond: Condition | undefined, out: Map<string, LitValue>): void {
577
+ if (!cond) return;
578
+ if (cond.type === "and") {
579
+ for (const c of cond.conditions) collectLiteralEquals(c, out);
580
+ return;
581
+ }
582
+ if (cond.type !== "simple" || cond.op !== "=") return;
583
+ const leftCol = cond.left.type === "column" ? cond.left.name : undefined;
584
+ const rightCol = cond.right.type === "column" ? cond.right.name : undefined;
585
+ if (leftCol !== undefined && cond.right.type === "literal") out.set(leftCol, cond.right.value);
586
+ else if (rightCol !== undefined && cond.left.type === "literal") out.set(rightCol, cond.left.value);
587
+ }
588
+
589
+ function pkConditions(pk: Readonly<Record<string, LitValue>>): Condition[] {
590
+ return Object.keys(pk)
591
+ .sort()
592
+ .map((name) => fieldCondition(name, pk[name]));
593
+ }
594
+
595
+ function andConditions(conditions: Condition[], tail: Condition | undefined): Condition | undefined {
596
+ const all = tail ? [...conditions, tail] : conditions;
597
+ if (all.length === 0) return undefined;
598
+ if (all.length === 1) return all[0];
599
+ return { type: "and", conditions: all };
600
+ }
601
+
602
+ function localizeFragmentAst(ast: Ast, primaryKeyFor: (table: string) => readonly string[]): Ast {
603
+ const out: Ast = { ...ast };
604
+ if (ast.select !== undefined) out.select = uniqSort([...ast.select, ...primaryKeyFor(ast.table)]);
605
+ if (ast.related !== undefined) {
606
+ out.related = ast.related.map((rel) => {
607
+ if (rel.subquery.aggregate !== undefined) return rel;
608
+ if (!isFragmentRelationship(rel)) {
609
+ return { ...rel, subquery: localizeFragmentAst(rel.subquery, primaryKeyFor) };
610
+ }
611
+ const childPk = primaryKeyFor(rel.subquery.table);
612
+ const subquery: Ast = {
613
+ ...rel.subquery,
614
+ select: uniqSort([...childPk]),
615
+ };
616
+ delete subquery.related;
617
+ return markFragmentRelationship({ ...rel, subquery });
618
+ });
619
+ }
620
+ return out;
318
621
  }
319
622
 
320
623
  // ----------------------------- the runtime builder -----------------------------
@@ -401,22 +704,42 @@ interface ResolvedCorrelated {
401
704
  child: AnyTable;
402
705
  corr: { parent: string[]; child: string[] };
403
706
  build: ((q: unknown) => unknown) | undefined;
707
+ fragment: boolean;
708
+ }
709
+
710
+ function composeCorrelatedBuild(
711
+ build: ((q: unknown) => unknown) | undefined,
712
+ edge: ((q: unknown) => unknown) | undefined,
713
+ ): ((q: unknown) => unknown) | undefined {
714
+ if (edge === undefined) return build;
715
+ if (!isFragment(build)) {
716
+ throw new Error("sub(): an edge callback is only supported when the child build is a Fragment.");
717
+ }
718
+ return (q: unknown) => edge(build(q as Query<AnyCols>));
404
719
  }
405
720
 
406
721
  /** Resolve the two `sub`/`countAs`/`exists` call shapes to a common `{ child, corr, build }`:
407
722
  * either a named {@link Relationship} (`(rel, build?)`) or an explicit `(child, corr, build?)`. */
408
- function resolveCorrelated(a: AnyTable | AnyRelationship, b: unknown, c: unknown): ResolvedCorrelated {
723
+ function resolveCorrelated(a: AnyTable | AnyRelationship, b: unknown, c: unknown, d?: unknown): ResolvedCorrelated {
409
724
  if (isRelationship(a)) {
410
725
  return {
411
726
  child: a.child as AnyTable,
412
727
  corr: { parent: [...a.correlation.parent], child: [...a.correlation.child] },
413
- build: b as ((q: unknown) => unknown) | undefined,
728
+ build: composeCorrelatedBuild(
729
+ b as ((q: unknown) => unknown) | undefined,
730
+ c as ((q: unknown) => unknown) | undefined,
731
+ ),
732
+ fragment: isFragment(b),
414
733
  };
415
734
  }
416
735
  return {
417
736
  child: a,
418
737
  corr: b as { parent: string[]; child: string[] },
419
- build: c as ((q: unknown) => unknown) | undefined,
738
+ build: composeCorrelatedBuild(
739
+ c as ((q: unknown) => unknown) | undefined,
740
+ d as ((q: unknown) => unknown) | undefined,
741
+ ),
742
+ fragment: isFragment(c),
420
743
  };
421
744
  }
422
745
 
@@ -507,7 +830,8 @@ function mergeRelatedLists(base: CorrelatedSubquery[], add: CorrelatedSubquery[]
507
830
  `or align them (FRAGMENT-COMPOSITION-DESIGN §10.2).`,
508
831
  );
509
832
  }
510
- byAlias.set(alias, { ...existing, subquery: mergeSubqueryAst(existing.subquery, csq.subquery) });
833
+ const merged = { ...existing, subquery: mergeSubqueryAst(existing.subquery, csq.subquery) };
834
+ byAlias.set(alias, isFragmentRelationship(existing) || isFragmentRelationship(csq) ? markFragmentRelationship(merged) : merged);
511
835
  }
512
836
  return [...byAlias.values()].sort((a, b) => {
513
837
  const x = aliasOf(a);
@@ -576,15 +900,15 @@ function makeQuery(
576
900
  const fragAst = childAst(fragTable as AnyTable, fragment as unknown as (q: unknown) => unknown);
577
901
  return makeQuery(meta, mergeIncludedFragment(s, fragAst), onMat, named, guardAst);
578
902
  },
579
- sub: (alias: string, childOrRel: AnyTable | AnyRelationship, b?: unknown, c?: unknown) => {
580
- const { child, corr, build } = resolveCorrelated(childOrRel, b, c);
903
+ sub: (alias: string, childOrRel: AnyTable | AnyRelationship, b?: unknown, c?: unknown, d?: unknown) => {
904
+ const { child, corr, build, fragment } = resolveCorrelated(childOrRel, b, c, d);
581
905
  const sub = childAst(child, build);
582
906
  sub.alias = alias;
583
907
  const csq: CorrelatedSubquery = {
584
908
  correlation: { parentField: corr.parent, childField: corr.child },
585
909
  subquery: sub,
586
910
  };
587
- return next({ related: [...s.related, csq] });
911
+ return next({ related: [...s.related, fragment ? markFragmentRelationship(csq) : csq] });
588
912
  },
589
913
  countAs: (alias: string, childOrRel: AnyTable | AnyRelationship, b?: unknown, c?: unknown) => {
590
914
  // Same correlated-child mechanism as `sub`, but mark the child a `count` aggregate:
package/src/store.ts CHANGED
@@ -51,6 +51,19 @@ export interface CachedQueryView<Q extends Query<any, any, any>> {
51
51
  destroy(): void;
52
52
  }
53
53
 
54
+ export interface SyncQueryLease {
55
+ readonly resultType: ResultType;
56
+ subscribe(listener: () => void): () => void;
57
+ release(): void;
58
+ }
59
+
60
+ interface SyncLeaseState {
61
+ resultType: ResultType;
62
+ listeners: Set<() => void>;
63
+ released: boolean;
64
+ seedKey?: string;
65
+ }
66
+
54
67
  /** One live materialized view's read-only summary for a devtools pane (DEBUG-TOOLS-BROWSER-DESIGN
55
68
  * §4.2 — "surface, not instrument"). All fields are already held by the {@link Store}; this is the
56
69
  * single read-only accessor over the otherwise-private `views`/`asts` maps. */
@@ -93,6 +106,7 @@ export class Store<S extends ColsMap> {
93
106
  private nextId = 1;
94
107
  private readonly views = new Map<QueryId, FlatArrayView>();
95
108
  private readonly asts = new Map<QueryId, Ast>();
109
+ private readonly syncLeases = new Map<QueryId, SyncLeaseState>();
96
110
  // SSR seeds (SSR-DESIGN.md §6), keyed by `viewKey`: a view materialized for one of these is
97
111
  // seeded for first paint; a seed is consumed (dropped) the moment its query's first live
98
112
  // `hello` lands, so later mounts don't flash stale SSR data.
@@ -111,6 +125,12 @@ export class Store<S extends ColsMap> {
111
125
  // transition (it never displaces this single handler).
112
126
  this.backend.onResultType?.((qid, rt) => {
113
127
  this.views.get(qid)?.setResultType(rt);
128
+ const sync = this.syncLeases.get(qid);
129
+ if (sync && sync.resultType !== rt) {
130
+ sync.resultType = rt;
131
+ if (rt === "complete" && sync.seedKey !== undefined) this.seeds.delete(sync.seedKey);
132
+ for (const listener of sync.listeners) listener();
133
+ }
114
134
  if (this.devObservers) for (const o of this.devObservers) o.onResultType?.(qid, rt);
115
135
  });
116
136
  // `store.query` is the LOCAL builder (201-LOCAL-ONLY-TABLES-DESIGN.md §5): it scopes over
@@ -153,6 +173,49 @@ export class Store<S extends ColsMap> {
153
173
  };
154
174
  }
155
175
 
176
+ /** Retain a named remote query purely for normalized/local-first coverage. This does not
177
+ * register or materialize the query AST locally, so React can keep server sync coverage alive
178
+ * without subscribing to the broad coverage result tree. */
179
+ retainSyncQuery<Q extends Query<any, any, any>>(query: Q): SyncQueryLease {
180
+ if (!this.canRetainRemoteQueries()) {
181
+ throw new Error(
182
+ "store.retainSyncQuery: this backend cannot retain a remote query without a local view.",
183
+ );
184
+ }
185
+ const ast = query.ast();
186
+ const remote = typeof query.name === "string" ? { name: query.name, args: query.args } : undefined;
187
+ if (!remote) {
188
+ throw new Error("store.retainSyncQuery: sync-only coverage requires a named query.");
189
+ }
190
+ const qid = this.nextId++;
191
+ const state: SyncLeaseState = { resultType: "unknown", listeners: new Set(), released: false, seedKey: stableKey(ast) };
192
+ this.syncLeases.set(qid, state);
193
+ try {
194
+ this.backend.retainRemoteQuery?.(qid, remote, qid, ast);
195
+ } catch (e) {
196
+ this.syncLeases.delete(qid);
197
+ throw e;
198
+ }
199
+ return {
200
+ get resultType() {
201
+ return state.resultType;
202
+ },
203
+ subscribe: (listener: () => void) => {
204
+ state.listeners.add(listener);
205
+ listener();
206
+ return () => {
207
+ state.listeners.delete(listener);
208
+ };
209
+ },
210
+ release: () => {
211
+ if (state.released) return;
212
+ state.released = true;
213
+ this.syncLeases.delete(qid);
214
+ this.backend.releaseRemoteQuery?.(qid);
215
+ },
216
+ };
217
+ }
218
+
156
219
  /** Apply a batch of mutations (object rows → positional). Resolves when the backend has
157
220
  * accepted them (local: applied; remote: sent). The resulting view updates flow back via
158
221
  * the backend's event stream. */
@@ -234,6 +297,12 @@ export class Store<S extends ColsMap> {
234
297
  return this.seeds.get(viewKey);
235
298
  }
236
299
 
300
+ primaryKeyFor(table: string): readonly string[] {
301
+ const meta = this.schema.tables[table];
302
+ if (!meta) throw new Error(`unknown table: ${table}`);
303
+ return meta.primaryKey;
304
+ }
305
+
237
306
  /** Convert assembled (nested-by-name) rows (SSR-DESIGN.md §3.3) into the view's projected result
238
307
  * shape: spread `cols` (parsing json columns), recurse into each relationship by its alias
239
308
  * (plural → array, `.one()` → object/null, `countAs` → bare scalar). */
@@ -310,7 +379,7 @@ export class Store<S extends ColsMap> {
310
379
  const remote = typeof query.name === "string" ? { name: query.name, args: query.args } : undefined;
311
380
  if (!remote || !this.canRetainRemoteQueries()) return () => {};
312
381
  const qid = this.nextId++;
313
- this.backend.retainRemoteQuery?.(qid, remote, localQueryId);
382
+ this.backend.retainRemoteQuery?.(qid, remote, localQueryId, query.ast());
314
383
  let released = false;
315
384
  return () => {
316
385
  if (released) return;