@rindle/client 0.1.0-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +65 -0
  3. package/dist/ast.d.ts +83 -0
  4. package/dist/ast.d.ts.map +1 -0
  5. package/dist/ast.js +5 -0
  6. package/dist/ast.js.map +1 -0
  7. package/dist/compare.d.ts +17 -0
  8. package/dist/compare.d.ts.map +1 -0
  9. package/dist/compare.js +81 -0
  10. package/dist/compare.js.map +1 -0
  11. package/dist/index.d.ts +12 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +13 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/key.d.ts +2 -0
  16. package/dist/key.d.ts.map +1 -0
  17. package/dist/key.js +26 -0
  18. package/dist/key.js.map +1 -0
  19. package/dist/operators.d.ts +39 -0
  20. package/dist/operators.d.ts.map +1 -0
  21. package/dist/operators.js +45 -0
  22. package/dist/operators.js.map +1 -0
  23. package/dist/query.d.ts +280 -0
  24. package/dist/query.d.ts.map +1 -0
  25. package/dist/query.js +348 -0
  26. package/dist/query.js.map +1 -0
  27. package/dist/schema.d.ts +111 -0
  28. package/dist/schema.d.ts.map +1 -0
  29. package/dist/schema.js +92 -0
  30. package/dist/schema.js.map +1 -0
  31. package/dist/ssr.d.ts +73 -0
  32. package/dist/ssr.d.ts.map +1 -0
  33. package/dist/ssr.js +66 -0
  34. package/dist/ssr.js.map +1 -0
  35. package/dist/store.d.ts +90 -0
  36. package/dist/store.d.ts.map +1 -0
  37. package/dist/store.js +225 -0
  38. package/dist/store.js.map +1 -0
  39. package/dist/types.d.ts +250 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +20 -0
  42. package/dist/types.js.map +1 -0
  43. package/dist/view.d.ts +88 -0
  44. package/dist/view.d.ts.map +1 -0
  45. package/dist/view.js +294 -0
  46. package/dist/view.js.map +1 -0
  47. package/package.json +36 -0
  48. package/src/ast.ts +94 -0
  49. package/src/compare.ts +85 -0
  50. package/src/index.ts +57 -0
  51. package/src/key.ts +23 -0
  52. package/src/operators.ts +68 -0
  53. package/src/query.ts +734 -0
  54. package/src/schema.ts +181 -0
  55. package/src/ssr.ts +115 -0
  56. package/src/store.ts +279 -0
  57. package/src/types.ts +234 -0
  58. package/src/view.ts +348 -0
