@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/README.md +70 -5
- package/dist/query.d.ts +96 -31
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +157 -7
- package/dist/query.js.map +1 -1
- package/dist/store.d.ts +11 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +58 -1
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +4 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/view.d.ts.map +1 -1
- package/dist/view.js +32 -19
- package/dist/view.js.map +1 -1
- package/package.json +1 -1
- package/src/query.ts +371 -47
- package/src/store.ts +70 -1
- package/src/types.ts +5 -2
- package/src/view.ts +36 -20
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
|
-
|
|
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<
|
|
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<
|
|
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<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
287
|
-
*
|
|
288
|
-
*
|
|
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
|
|
292
|
-
? Projected<C, Sel> &
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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;
|