package/src/query.ts ADDED
@@ -0,0 +1,734 @@
1
+ // The fluent, type-safe query builder. It accumulates state immutably and compiles to the
2
+ // Zero-wire {@link Ast} via `.ast()` (which the wasm `Db.query`/a remote server parses).
3
+ //
4
+ // Two runtime Proxies give the requested ergonomics (WASM-CLIENT-DESIGN.md §6):
5
+ // - `where` is callable (`where(or(...))`) AND a field proxy (`where.closed(false)`);
6
+ // - `where<Field>(…)` camelCase sugar (`whereClosed(false)`) is intercepted too.
7
+ // Both are fully typed via mapped types + template-literal keys.
8
+
9
+ import type { Ast, Bound, Condition, CorrelatedSubquery, Dir, ExistsOp, LitValue, OrderPart } from "./ast.ts";
10
+ import { stableKey } from "./key.ts";
11
+ import type { Arg, Cond } from "./operators.ts";
12
+ import { fieldCondition } from "./operators.ts";
13
+ import type { AnyCols, AnyRelationship, AnyTable, ColsMap, ColT, Relationship, RowOf, Schema, TableLike, TableMeta } from "./schema.ts";
14
+ import { isRelationship, SCHEMA } from "./schema.ts";
15
+ import type { ArrayView, SingularArrayView } from "./view.ts";
16
+
17
+ // ----------------------------- the public Query type -----------------------------
18
+
19
+ // `One` tracks a top-level `.one()`: it flips `materialize()` from a plural `ArrayView`
20
+ // (`data: R[]`) to a `SingularArrayView` (`data: R | null`). It threads through every chained
21
+ // method so the unwrap survives `.one().where(…)`, etc. Defaults `false` (plural).
22
+
23
+ type FieldFn<C extends AnyCols, K extends keyof C, Rels, One extends boolean, Sel extends string> = (
24
+ arg: Arg<ColT<C[K]>>,
25
+ ) => Query<C, Rels, One, Sel>;
26
+
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>;
30
+ };
31
+
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>;
35
+ };
36
+
37
+ /** What `materialize()` returns: singular (`R | null`) for a top-level `.one()`, else plural. */
38
+ type MaterializedView<R, One extends boolean> = One extends true ? SingularArrayView<R> : ArrayView<R>;
39
+
40
+ /**
41
+ * The result row type of a projection (masking, FRAGMENT-COMPOSITION-DESIGN.md §6 / §10.6).
42
+ * `Sel` is the union of columns named via `select(...)`. With nothing selected (`Sel = never`)
43
+ * it stays the full {@link RowOf} — the "no `select` ⇒ all columns" convention, kept and made
44
+ * type-honest. Once any column is selected, the row is **masked** to exactly those columns, so a
45
+ * component (its fragment) can read only what it declared. `Pick` only ever *removes* fields, so
46
+ * narrowing is always sound versus a runtime row that may still carry more.
47
+ */
48
+ type Projected<C extends AnyCols, Sel extends string> = [Sel] extends [never]
49
+ ? RowOf<C>
50
+ : Pick<RowOf<C>, Sel & keyof C>;
51
+
52
+ interface QueryBase<C extends AnyCols, Rels, One extends boolean, Sel extends string> {
53
+ /** Present when this local query came from `defineQuery`; used as the remote identity. */
54
+ readonly name?: string;
55
+ /** Present when this local query came from `defineQuery`; sent with `name` upstream. */
56
+ readonly args?: unknown;
57
+ /** Condition form — consumes `or()`/`and()`/`exists()`/field conditions (AND-ed across calls).
58
+ * 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>;
61
+ /** Project a subset of columns (PROJECTION-SUPPORT-DESIGN.md §6, masking §6/§10.6). Chainable
62
+ * (each call unions into `Sel`); omit to select all. Drives what the server syncs, what the
63
+ * view reports, AND — once any column is named — narrows the result row TYPE to exactly the
64
+ * 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>;
67
+ /** Cursor paging: start at (or, with `exclusive`, after) the partial `cursor` row over the
68
+ * sort columns. Lowers to a `Skip` in the engine. */
69
+ start(cursor: Partial<RowOf<C>>, opts?: { exclusive?: boolean }): Query<C, Rels, One, Sel>;
70
+ /** Return a single row: `materialize()` yields a {@link SingularArrayView} (`data: R | null`)
71
+ * and the engine caps the query to `limit = 1`. */
72
+ one(): Query<C, Rels, true, Sel>;
73
+ /** **Merge** a {@link Fragment} into THIS node — Relay's "spread on the same type" (§5). The
74
+ * fragment must be over the same table (enforced in the result type: a fragment over a different
75
+ * table yields `never`, plus a runtime throw). Unions the projection (`Sel | FSel`), folds in the
76
+ * fragment's nested relationships (`Rels & FRels`), and merges any same-`alias` edge recursively
77
+ * — canonically, so `include` order doesn't matter (§10.5). Two fragments that spread the same
78
+ * alias with a conflicting row-set (correlation/where/orderBy/…) **throw** (§10.2); an included
79
+ * fragment may **not** add a root `where`/`orderBy`/`limit` (§10.3). This is also the single-shot
80
+ * way to ROOT a fragment onto a base query (`queries.issue.where.id(x).include(IssueCard)`) — the
81
+ * loader's composed root — equivalent to applying the fragment, but canonicalized.
82
+ *
83
+ * The fragment's columns are a *separate* inferred parameter `FC` (not the class `C`) so that
84
+ * `include` keeps `C` out of any contravariant position — preserving `Query<Concrete>`'s
85
+ * assignability to `Query<AnyCols>` (needed by `Store<ColsMap>`/`QueryRoot`). Same-table is then
86
+ * 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;
90
+ /** Nest a child by EXPLICIT correlation (no schema relationship). `alias` is the result key.
91
+ * The child's own projection (`CSel`) masks the nested row, so a fragment spread here
92
+ * contributes exactly the columns it declared. */
93
+ sub<A extends string, CC extends AnyCols, CRels = {}, CSel extends string = never>(
94
+ alias: A,
95
+ child: TableLike<CC>,
96
+ corr: { parent: Array<keyof C & string>; child: Array<keyof CC & string> },
97
+ build?: (q: Query<CC>) => Query<CC, CRels, boolean, CSel>,
98
+ ): Query<C, Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> }, One, Sel>;
99
+ /** Nest a child by a named {@link Relationship} (`rel(parent, child, {...})`) — the correlation comes
100
+ * from the relationship, so no `{ parent, child }` keys are restated. The relationship must belong to
101
+ * THIS table (its parent columns are checked against `C`). `alias` is still the result key. */
102
+ sub<A extends string, CC extends AnyCols, CRels = {}, CSel extends string = never>(
103
+ alias: A,
104
+ relationship: Relationship<C, CC>,
105
+ build?: (q: Query<CC>) => Query<CC, CRels, boolean, CSel>,
106
+ ): Query<C, Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> }, One, Sel>;
107
+ /** Add a **relationship aggregate** — `issue.countAs("commentCount", comment, …)`
108
+ * (`REDUCE-DESIGN.md` §9). Like {@link sub} (explicit correlation, optional child
109
+ * `build` for a filtered `count(child WHERE …)`), but the relationship surfaces a single
110
+ * scalar `count(*)` of the correlated child rows named `alias` — so the result key is a
111
+ * `number`, not an array; an empty (childless) parent reads `0`. */
112
+ countAs<A extends string, CC extends AnyCols>(
113
+ alias: A,
114
+ child: TableLike<CC>,
115
+ corr: { parent: Array<keyof C & string>; child: Array<keyof CC & string> },
116
+ build?: (q: Query<CC>) => Query<CC, unknown>,
117
+ ): Query<C, Rels & { [P in A]: number }, One, Sel>;
118
+ /** `countAs` by a named {@link Relationship} — like the explicit form, but the correlation comes from
119
+ * `rel(...)` instead of being restated. */
120
+ countAs<A extends string, CC extends AnyCols>(
121
+ alias: A,
122
+ relationship: Relationship<C, CC>,
123
+ build?: (q: Query<CC>) => Query<CC, unknown>,
124
+ ): Query<C, Rels & { [P in A]: number }, One, Sel>;
125
+ /** The compiled Zero-wire AST (what a backend's `query` consumes). */
126
+ ast(): Ast;
127
+ /** 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>;
131
+ }
132
+
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>;
137
+
138
+ type AnyQuery = Query<any, any, any>;
139
+ const STAMP_NAMED_QUERY: unique symbol = Symbol("rindle.stampNamedQuery");
140
+
141
+ interface QueryInternals {
142
+ [STAMP_NAMED_QUERY](name: string, args: unknown): unknown;
143
+ }
144
+
145
+ function stampNamedQuery<Q extends AnyQuery>(query: Q, name: string, args: unknown): Q {
146
+ const stamp = (query as unknown as QueryInternals)[STAMP_NAMED_QUERY];
147
+ if (typeof stamp !== "function") {
148
+ throw new Error("defineQuery's build must return a Query built by newQueryBuilder/queries");
149
+ }
150
+ return stamp(name, args) as Q;
151
+ }
152
+
153
+ /** Turn raw, UNTRUSTED wire args into the canonical, typed args a query is built from. Its return
154
+ * type IS the query's args type — written once, it flows to both the client call signature and the
155
+ * `build` step, so there's no second place to restate the shape. */
156
+ export type QueryValidator<Args> = (rawArgs: unknown) => Args;
157
+ /**
158
+ * Build a `Query` (an `Ast` constructor) from already-validated args, plus an optional CONTEXT — the
159
+ * authenticated principal the query is scoped to. `Ctx` is **off-wire**: the client passes its own
160
+ * session ctx at the callsite, the server injects the AUTHORITATIVE ctx ({@link NamedQuery.resolve}
161
+ * via `registerQueries`), and the wire still carries only `name` + args. Both tiers run the same
162
+ * `build`, so whenever their ctx agrees the AST is byte-identical — and a client can never spoof it,
163
+ * since the server re-derives ctx from its trusted session and ignores anything the client claimed.
164
+ *
165
+ * The ctx is a *tuple* so a query opts into it by how it types `build`'s second parameter — and that
166
+ * shape becomes the call signature verbatim:
167
+ * - `(args) => Q` — no ctx (`q(args)`).
168
+ * - `(args, ctx: C) => Q` — ctx REQUIRED (`q(args, ctx)`); for always-authenticated queries.
169
+ * - `(args, ctx?: C) => Q` — ctx OPTIONAL (`q(args)` or `q(args, ctx)`); sound only when "no ctx"
170
+ * is a real, SYMMETRIC state — i.e. the build yields the SAME broad AST whether ctx is absent or
171
+ * present-with-no-user (anonymous / SSR), since the server always forwards a ctx (possibly
172
+ * anonymous). Otherwise type ctx required so the type catches an omitted ctx.
173
+ */
174
+ export type QueryBuilder<Args, Q extends AnyQuery, Ctx extends readonly unknown[] = []> = (
175
+ args: Args,
176
+ ...ctx: Ctx
177
+ ) => Q;
178
+
179
+ /**
180
+ * A single, co-located NAMED query (see {@link defineQuery}). It is:
181
+ * - **callable on the client** — `q(args, ctx?)` validates + builds + stamps the result with its
182
+ * remote subscription identity (`name` + args — NOT ctx), so a component that imports it always
183
+ * SYNCS. There is no unstamped builder to import by accident.
184
+ * - **registerable on the server** — it carries its `queryName` and a `resolve` that re-runs the
185
+ * SAME validator on untrusted wire args and builds the AUTHORITATIVE `Query` from the server's
186
+ * own ctx ({@link registerQueries `registerQueries`} in `@rindle/api-server`).
187
+ *
188
+ * `Ctx` is the ctx parameter list mirrored from `build` — `[]`, `[ctx: C]`, or `[ctx?: C]`. It is
189
+ * never part of the wire identity.
190
+ */
191
+ export interface NamedQuery<Args, Ctx extends readonly unknown[], Q extends AnyQuery> {
192
+ (args: Args, ...ctx: Ctx): Q;
193
+ /** The wire identity. The daemon leases by this name; client and server MUST agree on it — which
194
+ * they do, because both sides import the SAME `defineQuery` value. */
195
+ readonly queryName: string;
196
+ /** Server-side: validate raw wire args, then build the authoritative `Query` from the server's
197
+ * authoritative ctx (forwarded by `registerQueries`). */
198
+ resolve(rawArgs: unknown, ...ctx: Ctx): Q;
199
+ }
200
+
201
+ /**
202
+ * Define ONE co-located, named query. Keep it next to the component that reads it (a `*.queries.ts`
203
+ * file), so a query lives where its fragments and its component live.
204
+ *
205
+ * The returned value is callable on the client (it stamps its result with `name` + args, so the
206
+ * subscription SYNCS) and registerable on the server ({@link registerQueries}). The optional
207
+ * `validate` step turns untrusted wire args into the canonical args type — and that type flows to
208
+ * both the call signature and `build`, so the shape is written exactly once. `validate` runs on
209
+ * BOTH tiers (it builds the byte-identical authoritative AST on the server, and guards + guarantees
210
+ * the same AST on the client). If the server must DIVERGE from the client, define a second
211
+ * `defineQuery` with the same `name` for the server and register that one instead.
212
+ *
213
+ * `build` may take a second CONTEXT parameter (see {@link QueryBuilder}). Context is off-wire: the
214
+ * client passes its session ctx at the callsite, the server injects its authoritative ctx — so a
215
+ * per-user query stays symmetric without ever trusting (or transmitting) a client-supplied identity.
216
+ *
217
+ * ```ts
218
+ * // recentComments({ limit }) — validated, no ctx, byte-identical on both tiers
219
+ * export const recentCommentsQuery = defineQuery(
220
+ * "recentComments",
221
+ * (raw): { limit: number } => ({ limit: validateFeedLimit(raw) }),
222
+ * ({ limit }) => q.comment.orderBy("createdAt", "desc").limit(limit).include(FeedItemFragment),
223
+ * );
224
+ *
225
+ * // myIssues({ limit }, ctx) — ctx-scoped; the wire still carries only { limit }
226
+ * export const myIssuesQuery = defineQuery(
227
+ * "myIssues",
228
+ * (raw): { limit: number } => ({ limit: validateLimit(raw) }),
229
+ * ({ limit }, ctx: { user: string }) => q.issue.where.ownerId(ctx.user).limit(limit),
230
+ * );
231
+ * // client: myIssuesQuery({ limit: 20 }, { user: currentUser() })
232
+ * ```
233
+ */
234
+ export function defineQuery<Q extends AnyQuery>(name: string, build: () => Q): NamedQuery<void, [], Q>;
235
+ export function defineQuery<Args, Ctx extends readonly unknown[], Q extends AnyQuery>(
236
+ name: string,
237
+ build: (args: Args, ...ctx: Ctx) => Q,
238
+ ): NamedQuery<Args, Ctx, Q>;
239
+ export function defineQuery<Args, Ctx extends readonly unknown[], Q extends AnyQuery>(
240
+ name: string,
241
+ validate: QueryValidator<Args>,
242
+ build: (args: Args, ...ctx: Ctx) => Q,
243
+ ): NamedQuery<Args, Ctx, Q>;
244
+ export function defineQuery(
245
+ name: string,
246
+ validateOrBuild: (...a: any[]) => any,
247
+ maybeBuild?: (...a: any[]) => any,
248
+ ): NamedQuery<any, any, AnyQuery> {
249
+ const hasValidator = maybeBuild !== undefined;
250
+ const validate = (hasValidator ? validateOrBuild : (raw: unknown) => raw) as (raw: unknown) => unknown;
251
+ const build = (hasValidator ? maybeBuild : validateOrBuild) as (args: unknown, ...ctx: unknown[]) => AnyQuery;
252
+ const resolve = (rawArgs: unknown, ...ctx: unknown[]): AnyQuery => build(validate(rawArgs), ...ctx);
253
+ // The wire identity is (name, args) ONLY — ctx is never stamped, so it never crosses the wire.
254
+ const call = (args: unknown, ...ctx: unknown[]): AnyQuery =>
255
+ stampNamedQuery(build(validate(args), ...ctx), name, args ?? null);
256
+ return Object.assign(call, { queryName: name, resolve }) as NamedQuery<any, any, AnyQuery>;
257
+ }
258
+
259
+ // ----------------------------- fragments (FRAGMENT-COMPOSITION-DESIGN.md, Phase 0) -----------------------------
260
+
261
+ const FRAGMENT_BRAND: unique symbol = Symbol("rindle.fragment");
262
+
263
+ /**
264
+ * A reusable, typed *selection over a table* — Relay's "fragment", promoted to a first-class
265
+ * value. **Two verbs** compose a fragment, on one axis — does its data belong to THIS row or to a
266
+ * related one:
267
+ *
268
+ * - SAME node — `q.include(Frag)`: merge its selection into this node. This is also how you
269
+ * **root** a fragment onto a base query — `queries.t.where.id(x).include(Frag)`.
270
+ * - CHILD node — `q.sub(alias, child, corr, Frag)`: nest it under a relationship (`sub`'s 4th arg).
271
+ *
272
+ * A `Fragment` is therefore also a `build` transform (`(q: Query<C>) => Query<C, Rels>`); that
273
+ * call signature is the mechanism by which `sub` accepts a fragment as its build. Prefer
274
+ * `include` to root a fragment (canonical + merged) over calling it directly. It additionally
275
+ * carries its `table` (for {@link FragmentRef}/`useFragment` typing and masking). Composing
276
+ * fragments assembles ONE {@link Ast} → one materialization → one `/query` — the whole point
277
+ * (no request waterfall; design §1).
278
+ */
279
+ export interface Fragment<C extends AnyCols, Rels = {}, Sel extends string = never> {
280
+ (q: Query<C>): Query<C, Rels, false, Sel>;
281
+ readonly table: TableLike<C>;
282
+ readonly [FRAGMENT_BRAND]: true;
283
+ }
284
+
285
+ /**
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).
290
+ */
291
+ export type FragmentRef<F> = F extends Fragment<infer C, infer Rels, infer Sel>
292
+ ? Projected<C, Sel> & Rels
293
+ : never;
294
+
295
+ /**
296
+ * Define a co-located, composable {@link Fragment}: a named selection over `table`. The returned
297
+ * value is callable (the `build` transform), so it threads through the existing `sub`/`Rels`
298
+ * spine — no GraphQL, no codegen, no schema change (design §3).
299
+ *
300
+ * ```ts
301
+ * const UserAvatar = defineFragment(schema.user, (f) => f.select("id", "name", "avatarUrl"));
302
+ * const CommentRow = defineFragment(schema.comment, (f) =>
303
+ * f.select("id", "body", "authorId")
304
+ * .sub("author", schema.user, { parent: ["authorId"], child: ["id"] }, UserAvatar));
305
+ * ```
306
+ */
307
+ export function defineFragment<C extends AnyCols, Rels = {}, Sel extends string = never>(
308
+ 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>;
313
+ }
314
+
315
+ /** 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;
318
+ }
319
+
320
+ // ----------------------------- the runtime builder -----------------------------
321
+
322
+ interface State {
323
+ table: string;
324
+ alias?: string;
325
+ wheres: Condition[];
326
+ orderBy: OrderPart[];
327
+ related: CorrelatedSubquery[];
328
+ start?: Bound;
329
+ limit?: number;
330
+ one: boolean;
331
+ select?: string[];
332
+ }
333
+
334
+ interface NamedQueryState {
335
+ name: string;
336
+ args: unknown;
337
+ }
338
+
339
+ function emptyState(table: string): State {
340
+ return { table, wheres: [], orderBy: [], related: [], one: false };
341
+ }
342
+
343
+ function compile(s: State): Ast {
344
+ const ast: Ast = { table: s.table };
345
+ if (s.alias !== undefined) ast.alias = s.alias;
346
+ if (s.wheres.length === 1) ast.where = s.wheres[0];
347
+ else if (s.wheres.length > 1) ast.where = { type: "and", conditions: s.wheres };
348
+ if (s.related.length > 0) ast.related = s.related;
349
+ if (s.start !== undefined) ast.start = s.start;
350
+ if (s.orderBy.length > 0) ast.orderBy = s.orderBy;
351
+ if (s.limit !== undefined) ast.limit = s.limit;
352
+ if (s.one) ast.one = true;
353
+ if (s.select && s.select.length > 0) ast.select = s.select;
354
+ return ast;
355
+ }
356
+
357
+ /** Build a child AST for `sub`/`exists`: a fresh child query, the optional `build`, compiled. */
358
+ function childAst(child: AnyTable, build: ((q: unknown) => unknown) | undefined): Ast {
359
+ const cm = child[SCHEMA];
360
+ let cq: unknown = makeQuery(cm, emptyState(cm.name));
361
+ if (build) cq = build(cq);
362
+ return (cq as { ast(): Ast }).ast();
363
+ }
364
+
365
+ interface ResolvedCorrelated {
366
+ child: AnyTable;
367
+ corr: { parent: string[]; child: string[] };
368
+ build: ((q: unknown) => unknown) | undefined;
369
+ }
370
+
371
+ /** Resolve the two `sub`/`countAs`/`exists` call shapes to a common `{ child, corr, build }`:
372
+ * either a named {@link Relationship} (`(rel, build?)`) or an explicit `(child, corr, build?)`. */
373
+ function resolveCorrelated(a: AnyTable | AnyRelationship, b: unknown, c: unknown): ResolvedCorrelated {
374
+ if (isRelationship(a)) {
375
+ return {
376
+ child: a.child as AnyTable,
377
+ corr: { parent: [...a.correlation.parent], child: [...a.correlation.child] },
378
+ build: b as ((q: unknown) => unknown) | undefined,
379
+ };
380
+ }
381
+ return {
382
+ child: a,
383
+ corr: b as { parent: string[]; child: string[] },
384
+ build: c as ((q: unknown) => unknown) | undefined,
385
+ };
386
+ }
387
+
388
+ // ----------------------------- the merge pass (`include`, FRAGMENT-COMPOSITION-DESIGN §5) -----------
389
+ //
390
+ // `include(fragment)` folds a fragment's selection into the CURRENT node (same table). The merge is
391
+ // canonical (sorted) so include order is irrelevant — `q.include(A).include(B)` and
392
+ // `q.include(B).include(A)` compile to byte-identical ASTs → one `viewKey` → one materialization
393
+ // (§10.5). Two fragments that spread the same relationship `alias` are merged recursively; if they
394
+ // disagree on what rows that edge selects (correlation / table / where / orderBy / limit / paging /
395
+ // aggregate), that is an unresolvable conflict and we throw (§10.2).
396
+
397
+ function uniqSort(cols: string[]): string[] {
398
+ return [...new Set(cols)].sort();
399
+ }
400
+
401
+ /** Merge two same-node *fragment* selections: an empty (absent) `select` means "all columns", so
402
+ * merging "all" with anything stays "all" (§5). Both sides here are fragment-contributed. */
403
+ function mergeNestedSelect(a: string[] | undefined, b: string[] | undefined): string[] | undefined {
404
+ if (a === undefined || a.length === 0 || b === undefined || b.length === 0) return undefined;
405
+ return uniqSort([...a, ...b]);
406
+ }
407
+
408
+ /** Merge a fragment's `select` into the ROOTING query's `select`. A pure **union** — this mirrors
409
+ * the type level (`Sel | FSel`, where a fragment that selects nothing contributes `never`) and keeps
410
+ * `q.include(Frag)` equivalent to applying `Frag(q)`. An absent `select` (on either side) means
411
+ * "contributes no columns", NOT "all columns" — so a relationship-only fragment (e.g. an edge that
412
+ * just `sub`s, no root columns) doesn't blow the projection open. The result is "all columns" (a
413
+ * dropped `select`) only when nothing anywhere selected, i.e. the union is empty. (This differs from
414
+ * {@link mergeNestedSelect}: a *nested* same-alias child is an INTERSECTION of row types at the
415
+ * type level — `Proj<A> & Proj<B>` — so there a no-select side, meaning "all child columns", wins.) */
416
+ function mergeRootSelect(baseSel: string[] | undefined, fragSel: string[] | undefined): string[] | undefined {
417
+ const union = uniqSort([...(baseSel ?? []), ...(fragSel ?? [])]);
418
+ return union.length === 0 ? undefined : union;
419
+ }
420
+
421
+ function aliasOf(csq: CorrelatedSubquery): string {
422
+ return csq.subquery.alias ?? "";
423
+ }
424
+
425
+ /** A canonical key over everything that defines *which* rows a related edge yields — the fields
426
+ * two same-alias spreads must AGREE on (select + related are merged, so they're excluded). */
427
+ function edgeShapeKey(csq: CorrelatedSubquery): string {
428
+ const sq = csq.subquery;
429
+ return stableKey({
430
+ correlation: csq.correlation,
431
+ system: csq.system,
432
+ table: sq.table,
433
+ where: sq.where,
434
+ orderBy: sq.orderBy,
435
+ limit: sq.limit,
436
+ start: sq.start,
437
+ one: sq.one,
438
+ aggregate: sq.aggregate,
439
+ aggregatePrecomputed: sq.aggregatePrecomputed,
440
+ });
441
+ }
442
+
443
+ /** Merge the `select` + nested `related` of two same-alias subqueries (callers verified the edge
444
+ * shape agrees). Returns a canonical (sorted) AST, omitting empty `select`/`related` like compile. */
445
+ function mergeSubqueryAst(base: Ast, frag: Ast): Ast {
446
+ const out: Ast = { ...base };
447
+ const select = mergeNestedSelect(base.select, frag.select);
448
+ if (select === undefined) delete out.select;
449
+ else out.select = select;
450
+ const related = mergeRelatedLists(base.related ?? [], frag.related ?? []);
451
+ if (related.length === 0) delete out.related;
452
+ else out.related = related;
453
+ return out;
454
+ }
455
+
456
+ /** Merge two related lists by `alias`: same alias ⇒ recurse (after an edge-shape agreement check);
457
+ * distinct aliases ⇒ kept side by side. Output is ordered by alias (canonical, §10.5). */
458
+ function mergeRelatedLists(base: CorrelatedSubquery[], add: CorrelatedSubquery[]): CorrelatedSubquery[] {
459
+ const byAlias = new Map<string, CorrelatedSubquery>();
460
+ for (const csq of base) byAlias.set(aliasOf(csq), csq);
461
+ for (const csq of add) {
462
+ const alias = aliasOf(csq);
463
+ const existing = byAlias.get(alias);
464
+ if (existing === undefined) {
465
+ byAlias.set(alias, csq);
466
+ continue;
467
+ }
468
+ if (edgeShapeKey(existing) !== edgeShapeKey(csq)) {
469
+ throw new Error(
470
+ `include(): conflicting definitions for relationship "${alias}" — two fragments spread the ` +
471
+ `same alias with different correlation/table/where/orderBy/limit. Give them distinct aliases ` +
472
+ `or align them (FRAGMENT-COMPOSITION-DESIGN §10.2).`,
473
+ );
474
+ }
475
+ byAlias.set(alias, { ...existing, subquery: mergeSubqueryAst(existing.subquery, csq.subquery) });
476
+ }
477
+ return [...byAlias.values()].sort((a, b) => {
478
+ const x = aliasOf(a);
479
+ const y = aliasOf(b);
480
+ return x < y ? -1 : x > y ? 1 : 0;
481
+ });
482
+ }
483
+
484
+ /** Fold an `include`d fragment's compiled AST into the current builder {@link State}. An included
485
+ * fragment contributes only `select` + nested relationships; it may not constrain the node's row
486
+ * set / window — adding a root `where` (§10.3) or `orderBy`/`limit`/`start`/`one` throws. */
487
+ function mergeIncludedFragment(s: State, frag: Ast): State {
488
+ if (frag.where !== undefined) {
489
+ throw new Error(
490
+ "include(): a fragment may not add a root `where` — only the rooting query filters " +
491
+ "(FRAGMENT-COMPOSITION-DESIGN §10.3).",
492
+ );
493
+ }
494
+ if (frag.orderBy !== undefined || frag.limit !== undefined || frag.start !== undefined || frag.one) {
495
+ throw new Error(
496
+ "include(): a fragment may not set a root `orderBy`/`limit`/`start`/`one` — it contributes only " +
497
+ "`select` and nested relationships (those define the node's window, which is the rooting query's job).",
498
+ );
499
+ }
500
+ const select = mergeRootSelect(s.select, frag.select);
501
+ const next: State = { ...s, related: mergeRelatedLists(s.related, frag.related ?? []) };
502
+ if (select === undefined) delete next.select;
503
+ else next.select = select;
504
+ return next;
505
+ }
506
+
507
+ // Internal: the runtime is untyped (Proxy magic); the public `Query<C,Rels>` type is the contract.
508
+ function makeQuery(
509
+ meta: TableMeta,
510
+ s: State,
511
+ onMat?: (query: AnyQuery) => unknown,
512
+ named?: NamedQueryState,
513
+ ): unknown {
514
+ const next = (patch: Partial<State>): unknown => makeQuery(meta, { ...s, ...patch }, onMat, named);
515
+ const applyField = (field: string, arg: unknown) =>
516
+ next({ wheres: [...s.wheres, fieldCondition(field, arg)] });
517
+ let proxy: unknown;
518
+
519
+ const base: Record<string, unknown> = {
520
+ name: named?.name,
521
+ args: named?.args,
522
+ where: (cond: Condition) => next({ wheres: [...s.wheres, cond] }),
523
+ orderBy: (col: string, dir: Dir) => next({ orderBy: [...s.orderBy, [col, dir]] }),
524
+ select: (...cols: string[]) => next({ select: [...(s.select ?? []), ...cols] }),
525
+ limit: (n: number) => next({ limit: n }),
526
+ start: (cursor: Record<string, LitValue>, opts?: { exclusive?: boolean }) =>
527
+ next({ start: { row: { ...cursor }, exclusive: opts?.exclusive ?? false } }),
528
+ one: () => next({ one: true }),
529
+ include: (fragment: Fragment<AnyCols, unknown, string>) => {
530
+ const fragTable = fragment.table;
531
+ if (fragTable[SCHEMA].name !== s.table) {
532
+ throw new Error(
533
+ `include(): the fragment is over "${fragTable[SCHEMA].name}" but this query is over "${s.table}". ` +
534
+ `include() merges a fragment into the SAME table — use sub() to nest a different table.`,
535
+ );
536
+ }
537
+ const fragAst = childAst(fragTable as AnyTable, fragment as unknown as (q: unknown) => unknown);
538
+ return makeQuery(meta, mergeIncludedFragment(s, fragAst), onMat, named);
539
+ },
540
+ sub: (alias: string, childOrRel: AnyTable | AnyRelationship, b?: unknown, c?: unknown) => {
541
+ const { child, corr, build } = resolveCorrelated(childOrRel, b, c);
542
+ const sub = childAst(child, build);
543
+ sub.alias = alias;
544
+ const csq: CorrelatedSubquery = {
545
+ correlation: { parentField: corr.parent, childField: corr.child },
546
+ subquery: sub,
547
+ };
548
+ return next({ related: [...s.related, csq] });
549
+ },
550
+ countAs: (alias: string, childOrRel: AnyTable | AnyRelationship, b?: unknown, c?: unknown) => {
551
+ // Same correlated-child mechanism as `sub`, but mark the child a `count` aggregate:
552
+ // the builder lowers it to a scalar-projected singular relationship (REDUCE-DESIGN §9).
553
+ const { child, corr, build } = resolveCorrelated(childOrRel, b, c);
554
+ const sub = childAst(child, build);
555
+ sub.alias = alias;
556
+ sub.aggregate = "count";
557
+ const csq: CorrelatedSubquery = {
558
+ correlation: { parentField: corr.parent, childField: corr.child },
559
+ subquery: sub,
560
+ };
561
+ return next({ related: [...s.related, csq] });
562
+ },
563
+ ast: () => compile(s),
564
+ materialize: () => {
565
+ if (!onMat) throw new Error("materialize() requires a Store — use store.query.<table>");
566
+ return onMat(proxy as AnyQuery);
567
+ },
568
+ };
569
+
570
+ // `where` is callable AND a field proxy.
571
+ const whereProxy = new Proxy(base.where as object, {
572
+ get(_t, prop) {
573
+ if (typeof prop === "string") return (arg: unknown) => applyField(prop, arg);
574
+ return undefined;
575
+ },
576
+ });
577
+
578
+ proxy = new Proxy(base, {
579
+ get(target, prop) {
580
+ if (prop === "where") return whereProxy;
581
+ if (prop === STAMP_NAMED_QUERY) return (name: string, args: unknown) => makeQuery(meta, s, onMat, { name, args });
582
+ if (typeof prop === "string" && prop.length > 5 && prop.startsWith("where")) {
583
+ const field = prop[5].toLowerCase() + prop.slice(6);
584
+ return (arg: unknown) => applyField(field, arg);
585
+ }
586
+ return target[prop as string];
587
+ },
588
+ });
589
+ return proxy;
590
+ }
591
+
592
+ // ----------------------------- EXISTS / NOT EXISTS -----------------------------
593
+
594
+ /** Options for `exists` / `notExists`. */
595
+ export interface ExistsOpts {
596
+ /**
597
+ * Fold this `EXISTS` as a build-time **scalar** subquery: when the child binds a
598
+ * statically-unique key, the engine reads it once, inlines the correlation value as a
599
+ * literal, and deletes the join. **Snapshot semantics** — the inlined value does not
600
+ * react to later child changes. Defaults to `false` (a live `EXISTS` join).
601
+ */
602
+ scalar?: boolean;
603
+ }
604
+
605
+ function existsImpl(
606
+ child: AnyTable,
607
+ corr: { parent: string[]; child: string[] },
608
+ build: ((q: unknown) => unknown) | undefined,
609
+ op: ExistsOp,
610
+ // `"permissions"` ⇒ a server-only, non-syncing gate (`exists_noSync`): the normalized
611
+ // serializer prunes its witnesses so the permission table is never synced to the client.
612
+ system?: "permissions",
613
+ opts?: ExistsOpts,
614
+ ): Condition {
615
+ const sub = childAst(child, build);
616
+ sub.alias = child[SCHEMA].name;
617
+ const related: CorrelatedSubquery = {
618
+ correlation: { parentField: corr.parent, childField: corr.child },
619
+ subquery: sub,
620
+ };
621
+ if (system) related.system = system;
622
+ return {
623
+ type: "correlatedSubquery",
624
+ op,
625
+ related,
626
+ ...(opts?.scalar ? { scalar: true } : {}),
627
+ };
628
+ }
629
+
630
+ /** `EXISTS` by a named {@link Relationship} — correlation from the rel; the parent row type is the
631
+ * relationship's parent, so the condition lands on the matching `.where()`. */
632
+ export function exists<PC extends AnyCols, CC extends AnyCols>(
633
+ relationship: Relationship<PC, CC>,
634
+ build?: (q: Query<CC>) => Query<CC, unknown>,
635
+ opts?: ExistsOpts,
636
+ ): Cond<RowOf<PC>>;
637
+ /** `EXISTS (<correlated subquery>)` — a condition (use inside `where`/`or`/`and`). */
638
+ export function exists<CC extends AnyCols, R = unknown>(
639
+ child: TableLike<CC>,
640
+ corr: { parent: Array<keyof R & string>; child: Array<keyof CC & string> },
641
+ build?: (q: Query<CC>) => Query<CC, unknown>,
642
+ opts?: ExistsOpts,
643
+ ): Cond<R>;
644
+ export function exists(a: AnyTable | AnyRelationship, b?: unknown, c?: unknown, d?: unknown): Cond<unknown> {
645
+ const { child, corr, build } = resolveCorrelated(a, b, c);
646
+ const opts = (isRelationship(a) ? c : d) as ExistsOpts | undefined;
647
+ return existsImpl(child, corr, build, "EXISTS", undefined, opts) as Cond<unknown>;
648
+ }
649
+
650
+ /** `NOT EXISTS` by a named {@link Relationship}. */
651
+ export function notExists<PC extends AnyCols, CC extends AnyCols>(
652
+ relationship: Relationship<PC, CC>,
653
+ build?: (q: Query<CC>) => Query<CC, unknown>,
654
+ opts?: ExistsOpts,
655
+ ): Cond<RowOf<PC>>;
656
+ /** `NOT EXISTS (<correlated subquery>)`. */
657
+ export function notExists<CC extends AnyCols, R = unknown>(
658
+ child: TableLike<CC>,
659
+ corr: { parent: Array<keyof R & string>; child: Array<keyof CC & string> },
660
+ build?: (q: Query<CC>) => Query<CC, unknown>,
661
+ opts?: ExistsOpts,
662
+ ): Cond<R>;
663
+ export function notExists(a: AnyTable | AnyRelationship, b?: unknown, c?: unknown, d?: unknown): Cond<unknown> {
664
+ const { child, corr, build } = resolveCorrelated(a, b, c);
665
+ const opts = (isRelationship(a) ? c : d) as ExistsOpts | undefined;
666
+ return existsImpl(child, corr, build, "NOT EXISTS", undefined, opts) as Cond<unknown>;
667
+ }
668
+
669
+ /**
670
+ * `EXISTS (<correlated subquery>)` as a **server-only, non-syncing** gate (`exists_noSync`).
671
+ * Identical to {@link exists} for filtering parent visibility, but stamps the subquery
672
+ * `system: "permissions"`, so the engine's normalized serializer prunes its witnesses from the
673
+ * footprint — the permission table's rows are never synced to the client and the client never
674
+ * re-evaluates the gate. Use this when building the **server's** query (the user's API server);
675
+ * the client holds its own un-gated query.
676
+ */
677
+ export function existsNoSync<PC extends AnyCols, CC extends AnyCols>(
678
+ relationship: Relationship<PC, CC>,
679
+ build?: (q: Query<CC>) => Query<CC, unknown>,
680
+ ): Cond<RowOf<PC>>;
681
+ export function existsNoSync<CC extends AnyCols, R = unknown>(
682
+ child: TableLike<CC>,
683
+ corr: { parent: Array<keyof R & string>; child: Array<keyof CC & string> },
684
+ build?: (q: Query<CC>) => Query<CC, unknown>,
685
+ ): Cond<R>;
686
+ export function existsNoSync(a: AnyTable | AnyRelationship, b?: unknown, c?: unknown): Cond<unknown> {
687
+ const { child, corr, build } = resolveCorrelated(a, b, c);
688
+ return existsImpl(child, corr, build, "EXISTS", "permissions") as Cond<unknown>;
689
+ }
690
+
691
+ /** `NOT EXISTS (<correlated subquery>)` as a **server-only, non-syncing** gate — the `NOT EXISTS` form of {@link existsNoSync} (a deny-style permission rule). */
692
+ export function notExistsNoSync<PC extends AnyCols, CC extends AnyCols>(
693
+ relationship: Relationship<PC, CC>,
694
+ build?: (q: Query<CC>) => Query<CC, unknown>,
695
+ ): Cond<RowOf<PC>>;
696
+ export function notExistsNoSync<CC extends AnyCols, R = unknown>(
697
+ child: TableLike<CC>,
698
+ corr: { parent: Array<keyof R & string>; child: Array<keyof CC & string> },
699
+ build?: (q: Query<CC>) => Query<CC, unknown>,
700
+ ): Cond<R>;
701
+ export function notExistsNoSync(a: AnyTable | AnyRelationship, b?: unknown, c?: unknown): Cond<unknown> {
702
+ const { child, corr, build } = resolveCorrelated(a, b, c);
703
+ return existsImpl(child, corr, build, "NOT EXISTS", "permissions") as Cond<unknown>;
704
+ }
705
+
706
+ // ----------------------------- the query root (store.query.<table>) -----------------------------
707
+
708
+ export type QueryRoot<S extends ColsMap> = { [N in keyof S]: Query<S[N]> };
709
+
710
+ /** A typed query entry over a schema: `queries(schema).issue.where.closed(false)…`. */
711
+ export function queries<S extends ColsMap>(
712
+ schema: Schema<S>,
713
+ onMaterialize?: (query: AnyQuery) => unknown,
714
+ ): QueryRoot<S> {
715
+ return new Proxy({} as Record<string, unknown>, {
716
+ get(_t, prop) {
717
+ if (typeof prop !== "string") return undefined;
718
+ // Framework/JS introspection probes are never table names — return undefined instead of
719
+ // throwing so the query root (and a `Store` carrying it) can be safely enumerated by code
720
+ // that inspects objects. React 19's dev-mode prop diffing reads `$$typeof` on every prop
721
+ // value; `then` is the thenable check; `toJSON` is JSON serialization. A real typo'd table
722
+ // (`store.query.isue`) is a plain identifier and still throws below.
723
+ if (prop[0] === "$" || prop === "then" || prop === "toJSON") return undefined;
724
+ const meta = schema.tables[prop];
725
+ if (!meta) throw new Error(`unknown table: ${prop}`);
726
+ return makeQuery(meta, emptyState(meta.name), onMaterialize);
727
+ },
728
+ }) as QueryRoot<S>;
729
+ }
730
+
731
+ /** A schema-bound query-builder factory for portable client/server query definitions. */
732
+ export function newQueryBuilder<S extends ColsMap>(schema: Schema<S>): QueryRoot<S> {
733
+ return queries(schema);
734
+ }