API index and search · Build metadata
Source snapshot
packages/client/src/query.ts
1// The fluent, type-safe query builder. It accumulates state immutably and compiles to the2// 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.89import type { Ast, Bound, Condition, CorrelatedSubquery, Dir, ExistsOp, LitValue, OrderPart, SimpleOp } from "./ast.ts";10import { stableKey } from "./key.ts";11import type { Arg, Cond } from "./operators.ts";12import { fieldCondition } from "./operators.ts";13import type { AnyCols, AnyRelationship, AnyTable, ColsMap, ColT, Relationship, RowOf, Schema, TableLike, TableMeta } from "./schema.ts";14import { isLocalTable, isRelationship, SCHEMA } from "./schema.ts";15import type { ArrayView, SingularArrayView } from "./view.ts";1617// ----------------------------- the public Query type -----------------------------1819// `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 chained21// method so the unwrap survives `.one().where(…)`, etc. Defaults `false` (plural).2223type FieldFn<C extends AnyCols, K extends keyof C, Rels, One extends boolean, Sel extends string, LocalRels> = (24 arg: Arg<ColT<C[K]>>,25) => Query<C, Rels, One, Sel, LocalRels>;2627/** `where.closed(false)`, `where.priority(gt(3))`. */28type 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};3132/** `whereClosed(false)`, `wherePriority(gt(3))`. */33type 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};3637/** What `materialize()` returns: singular (`R | null`) for a top-level `.one()`, else plural. */38type MaterializedView<R, One extends boolean> = One extends true ? SingularArrayView<R> : ArrayView<R>;3940/**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 made44 * type-honest. Once any column is selected, the row is **masked** to exactly those columns, so a45 * component (its fragment) can read only what it declared. `Pick` only ever *removes* fields, so46 * narrowing is always sound versus a runtime row that may still carry more.47 */48type Projected<C extends AnyCols, Sel extends string> = [Sel] extends [never]49 ? RowOf<C>50 : Pick<RowOf<C>, Sel & keyof C>;5152/**53 * The accumulating result row of a top-level aggregate (REDUCE-DESIGN.md §8). `Agg` is `false`54 * until {@link QueryBase.count}/{@link QueryBase.groupBy} reshapes the query; then it is the55 * group-by columns intersected with the synthetic `{ count: number }`. {@link AggAcc} treats the56 * `false` state as `{}` so the intersections in `count`/`groupBy` compose either way.57 */58type AggAcc<Agg> = [Agg] extends [false] ? {} : Agg;5960/** `having`'s field binder: one accessor per aggregate-OUTPUT column (the group-by columns plus the61 * synthetic numeric `count`), each producing a {@link Cond} over that aggregate row. It is the only62 * way to name `count` in a predicate (it lives on no base table); compose several with `and`/`or`. */63type HavingProxy<Row> = { [K in keyof Row & string]: (arg: Arg<Row[K]>) => Cond<Row> };6465/** The `countAs` relationship aliases of a query — the {@link Rels} keys whose value is the scalar66 * `number` a count aggregate surfaces. These are the only aliases {@link QueryBase.having}'s67 * parent-by-child-aggregate overload accepts (a plain `sub` alias carries a row object, not a68 * number, so it is rejected). */69type AggregateAlias<Rels> = { [K in keyof Rels]: Rels[K] extends number ? K & string : never }[keyof Rels];7071/** What a query materializes: the post-aggregation row once reshaped by {@link QueryBase.count}72 * (`Agg`), else the projected base row plus its relationship values. */73type ResultRow<C extends AnyCols, Rels, Sel extends string, Agg> = [Agg] extends [false]74 ? Projected<C, Sel> & Rels75 : Agg;7677type FragmentEdge<C extends AnyCols, Rels, Sel extends string, LocalRels> = (78 q: Query<C, Rels, false, Sel, LocalRels>,79) => Query<C, Rels, boolean, Sel, LocalRels>;8081interface QueryBase<C extends AnyCols, Rels, One extends boolean, Sel extends string, LocalRels, Agg = false> {82 /** Present when this local query came from `defineQuery`; used as the remote identity. */83 readonly name?: string;84 /** Present when this local query came from `defineQuery`; sent with `name` upstream. */85 readonly args?: unknown;86 /** Present when this local query came from a realtime-labeled `defineQuery`87 * (RINDLE-REALTIME-QUERY-ENABLEMENT §2.1). Declaration metadata only — it never joins the88 * wire identity and never changes the AST. */89 readonly realtime?: RealtimeQueryLabel;90 /** Condition form — consumes `or()`/`and()`/`exists()`/field conditions (AND-ed across calls).91 * Filters over the FULL column set (`RowOf<C>`), independent of what's `select`ed/masked. */92 where(cond: Cond<RowOf<C>>): Query<C, Rels, One, Sel, LocalRels>;93 orderBy<K extends keyof C>(col: K, dir: Dir): Query<C, Rels, One, Sel, LocalRels>;94 /** Project a subset of columns (PROJECTION-SUPPORT-DESIGN.md §6, masking §6/§10.6). Chainable95 * (each call unions into `Sel`); omit to select all. Drives what the server syncs, what the96 * view reports, AND — once any column is named — narrows the result row TYPE to exactly the97 * selection (masking). `where`/`orderBy`/`start`/`sub` still see the full column set. */98 select<K extends keyof C & string>(...cols: K[]): Query<C, Rels, One, Sel | K, LocalRels>;99 limit(n: number): Query<C, Rels, One, Sel, LocalRels>;100 /** Cursor paging: start at (or, with `exclusive`, after) the partial `cursor` row over the101 * sort columns. Lowers to a `Skip` in the engine. */102 start(cursor: Partial<RowOf<C>>, opts?: { exclusive?: boolean }): Query<C, Rels, One, Sel, LocalRels>;103 /** Return a single row: `materialize()` yields a {@link SingularArrayView} (`data: R | null`)104 * and the engine caps the query to `limit = 1`. */105 one(): Query<C, Rels, true, Sel, LocalRels>;106 /** **Merge** a {@link Fragment} into THIS node — Relay's "spread on the same type" (§5). The107 * fragment must be over the same table (enforced in the result type: a fragment over a different108 * table yields `never`, plus a runtime throw). Unions the projection (`Sel | FSel`), folds in the109 * fragment's nested relationships (`Rels & FRels`), and merges any same-`alias` edge recursively110 * — canonically, so `include` order doesn't matter (§10.5). Two fragments that spread the same111 * alias with a conflicting row-set (correlation/where/orderBy/…) **throw** (§10.2); an included112 * fragment may **not** add a root `where`/`orderBy`/`limit` (§10.3). This is also the single-shot113 * way to ROOT a fragment onto a base query (`queries.issue.where.id(x).include(IssueCard)`) — the114 * loader's composed root — equivalent to applying the fragment, but canonicalized.115 *116 * The fragment's columns are a *separate* inferred parameter `FC` (not the class `C`) so that117 * `include` keeps `C` out of any contravariant position — preserving `Query<Concrete>`'s118 * assignability to `Query<AnyCols>` (needed by `Store<ColsMap>`/`QueryRoot`). Same-table is then119 * checked as the mutual-assignability of `C` and `FC` in the return type. */120 include<FC extends AnyCols, FRels = {}, FSel extends string = never, FLocalRels = FRels>(121 fragment: Fragment<FC, FRels, FSel, FLocalRels>,122 ): [C] extends [FC] ? ([FC] extends [C] ? Query<C, Rels & FRels, One, Sel | FSel, LocalRels & FLocalRels> : never) : never;123 /** Nest a child fragment as an opaque local-read ref. The coverage query still composes the124 * child's full AST at runtime; the React-facing fragment data exposes only refs at this125 * boundary so the child component owns its local subscription. */126 sub<127 A extends string,128 CC extends AnyCols,129 CRels = {},130 CSel extends string = never,131 CLocalRels = CRels,132 F extends Fragment<CC, CRels, CSel, CLocalRels> = Fragment<CC, CRels, CSel, CLocalRels>,133 >(134 alias: A,135 child: TableLike<CC>,136 corr: { parent: Array<keyof C & string>; child: Array<keyof CC & string> },137 build: F,138 edge: FragmentEdge<CC, CRels, CSel, CLocalRels>,139 ): Query<140 C,141 Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> },142 One,143 Sel,144 LocalRels & { [P in A]: Array<FragmentRef<F>> }145 >;146 sub<147 A extends string,148 CC extends AnyCols,149 CRels = {},150 CSel extends string = never,151 CLocalRels = CRels,152 F extends Fragment<CC, CRels, CSel, CLocalRels> = Fragment<CC, CRels, CSel, CLocalRels>,153 >(154 alias: A,155 child: TableLike<CC>,156 corr: { parent: Array<keyof C & string>; child: Array<keyof CC & string> },157 build: F,158 ): Query<159 C,160 Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> },161 One,162 Sel,163 LocalRels & { [P in A]: Array<FragmentRef<F>> }164 >;165 /** Relationship-value form of the fragment-ref overload above. */166 sub<167 A extends string,168 CC extends AnyCols,169 CRels = {},170 CSel extends string = never,171 CLocalRels = CRels,172 F extends Fragment<CC, CRels, CSel, CLocalRels> = Fragment<CC, CRels, CSel, CLocalRels>,173 >(174 alias: A,175 relationship: Relationship<C, CC>,176 build: F,177 edge: FragmentEdge<CC, CRels, CSel, CLocalRels>,178 ): Query<179 C,180 Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> },181 One,182 Sel,183 LocalRels & { [P in A]: Array<FragmentRef<F>> }184 >;185 /** Relationship-value form of the fragment-ref overload above. */186 sub<187 A extends string,188 CC extends AnyCols,189 CRels = {},190 CSel extends string = never,191 CLocalRels = CRels,192 F extends Fragment<CC, CRels, CSel, CLocalRels> = Fragment<CC, CRels, CSel, CLocalRels>,193 >(194 alias: A,195 relationship: Relationship<C, CC>,196 build: F,197 ): Query<198 C,199 Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> },200 One,201 Sel,202 LocalRels & { [P in A]: Array<FragmentRef<F>> }203 >;204 /** Nest a child by EXPLICIT correlation (no schema relationship). `alias` is the result key.205 * The child's own projection (`CSel`) masks the nested row, so a fragment spread here206 * contributes exactly the columns it declared. */207 sub<A extends string, CC extends AnyCols, CRels = {}, CSel extends string = never>(208 alias: A,209 child: TableLike<CC>,210 corr: { parent: Array<keyof C & string>; child: Array<keyof CC & string> },211 build?: (q: Query<CC>) => Query<CC, CRels, boolean, CSel>,212 ): Query<213 C,214 Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> },215 One,216 Sel,217 LocalRels & { [P in A]: Array<Projected<CC, CSel> & CRels> }218 >;219 /** Nest a child by a named {@link Relationship} (`rel(parent, child, {...})`) — the correlation comes220 * from the relationship, so no `{ parent, child }` keys are restated. The relationship must belong to221 * THIS table (its parent columns are checked against `C`). `alias` is still the result key. */222 sub<A extends string, CC extends AnyCols, CRels = {}, CSel extends string = never>(223 alias: A,224 relationship: Relationship<C, CC>,225 build?: (q: Query<CC>) => Query<CC, CRels, boolean, CSel>,226 ): Query<227 C,228 Rels & { [P in A]: Array<Projected<CC, CSel> & CRels> },229 One,230 Sel,231 LocalRels & { [P in A]: Array<Projected<CC, CSel> & CRels> }232 >;233 /** Add a **relationship aggregate** — `issue.countAs("commentCount", comment, …)`234 * (`REDUCE-DESIGN.md` §9). Like {@link sub} (explicit correlation, optional child235 * `build` for a filtered `count(child WHERE …)`), but the relationship surfaces a single236 * scalar `count(*)` of the correlated child rows named `alias` — so the result key is a237 * `number`, not an array; an empty (childless) parent reads `0`. */238 countAs<A extends string, CC extends AnyCols>(239 alias: A,240 child: TableLike<CC>,241 corr: { parent: Array<keyof C & string>; child: Array<keyof CC & string> },242 build?: (q: Query<CC>) => Query<CC, unknown>,243 ): Query<C, Rels & { [P in A]: number }, One, Sel, LocalRels & { [P in A]: number }>;244 /** `countAs` by a named {@link Relationship} — like the explicit form, but the correlation comes from245 * `rel(...)` instead of being restated. */246 countAs<A extends string, CC extends AnyCols>(247 alias: A,248 relationship: Relationship<C, CC>,249 build?: (q: Query<CC>) => Query<CC, unknown>,250 ): Query<C, Rels & { [P in A]: number }, One, Sel, LocalRels & { [P in A]: number }>;251 /** Reshape this query into a top-level `count(*)` aggregate (`REDUCE-DESIGN.md` §8) — the SQL252 * `SELECT count(*) FROM table [GROUP BY …] [HAVING …]`. Without {@link groupBy} it is a GLOBAL253 * count (one `{ count }` row, value `0` even on empty input); with it, one `{ …group, count }`254 * row per distinct group. The result row becomes the aggregate's OUTPUT — the group-by columns255 * plus a numeric `count` — so chain {@link having} to filter it. Distinct from {@link countAs}256 * (a child-relationship scalar attached to the parent row); this reshapes the query ITSELF. The257 * engine rejects pairing a root aggregate with `select`/`sub`/`countAs`/`orderBy`/`limit`/`one`. */258 count(): Query<C, Rels, One, Sel, LocalRels, AggAcc<Agg> & { count: number }>;259 /** Add a top-level `GROUP BY` column (chain for a compound key); only meaningful with260 * {@link count}. Each grouped column joins the aggregate result row, keyed + sorted by the261 * group key. */262 groupBy<K extends keyof C & string>(263 col: K,264 ): Query<C, Rels, One, Sel, LocalRels, AggAcc<Agg> & Pick<RowOf<C>, K>>;265 /** `HAVING (…)` — filter the **post-aggregation** rows of a {@link count} query (`REDUCE-DESIGN.md`266 * §4: a filter directly above the reduce). `build` receives a field binder over the aggregate's267 * OUTPUT columns — the {@link groupBy} columns and the synthetic `count` — and returns a268 * {@link Cond}; compose several with `and`/`or`. E.g.269 * `.groupBy("status").count().having((h) => h.count(gt(3)))`. Distinct from {@link where}, which270 * filters base rows BELOW the reduce. */271 having(build: (h: HavingProxy<AggAcc<Agg>>) => Cond<AggAcc<Agg>>): Query<C, Rels, One, Sel, LocalRels, Agg>;272 /** `HAVING count(child) <op> n` — filter THIS parent by a child relationship aggregate's count273 * (`PARENT-AGGREGATE-FILTER-DESIGN.md`). `alias` must name a {@link countAs} relationship already274 * on this query; this drops parents whose child count fails `<op> val`, maintained incrementally275 * (a child add/remove crossing the threshold adds/removes the parent). The display `countAs` is276 * untouched — a survivor still shows its real count. Distinct from the {@link having} overload277 * above, which filters a top-level {@link count}'s own output rows.278 *279 * **v1: high-pass predicates only** — predicates *false* at count 0 (`>`, `>=`/`=`/`!=` for280 * `n ≥ 1`). A childless parent forms no group, so the engine rejects (at build) a predicate *true*281 * at count 0 (`<=`, `< n` for `n ≥ 1`, `= 0`, `>= 0`); those need row-widening (deferred). */282 having<A extends AggregateAlias<Rels>>(283 alias: A,284 op: SimpleOp,285 val: number,286 ): Query<C, Rels, One, Sel, LocalRels, Agg>;287 /** The compiled Zero-wire AST (what a backend's `query` consumes). */288 ast(): Ast;289 /** Materialize into a live, typed view. Wired by the Store/backend. A top-level `.one()`290 * yields a {@link SingularArrayView}; otherwise an {@link ArrayView}. The row is the aggregate291 * output once {@link count} reshaped the query, else masked to the projection ({@link Projected})292 * — full {@link RowOf} until a column is `select`ed. */293 materialize(): MaterializedView<ResultRow<C, Rels, Sel, Agg>, One>;294}295296export type Query<297 C extends AnyCols,298 Rels = {},299 One extends boolean = false,300 Sel extends string = never,301 LocalRels = Rels,302 Agg = false,303> =304 & Omit<QueryBase<C, Rels, One, Sel, LocalRels, Agg>, "where">305 & { where: QueryBase<C, Rels, One, Sel, LocalRels, Agg>["where"] & WhereProxy<C, Rels, One, Sel, LocalRels> }306 & WhereSugar<C, Rels, One, Sel, LocalRels>;307308export type AnyQuery = Query<any, any, any, any, any, any>;309310export type QueryLocalData<Q extends AnyQuery> =311 Q extends Query<infer C, any, infer One, infer Sel, infer LocalRels, infer Agg>312 ? [Agg] extends [false]313 ? One extends true314 ? (Projected<C, Sel> & LocalRels) | null315 : readonly (Projected<C, Sel> & LocalRels)[]316 : readonly Agg[]317 : never;318const STAMP_NAMED_QUERY: unique symbol = Symbol("rindle.stampNamedQuery");319320interface QueryInternals {321 [STAMP_NAMED_QUERY](name: string, args: unknown, realtime?: RealtimeQueryLabel): unknown;322}323324function stampNamedQuery<Q extends AnyQuery>(325 query: Q,326 name: string,327 args: unknown,328 realtime?: RealtimeQueryLabel,329): Q {330 const stamp = (query as unknown as QueryInternals)[STAMP_NAMED_QUERY];331 if (typeof stamp !== "function") {332 throw new Error("defineQuery's build must return a Query built by newQueryBuilder/queries");333 }334 return stamp(name, args, realtime) as Q;335}336337/** Turn raw, UNTRUSTED wire args into the canonical, typed args a query is built from. Its return338 * type IS the query's args type — written once, it flows to both the client call signature and the339 * `build` step, so there's no second place to restate the shape. */340export type QueryValidator<Args> = (rawArgs: unknown) => Args;341/**342 * Build a `Query` (an `Ast` constructor) from already-validated args, plus an optional CONTEXT — the343 * authenticated principal the query is scoped to. `Ctx` is **off-wire**: the client passes its own344 * session ctx at the callsite, the server injects the AUTHORITATIVE ctx ({@link NamedQuery.resolve}345 * via `registerQueries`), and the wire still carries only `name` + args. Both tiers run the same346 * `build`, so whenever their ctx agrees the AST is byte-identical — and a client can never spoof it,347 * since the server re-derives ctx from its trusted session and ignores anything the client claimed.348 *349 * The ctx is a *tuple* so a query opts into it by how it types `build`'s second parameter — and that350 * shape becomes the call signature verbatim:351 * - `(args) => Q` — no ctx (`q(args)`).352 * - `(args, ctx: C) => Q` — ctx REQUIRED (`q(args, ctx)`); for always-authenticated queries.353 * - `(args, ctx?: C) => Q` — ctx OPTIONAL (`q(args)` or `q(args, ctx)`); sound only when "no ctx"354 * is a real, SYMMETRIC state — i.e. the build yields the SAME broad AST whether ctx is absent or355 * present-with-no-user (anonymous / SSR), since the server always forwards a ctx (possibly356 * anonymous). Otherwise type ctx required so the type catches an omitted ctx.357 */358export type QueryBuilder<Args, Q extends AnyQuery, Ctx extends readonly unknown[] = []> = (359 args: Args,360 ...ctx: Ctx361) => Q;362363/**364 * The realtime LABEL a named query may declare (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §2.1):365 * which api-server room PROFILE the query wants to be served from, and how the query's args map366 * to that profile's key args. This is the DECLARATION only — the serve decision (covering proof,367 * lease routing) is the server's, and the final room key is minted server-side by the profile's368 * own `key` under authoritative ctx, so a label can never place a query in a room the server369 * didn't derive itself. Both tiers import the SAME `defineQuery` value, so they always agree370 * *which profile* a query belongs to.371 */372export interface RealtimeQueryLabel<Args = any> {373 /** The room profile name — must match a `realtime.rooms` key on the api-server (validated374 * loudly at `createRindleApiServer` construction). May not contain `/` (the wire room-key375 * delimiter). */376 readonly room: string;377 /** Map the query's VALIDATED args to the profile's key args (what the server feeds the378 * profile's `key(args)`). Identity when omitted. Must be pure — both tiers may run it. */379 readonly args?: (queryArgs: Args) => unknown;380}381382/** Options for {@link defineQuery}. */383export interface DefineQueryOptions<Args = any> {384 /** Declare this query realtime-eligible — see {@link RealtimeQueryLabel}. */385 realtime?: RealtimeQueryLabel<Args>;386}387388/** Loud, definition-time validation of a realtime label — a malformed label is a config bug the389 * author should hit at module load, not a query that silently never room-serves. */390function validateRealtimeLabel(391 name: string,392 label: RealtimeQueryLabel<any> | undefined,393): RealtimeQueryLabel<any> | undefined {394 if (label === undefined) return undefined;395 if (typeof label.room !== "string" || label.room.length === 0) {396 throw new Error(`defineQuery("${name}"): realtime.room must be a non-empty room-profile name.`);397 }398 if (label.room.includes("/")) {399 throw new Error(400 `defineQuery("${name}"): realtime.room "${label.room}" may not contain "/" — it delimits the ` +401 `wire room key ("<profile>/<key>").`,402 );403 }404 if (label.args !== undefined && typeof label.args !== "function") {405 throw new Error(406 `defineQuery("${name}"): realtime.args must be a function mapping the query's args to the ` +407 `profile's key args (or omitted for identity).`,408 );409 }410 return label;411}412413/**414 * A single, co-located NAMED query (see {@link defineQuery}). It is:415 * - **callable on the client** — `q(args, ctx?)` validates + builds + stamps the result with its416 * remote subscription identity (`name` + args — NOT ctx), so a component that imports it always417 * SYNCS. There is no unstamped builder to import by accident.418 * - **registerable on the server** — it carries its `queryName` and a `resolve` that re-runs the419 * SAME validator on untrusted wire args and builds the AUTHORITATIVE `Query` from the server's420 * own ctx ({@link registerQueries `registerQueries`} in `@rindle/api-server`).421 *422 * `Ctx` is the ctx parameter list mirrored from `build` — `[]`, `[ctx: C]`, or `[ctx?: C]`. It is423 * never part of the wire identity.424 */425export interface NamedQuery<Args, Ctx extends readonly unknown[], Q extends AnyQuery> {426 (args: Args, ...ctx: Ctx): Q;427 /** The wire identity. The daemon leases by this name; client and server MUST agree on it — which428 * they do, because both sides import the SAME `defineQuery` value. */429 readonly queryName: string;430 /** The §2.1 realtime label, when this query was defined with one ({@link DefineQueryOptions}).431 * Absent on unlabeled queries. Carried onto the stamped built Query too, and preserved through432 * `registerQueries` on the server. */433 readonly realtime?: RealtimeQueryLabel<Args>;434 /** Server-side: validate raw wire args, then build the authoritative `Query` from the server's435 * authoritative ctx (forwarded by `registerQueries`). */436 resolve(rawArgs: unknown, ...ctx: Ctx): Q;437}438439/**440 * Define ONE co-located, named query. Keep it next to the component that reads it (a `*.queries.ts`441 * file), so a query lives where its fragments and its component live.442 *443 * The returned value is callable on the client (it stamps its result with `name` + args, so the444 * subscription SYNCS) and registerable on the server ({@link registerQueries}). The optional445 * `validate` step turns untrusted wire args into the canonical args type — and that type flows to446 * both the call signature and `build`, so the shape is written exactly once. `validate` runs on447 * BOTH tiers (it builds the byte-identical authoritative AST on the server, and guards + guarantees448 * the same AST on the client). If the server must DIVERGE from the client, define a second449 * `defineQuery` with the same `name` for the server and register that one instead.450 *451 * `build` may take a second CONTEXT parameter (see {@link QueryBuilder}). Context is off-wire: the452 * client passes its session ctx at the callsite, the server injects its authoritative ctx — so a453 * per-user query stays symmetric without ever trusting (or transmitting) a client-supplied identity.454 *455 * ```ts456 * // recentComments({ limit }) — validated, no ctx, byte-identical on both tiers457 * export const recentCommentsQuery = defineQuery(458 * "recentComments",459 * (raw): { limit: number } => ({ limit: validateFeedLimit(raw) }),460 * ({ limit }) => q.comment.orderBy("createdAt", "desc").limit(limit).include(FeedItemFragment),461 * );462 *463 * // myIssues({ limit }, ctx) — ctx-scoped; the wire still carries only { limit }464 * export const myIssuesQuery = defineQuery(465 * "myIssues",466 * (raw): { limit: number } => ({ limit: validateLimit(raw) }),467 * ({ limit }, ctx: { user: string }) => q.issue.where.ownerId(ctx.user).limit(limit),468 * );469 * // client: myIssuesQuery({ limit: 20 }, { user: currentUser() })470 * ```471 */472export function defineQuery<Q extends AnyQuery>(473 name: string,474 build: () => Q,475 options?: DefineQueryOptions<void>,476): NamedQuery<void, [], Q>;477export function defineQuery<Args, Ctx extends readonly unknown[], Q extends AnyQuery>(478 name: string,479 build: (args: Args, ...ctx: Ctx) => Q,480 options?: DefineQueryOptions<Args>,481): NamedQuery<Args, Ctx, Q>;482export function defineQuery<Args, Ctx extends readonly unknown[], Q extends AnyQuery>(483 name: string,484 validate: QueryValidator<Args>,485 build: (args: Args, ...ctx: Ctx) => Q,486 options?: DefineQueryOptions<Args>,487): NamedQuery<Args, Ctx, Q>;488export function defineQuery(489 name: string,490 validateOrBuild: (...a: any[]) => any,491 maybeBuildOrOptions?: ((...a: any[]) => any) | DefineQueryOptions<any>,492 maybeOptions?: DefineQueryOptions<any>,493): NamedQuery<any, any, AnyQuery> {494 const hasValidator = typeof maybeBuildOrOptions === "function";495 const validate = (hasValidator ? validateOrBuild : (raw: unknown) => raw) as (raw: unknown) => unknown;496 const build = (hasValidator ? maybeBuildOrOptions : validateOrBuild) as (args: unknown, ...ctx: unknown[]) => AnyQuery;497 const options = hasValidator ? maybeOptions : (maybeBuildOrOptions as DefineQueryOptions<any> | undefined);498 // §2.1 realtime label — pure declaration metadata, validated loudly at definition time.499 const realtime = validateRealtimeLabel(name, options?.realtime);500 const resolve = (rawArgs: unknown, ...ctx: unknown[]): AnyQuery => build(validate(rawArgs), ...ctx);501 // The wire identity is (name, args) ONLY — ctx is never stamped, so it never crosses the wire.502 // The realtime label rides the stamp as metadata beside the identity, never inside it.503 const call = (args: unknown, ...ctx: unknown[]): AnyQuery =>504 stampNamedQuery(build(validate(args), ...ctx), name, args ?? null, realtime);505 return Object.assign(506 call,507 realtime === undefined ? { queryName: name, resolve } : { queryName: name, resolve, realtime },508 ) as NamedQuery<any, any, AnyQuery>;509}510511// ----------------------------- fragments (FRAGMENT-COMPOSITION-DESIGN.md, Phase 0) -----------------------------512513const FRAGMENT_BRAND: unique symbol = Symbol("rindle.fragment");514const LOCAL_FRAGMENT_REF_BRAND: unique symbol = Symbol("rindle.localFragmentRef");515const FRAGMENT_REL_BRAND: unique symbol = Symbol("rindle.fragmentRelationship");516517/**518 * A reusable, typed *selection over a table* — Relay's "fragment", promoted to a first-class519 * value. **Two verbs** compose a fragment, on one axis — does its data belong to THIS row or to a520 * related one:521 *522 * - SAME node — `q.include(Frag)`: merge its selection into this node. This is also how you523 * **root** a fragment onto a base query — `queries.t.where.id(x).include(Frag)`.524 * - CHILD node — `q.sub(alias, child, corr, Frag)`: nest it under a relationship (`sub`'s 4th arg).525 *526 * A `Fragment` is therefore also a `build` transform (`(q: Query<C>) => Query<C, Rels>`); that527 * call signature is the mechanism by which `sub` accepts a fragment as its build. Prefer528 * `include` to root a fragment (canonical + merged) over calling it directly. It additionally529 * carries its `table` (for {@link FragmentRef}/`useFragment` typing and masking). Composing530 * fragments assembles ONE {@link Ast} → one materialization → one `/query` — the whole point531 * (no request waterfall; design §1).532 */533export interface Fragment<C extends AnyCols, Rels = {}, Sel extends string = never, LocalRels = Rels> {534 (q: Query<C>): Query<C, Rels, false, Sel, LocalRels>;535 readonly table: TableLike<C>;536 readonly [FRAGMENT_BRAND]: true;537}538539/**540 * The data returned by `useFragment(fragment, ref)`: the fragment's own selected columns plus541 * immediate relationship values. Relationships whose builder is another fragment surface as542 * opaque {@link FragmentRef}s, so child-owned payload is read only by the child fragment reader.543 */544export type FragmentData<F> = F extends Fragment<infer C, unknown, infer Sel, infer LocalRels>545 ? Projected<C, Sel> & LocalRels546 : never;547548export interface FragmentCoverage<Q extends AnyQuery = AnyQuery> {549 readonly key: string;550 readonly query: Q;551}552553export interface LocalFragmentRef<F extends Fragment<any, any, any, any> = Fragment<any, any, any, any>> {554 readonly [LOCAL_FRAGMENT_REF_BRAND]: true;555 readonly source: "local";556 readonly table: string;557 readonly pk: Readonly<Record<string, LitValue>>;558 readonly coverage: FragmentCoverage;559 readonly __fragment?: F;560}561562/** The opaque token a parent passes to a component that reads {@link FragmentData} for `F`. */563export type FragmentRef<F> = F extends Fragment<any, any, any, any> ? LocalFragmentRef<F> : never;564565/**566 * Define a co-located, composable {@link Fragment}: a named selection over `table`. The returned567 * value is callable (the `build` transform), so it threads through the existing `sub`/`Rels`568 * spine — no GraphQL, no codegen, no schema change (design §3).569 *570 * ```ts571 * const UserAvatar = defineFragment(schema.user, (f) => f.select("id", "name", "avatarUrl"));572 * const CommentRow = defineFragment(schema.comment, (f) =>573 * f.select("id", "body", "authorId")574 * .sub("author", schema.user, { parent: ["authorId"], child: ["id"] }, UserAvatar));575 * ```576 */577export function defineFragment<C extends AnyCols, Rels = {}, Sel extends string = never, LocalRels = Rels>(578 table: TableLike<C>,579 build: (q: Query<C>) => Query<C, Rels, boolean, Sel, LocalRels>,580): Fragment<C, Rels, Sel, LocalRels> {581 const frag = (q: Query<C>): Query<C, Rels, false, Sel, LocalRels> => build(q) as Query<C, Rels, false, Sel, LocalRels>;582 return Object.assign(frag, { table, [FRAGMENT_BRAND]: true as const }) as unknown as Fragment<C, Rels, Sel, LocalRels>;583}584585/** Runtime guard: is `v` a {@link Fragment} (a `defineFragment` value, not a plain build fn)? */586export function isFragment(v: unknown): v is Fragment<AnyCols, unknown, never, unknown> {587 return typeof v === "function" && (v as Partial<Fragment<AnyCols, unknown, never, unknown>>)[FRAGMENT_BRAND] === true;588}589590function isLocalFragmentRef(v: unknown): v is LocalFragmentRef {591 return typeof v === "object" && v !== null && (v as Partial<LocalFragmentRef>)[LOCAL_FRAGMENT_REF_BRAND] === true;592}593594type FragmentRelationship = CorrelatedSubquery & { [FRAGMENT_REL_BRAND]?: true };595596function markFragmentRelationship<T extends CorrelatedSubquery>(rel: T): T {597 Object.defineProperty(rel, FRAGMENT_REL_BRAND, { value: true });598 return rel;599}600601export function isFragmentRelationship(rel: CorrelatedSubquery): boolean {602 return (rel as FragmentRelationship)[FRAGMENT_REL_BRAND] === true;603}604605export function fragmentKey(ref: FragmentRef<any>): string {606 if (!isLocalFragmentRef(ref)) throw new Error("fragmentKey(): expected an opaque fragment ref.");607 return stableKey({ table: ref.table, pk: ref.pk });608}609610export function createLocalFragmentRef<F extends Fragment<any, any, any>>(611 fragment: F,612 pk: Record<string, LitValue>,613 coverage: FragmentCoverage,614): LocalFragmentRef<F> {615 return createLocalFragmentRefForTable(fragment.table[SCHEMA].name, pk, coverage);616}617618export function createLocalFragmentRefForTable<F extends Fragment<any, any, any>>(619 table: string,620 pk: Record<string, LitValue>,621 coverage: FragmentCoverage,622): LocalFragmentRef<F> {623 return {624 [LOCAL_FRAGMENT_REF_BRAND]: true,625 source: "local",626 table,627 pk: { ...pk },628 coverage,629 };630}631632export function createRootFragmentRef<F extends Fragment<any, any, any>, Q extends AnyQuery>(633 fragment: F,634 query: Q,635 coverageKey = stableKey({636 ast: query.ast(),637 remote: typeof query.name === "string" ? { name: query.name, args: query.args } : null,638 }),639): LocalFragmentRef<F> {640 const ast = query.ast();641 const table = fragment.table[SCHEMA];642 if (ast.table !== table.name) {643 throw new Error(644 `useRoot(): the coverage query is over "${ast.table}" but the fragment is over "${table.name}".`,645 );646 }647 const pk = primaryKeyFromWhere(ast, table.primaryKey);648 return createLocalFragmentRef(fragment, pk, { key: coverageKey, query });649}650651export function fragmentAst<F extends Fragment<any, any, any>>(fragment: F): Ast {652 return childAst(fragment.table as AnyTable, fragment as unknown as (q: unknown) => unknown);653}654655export function localFragmentReadAst<F extends Fragment<any, any, any>>(656 fragment: F,657 ref: LocalFragmentRef<F>,658 primaryKeyFor: (table: string) => readonly string[],659): Ast {660 const table = fragment.table[SCHEMA].name;661 if (ref.table !== table) {662 throw new Error(`useFragment(): the ref is for "${ref.table}" but the fragment is over "${table}".`);663 }664 const ast = localizeFragmentAst(fragmentAst(fragment), primaryKeyFor);665 ast.where = andConditions(pkConditions(ref.pk), ast.where);666 ast.one = true;667 return ast;668}669670export function localRootFragmentRefsAst<F extends Fragment<any, any, any>>(671 fragment: F,672 query: AnyQuery,673 primaryKeyFor: (table: string) => readonly string[],674): Ast {675 const ast = query.ast();676 const table = fragment.table[SCHEMA].name;677 if (ast.table !== table) {678 throw new Error(679 `useRoot(): the coverage query is over "${ast.table}" but the fragment is over "${table}".`,680 );681 }682 const out: Ast = { ...ast, select: uniqSort([...primaryKeyFor(table)]) };683 delete out.related;684 return out;685}686687export function localQueryReadAst(688 query: AnyQuery,689 primaryKeyFor: (table: string) => readonly string[],690): Ast {691 return localizeFragmentAst(query.ast(), primaryKeyFor);692}693694export function queryFromAst(ast: Ast): AnyQuery {695 return {696 ast: () => ast,697 materialize: () => {698 throw new Error("queryFromAst(): materialize() requires a Store; pass this query through Store/React.");699 },700 } as unknown as AnyQuery;701}702703function primaryKeyFromWhere(ast: Ast, primaryKey: readonly string[]): Record<string, LitValue> {704 const found = new Map<string, LitValue>();705 collectLiteralEquals(ast.where, found);706 const pk: Record<string, LitValue> = {};707 for (const col of primaryKey) {708 if (!found.has(col)) {709 throw new Error(710 `useRoot(): the coverage query must constrain primary key column "${col}" with a literal equality.`,711 );712 }713 pk[col] = found.get(col)!;714 }715 return pk;716}717718function collectLiteralEquals(cond: Condition | undefined, out: Map<string, LitValue>): void {719 if (!cond) return;720 if (cond.type === "and") {721 for (const c of cond.conditions) collectLiteralEquals(c, out);722 return;723 }724 if (cond.type !== "simple" || cond.op !== "=") return;725 const leftCol = cond.left.type === "column" ? cond.left.name : undefined;726 const rightCol = cond.right.type === "column" ? cond.right.name : undefined;727 if (leftCol !== undefined && cond.right.type === "literal") out.set(leftCol, cond.right.value);728 else if (rightCol !== undefined && cond.left.type === "literal") out.set(rightCol, cond.left.value);729}730731function pkConditions(pk: Readonly<Record<string, LitValue>>): Condition[] {732 return Object.keys(pk)733 .sort()734 .map((name) => fieldCondition(name, pk[name]));735}736737function andConditions(conditions: Condition[], tail: Condition | undefined): Condition | undefined {738 const all = tail ? [...conditions, tail] : conditions;739 if (all.length === 0) return undefined;740 if (all.length === 1) return all[0];741 return { type: "and", conditions: all };742}743744function localizeFragmentAst(ast: Ast, primaryKeyFor: (table: string) => readonly string[]): Ast {745 const out: Ast = { ...ast };746 if (ast.select !== undefined) out.select = uniqSort([...ast.select, ...primaryKeyFor(ast.table)]);747 if (ast.related !== undefined) {748 out.related = ast.related.map((rel) => {749 if (rel.subquery.aggregate !== undefined) return rel;750 if (!isFragmentRelationship(rel)) {751 return { ...rel, subquery: localizeFragmentAst(rel.subquery, primaryKeyFor) };752 }753 const childPk = primaryKeyFor(rel.subquery.table);754 const subquery: Ast = {755 ...rel.subquery,756 select: uniqSort([...childPk]),757 };758 delete subquery.related;759 return markFragmentRelationship({ ...rel, subquery });760 });761 }762 return out;763}764765// ----------------------------- the runtime builder -----------------------------766767interface State {768 table: string;769 alias?: string;770 wheres: Condition[];771 orderBy: OrderPart[];772 related: CorrelatedSubquery[];773 start?: Bound;774 limit?: number;775 one: boolean;776 select?: string[];777 // Top-level aggregate (REDUCE-DESIGN.md §8): `aggregate` reshapes the query into a `count(*)`;778 // `groupBy` partitions it; `having` filters the post-aggregation rows. All three are read by the779 // engine only together (see the guard in `compile`).780 aggregate?: "count";781 groupBy: string[];782 having?: Condition;783}784785interface NamedQueryState {786 name: string;787 args: unknown;788 /** The §2.1 realtime label (metadata only — never part of the wire identity). */789 realtime?: RealtimeQueryLabel;790}791792function emptyState(table: string): State {793 return { table, wheres: [], orderBy: [], related: [], one: false, groupBy: [] };794}795796function compile(s: State): Ast {797 const ast: Ast = { table: s.table };798 if (s.alias !== undefined) ast.alias = s.alias;799 if (s.wheres.length === 1) ast.where = s.wheres[0];800 else if (s.wheres.length > 1) ast.where = { type: "and", conditions: s.wheres };801 if (s.related.length > 0) ast.related = s.related;802 if (s.start !== undefined) ast.start = s.start;803 if (s.orderBy.length > 0) ast.orderBy = s.orderBy;804 if (s.limit !== undefined) ast.limit = s.limit;805 if (s.one) ast.one = true;806 if (s.select && s.select.length > 0) ast.select = s.select;807 if (s.aggregate !== undefined) ast.aggregate = s.aggregate;808 if (s.groupBy.length > 0) ast.groupBy = s.groupBy;809 if (s.having !== undefined) ast.having = s.having;810 // The engine takes the aggregate lowering ONLY when `aggregate` is set (REDUCE-DESIGN.md §8 /811 // builder `build_pipeline`); `groupBy`/`having` on the row spine would be silently ignored. Fail812 // loudly so a forgotten `.count()` is a clear error, not a query that quietly returns all rows.813 if ((ast.groupBy !== undefined || ast.having !== undefined) && ast.aggregate === undefined) {814 throw new Error(815 "groupBy()/having() require count(): a top-level GROUP BY / HAVING is only honored on an " +816 "aggregate query (REDUCE-DESIGN.md §8) — add .count().",817 );818 }819 return ast;820}821822/** Every base table an AST draws rows from — the root plus every related / `EXISTS` subquery (a823 * conservative deep scan for `table` fields, mirroring the optimistic backend's `collectTables`). */824function astTables(ast: Ast): Set<string> {825 const out = new Set<string>();826 const walk = (v: unknown): void => {827 if (Array.isArray(v)) {828 for (const x of v) walk(x);829 } else if (v && typeof v === "object") {830 const o = v as Record<string, unknown>;831 if (typeof o.table === "string") out.add(o.table);832 for (const k of Object.keys(o)) walk(o[k]);833 }834 };835 walk(ast);836 return out;837}838839/** Throw if `ast` references a local-only table ANYWHERE — the complete form of the {@link queries}840 * root guard (Q1 / E3 client half). The root proxy `get` only sees the table accessed off the841 * builder root; a local table reached through a relationship / `sub` / `countAs` / `exists` CHILD is842 * passed as a value and never touches the proxy, so it is caught here instead: at `.ast()` for a843 * server-scope builder, and again as an SSR backstop in `ServerStore.preload`. A server/named query844 * may never name a local table (`201-LOCAL-ONLY-TABLES-DESIGN.md` §5). */845export function assertNoLocalTables<S extends ColsMap>(ast: Ast, schema: Schema<S>): void {846 for (const t of astTables(ast)) {847 if (isLocalTable(schema, t)) {848 throw new Error(849 `local-only table "${t}" may not be used in a server/named query — it was reached through a ` +850 `relationship/subquery; build it from store.query (the local builder) instead ` +851 `(201-LOCAL-ONLY-TABLES-DESIGN.md §5 / Q1 / E3).`,852 );853 }854 }855}856857/** Build a child AST for `sub`/`exists`: a fresh child query, the optional `build`, compiled. */858function childAst(child: AnyTable, build: ((q: unknown) => unknown) | undefined): Ast {859 const cm = child[SCHEMA];860 let cq: unknown = makeQuery(cm, emptyState(cm.name));861 if (build) cq = build(cq);862 return (cq as { ast(): Ast }).ast();863}864865interface ResolvedCorrelated {866 child: AnyTable;867 corr: { parent: string[]; child: string[] };868 build: ((q: unknown) => unknown) | undefined;869 fragment: boolean;870}871872function composeCorrelatedBuild(873 build: ((q: unknown) => unknown) | undefined,874 edge: ((q: unknown) => unknown) | undefined,875): ((q: unknown) => unknown) | undefined {876 if (edge === undefined) return build;877 if (!isFragment(build)) {878 throw new Error("sub(): an edge callback is only supported when the child build is a Fragment.");879 }880 return (q: unknown) => edge(build(q as Query<AnyCols>));881}882883/** Resolve the two `sub`/`countAs`/`exists` call shapes to a common `{ child, corr, build }`:884 * either a named {@link Relationship} (`(rel, build?)`) or an explicit `(child, corr, build?)`. */885function resolveCorrelated(a: AnyTable | AnyRelationship, b: unknown, c: unknown, d?: unknown): ResolvedCorrelated {886 if (isRelationship(a)) {887 return {888 child: a.child as AnyTable,889 corr: { parent: [...a.correlation.parent], child: [...a.correlation.child] },890 build: composeCorrelatedBuild(891 b as ((q: unknown) => unknown) | undefined,892 c as ((q: unknown) => unknown) | undefined,893 ),894 fragment: isFragment(b),895 };896 }897 return {898 child: a,899 corr: b as { parent: string[]; child: string[] },900 build: composeCorrelatedBuild(901 c as ((q: unknown) => unknown) | undefined,902 d as ((q: unknown) => unknown) | undefined,903 ),904 fragment: isFragment(c),905 };906}907908// ----------------------------- the merge pass (`include`, FRAGMENT-COMPOSITION-DESIGN §5) -----------909//910// `include(fragment)` folds a fragment's selection into the CURRENT node (same table). The merge is911// canonical (sorted) so include order is irrelevant — `q.include(A).include(B)` and912// `q.include(B).include(A)` compile to byte-identical ASTs → one `viewKey` → one materialization913// (§10.5). Two fragments that spread the same relationship `alias` are merged recursively; if they914// disagree on what rows that edge selects (correlation / table / where / orderBy / limit / paging /915// aggregate), that is an unresolvable conflict and we throw (§10.2).916917function uniqSort(cols: string[]): string[] {918 return [...new Set(cols)].sort();919}920921/** Merge two same-node *fragment* selections: an empty (absent) `select` means "all columns", so922 * merging "all" with anything stays "all" (§5). Both sides here are fragment-contributed. */923function mergeNestedSelect(a: string[] | undefined, b: string[] | undefined): string[] | undefined {924 if (a === undefined || a.length === 0 || b === undefined || b.length === 0) return undefined;925 return uniqSort([...a, ...b]);926}927928/** Merge a fragment's `select` into the ROOTING query's `select`. A pure **union** — this mirrors929 * the type level (`Sel | FSel`, where a fragment that selects nothing contributes `never`) and keeps930 * `q.include(Frag)` equivalent to applying `Frag(q)`. An absent `select` (on either side) means931 * "contributes no columns", NOT "all columns" — so a relationship-only fragment (e.g. an edge that932 * just `sub`s, no root columns) doesn't blow the projection open. The result is "all columns" (a933 * dropped `select`) only when nothing anywhere selected, i.e. the union is empty. (This differs from934 * {@link mergeNestedSelect}: a *nested* same-alias child is an INTERSECTION of row types at the935 * type level — `Proj<A> & Proj<B>` — so there a no-select side, meaning "all child columns", wins.) */936function mergeRootSelect(baseSel: string[] | undefined, fragSel: string[] | undefined): string[] | undefined {937 const union = uniqSort([...(baseSel ?? []), ...(fragSel ?? [])]);938 return union.length === 0 ? undefined : union;939}940941function aliasOf(csq: CorrelatedSubquery): string {942 return csq.subquery.alias ?? "";943}944945/** A canonical key over everything that defines *which* rows a related edge yields — the fields946 * two same-alias spreads must AGREE on (select + related are merged, so they're excluded). */947function edgeShapeKey(csq: CorrelatedSubquery): string {948 const sq = csq.subquery;949 return stableKey({950 correlation: csq.correlation,951 system: csq.system,952 table: sq.table,953 where: sq.where,954 orderBy: sq.orderBy,955 limit: sq.limit,956 start: sq.start,957 one: sq.one,958 aggregate: sq.aggregate,959 aggregatePrecomputed: sq.aggregatePrecomputed,960 });961}962963/** Merge the `select` + nested `related` of two same-alias subqueries (callers verified the edge964 * shape agrees). Returns a canonical (sorted) AST, omitting empty `select`/`related` like compile. */965function mergeSubqueryAst(base: Ast, frag: Ast): Ast {966 const out: Ast = { ...base };967 const select = mergeNestedSelect(base.select, frag.select);968 if (select === undefined) delete out.select;969 else out.select = select;970 const related = mergeRelatedLists(base.related ?? [], frag.related ?? []);971 if (related.length === 0) delete out.related;972 else out.related = related;973 return out;974}975976/** Merge two related lists by `alias`: same alias ⇒ recurse (after an edge-shape agreement check);977 * distinct aliases ⇒ kept side by side. Output is ordered by alias (canonical, §10.5). */978function mergeRelatedLists(base: CorrelatedSubquery[], add: CorrelatedSubquery[]): CorrelatedSubquery[] {979 const byAlias = new Map<string, CorrelatedSubquery>();980 for (const csq of base) byAlias.set(aliasOf(csq), csq);981 for (const csq of add) {982 const alias = aliasOf(csq);983 const existing = byAlias.get(alias);984 if (existing === undefined) {985 byAlias.set(alias, csq);986 continue;987 }988 if (edgeShapeKey(existing) !== edgeShapeKey(csq)) {989 throw new Error(990 `include(): conflicting definitions for relationship "${alias}" — two fragments spread the ` +991 `same alias with different correlation/table/where/orderBy/limit. Give them distinct aliases ` +992 `or align them (FRAGMENT-COMPOSITION-DESIGN §10.2).`,993 );994 }995 const merged = { ...existing, subquery: mergeSubqueryAst(existing.subquery, csq.subquery) };996 byAlias.set(alias, isFragmentRelationship(existing) || isFragmentRelationship(csq) ? markFragmentRelationship(merged) : merged);997 }998 return [...byAlias.values()].sort((a, b) => {999 const x = aliasOf(a);1000 const y = aliasOf(b);1001 return x < y ? -1 : x > y ? 1 : 0;1002 });1003}10041005/** Fold an `include`d fragment's compiled AST into the current builder {@link State}. An included1006 * fragment contributes only `select` + nested relationships; it may not constrain the node's row1007 * set / window — adding a root `where` (§10.3) or `orderBy`/`limit`/`start`/`one` throws. */1008function mergeIncludedFragment(s: State, frag: Ast): State {1009 if (frag.where !== undefined) {1010 throw new Error(1011 "include(): a fragment may not add a root `where` — only the rooting query filters " +1012 "(FRAGMENT-COMPOSITION-DESIGN §10.3).",1013 );1014 }1015 if (frag.orderBy !== undefined || frag.limit !== undefined || frag.start !== undefined || frag.one) {1016 throw new Error(1017 "include(): a fragment may not set a root `orderBy`/`limit`/`start`/`one` — it contributes only " +1018 "`select` and nested relationships (those define the node's window, which is the rooting query's job).",1019 );1020 }1021 const select = mergeRootSelect(s.select, frag.select);1022 const next: State = { ...s, related: mergeRelatedLists(s.related, frag.related ?? []) };1023 if (select === undefined) delete next.select;1024 else next.select = select;1025 return next;1026}10271028// Internal: the runtime is untyped (Proxy magic); the public `Query<C,Rels>` type is the contract.1029function makeQuery(1030 meta: TableMeta,1031 s: State,1032 onMat?: (query: AnyQuery) => unknown,1033 named?: NamedQueryState,1034 // Set on a SERVER-scope root (`newQueryBuilder` / `includeLocal: false`); validates the finalized1035 // AST has no local-only table (caught even when reached via a child). Threaded to descendants of1036 // the same root so chained `.where`/`.sub`/`.countAs`/stamp keep enforcing it (Q1 / E3).1037 guardAst?: (ast: Ast) => void,1038): unknown {1039 const next = (patch: Partial<State>): unknown => makeQuery(meta, { ...s, ...patch }, onMat, named, guardAst);1040 const applyField = (field: string, arg: unknown) =>1041 next({ wheres: [...s.wheres, fieldCondition(field, arg)] });1042 let proxy: unknown;10431044 const base: Record<string, unknown> = {1045 name: named?.name,1046 args: named?.args,1047 realtime: named?.realtime,1048 where: (cond: Condition) => next({ wheres: [...s.wheres, cond] }),1049 orderBy: (col: string, dir: Dir) => next({ orderBy: [...s.orderBy, [col, dir]] }),1050 select: (...cols: string[]) => next({ select: [...(s.select ?? []), ...cols] }),1051 limit: (n: number) => next({ limit: n }),1052 start: (cursor: Record<string, LitValue>, opts?: { exclusive?: boolean }) =>1053 next({ start: { row: { ...cursor }, exclusive: opts?.exclusive ?? false } }),1054 one: () => next({ one: true }),1055 include: (fragment: Fragment<AnyCols, unknown, string>) => {1056 const fragTable = fragment.table;1057 if (fragTable[SCHEMA].name !== s.table) {1058 throw new Error(1059 `include(): the fragment is over "${fragTable[SCHEMA].name}" but this query is over "${s.table}". ` +1060 `include() merges a fragment into the SAME table — use sub() to nest a different table.`,1061 );1062 }1063 const fragAst = childAst(fragTable as AnyTable, fragment as unknown as (q: unknown) => unknown);1064 return makeQuery(meta, mergeIncludedFragment(s, fragAst), onMat, named, guardAst);1065 },1066 sub: (alias: string, childOrRel: AnyTable | AnyRelationship, b?: unknown, c?: unknown, d?: unknown) => {1067 const { child, corr, build, fragment } = resolveCorrelated(childOrRel, b, c, d);1068 const sub = childAst(child, build);1069 sub.alias = alias;1070 const csq: CorrelatedSubquery = {1071 correlation: { parentField: corr.parent, childField: corr.child },1072 subquery: sub,1073 };1074 return next({ related: [...s.related, fragment ? markFragmentRelationship(csq) : csq] });1075 },1076 countAs: (alias: string, childOrRel: AnyTable | AnyRelationship, b?: unknown, c?: unknown) => {1077 // Same correlated-child mechanism as `sub`, but mark the child a `count` aggregate:1078 // the builder lowers it to a scalar-projected singular relationship (REDUCE-DESIGN §9).1079 const { child, corr, build } = resolveCorrelated(childOrRel, b, c);1080 const sub = childAst(child, build);1081 sub.alias = alias;1082 sub.aggregate = "count";1083 const csq: CorrelatedSubquery = {1084 correlation: { parentField: corr.parent, childField: corr.child },1085 subquery: sub,1086 };1087 return next({ related: [...s.related, csq] });1088 },1089 // Top-level aggregate (REDUCE-DESIGN §8): `count` reshapes this query into a `count(*)`;1090 // `groupBy` partitions it; `having` filters the post-aggregation rows. `having`'s callback gets1091 // a field binder over the aggregate's output columns (group cols + the synthetic `count`), so a1092 // predicate can name `count` — which lives on no base table — exactly like `where.<field>(…)`.1093 count: () => next({ aggregate: "count" }),1094 groupBy: (col: string) => next({ groupBy: [...s.groupBy, col] }),1095 having: (a: unknown, op?: SimpleOp, val?: number) => {1096 // Overload 1 — top-level aggregate HAVING: `having((h) => h.count(gt(3)))`. The callback1097 // binds the aggregate's output columns (group cols + the synthetic `count`).1098 if (typeof a === "function") {1099 const build = a as (h: Record<string, (arg: unknown) => Condition>) => Condition;1100 const h = new Proxy({} as Record<string, (arg: unknown) => Condition>, {1101 get: (_t, prop) => (typeof prop === "string" ? (arg: unknown) => fieldCondition(prop, arg) : undefined),1102 });1103 return next({ having: build(h) });1104 }1105 // Overload 2 — filter THIS parent by a child aggregate's count:1106 // `.having("commentCount", ">", 10)` (PARENT-AGGREGATE-FILTER-DESIGN.md). Bind the existing1107 // `countAs(alias, …)` relationship and push a hidden EXISTS whose subquery clones the same1108 // correlation + child + `count` aggregate, plus a `HAVING count <op> val`. The engine lowers1109 // it to an EXISTS over a HAVING-filtered reduce; the display `countAs` is untouched.1110 const alias = a as string;1111 const display = s.related.find(1112 (r) => r.subquery.alias === alias && r.subquery.aggregate === "count",1113 );1114 if (!display) {1115 throw new Error(1116 `having("${alias}", …): this query has no countAs("${alias}", …) relationship to filter ` +1117 `on — attach the child count aggregate first.`,1118 );1119 }1120 const gate: CorrelatedSubquery = {1121 correlation: display.correlation,1122 subquery: {1123 ...display.subquery,1124 alias: `__having_${alias}`,1125 having: {1126 type: "simple",1127 op: op as SimpleOp,1128 left: { type: "column", name: "count" },1129 right: { type: "literal", value: val as LitValue },1130 },1131 },1132 };1133 return next({ wheres: [...s.wheres, { type: "correlatedSubquery", op: "EXISTS", related: gate }] });1134 },1135 ast: () => {1136 const a = compile(s);1137 guardAst?.(a);1138 return a;1139 },1140 materialize: () => {1141 if (!onMat) throw new Error("materialize() requires a Store — use store.query.<table>");1142 return onMat(proxy as AnyQuery);1143 },1144 };11451146 // `where` is callable AND a field proxy.1147 const whereProxy = new Proxy(base.where as object, {1148 get(_t, prop) {1149 if (typeof prop === "string") return (arg: unknown) => applyField(prop, arg);1150 return undefined;1151 },1152 });11531154 proxy = new Proxy(base, {1155 get(target, prop) {1156 if (prop === "where") return whereProxy;1157 if (prop === STAMP_NAMED_QUERY)1158 return (name: string, args: unknown, realtime?: RealtimeQueryLabel) =>1159 makeQuery(meta, s, onMat, { name, args, realtime }, guardAst);1160 if (typeof prop === "string" && prop.length > 5 && prop.startsWith("where")) {1161 const field = prop[5].toLowerCase() + prop.slice(6);1162 return (arg: unknown) => applyField(field, arg);1163 }1164 return target[prop as string];1165 },1166 });1167 return proxy;1168}11691170// ----------------------------- EXISTS / NOT EXISTS -----------------------------11711172/** Options for `exists` / `notExists`. */1173export interface ExistsOpts {1174 /**1175 * Fold this `EXISTS` as a build-time **scalar** subquery: when the child binds a1176 * statically-unique key, the engine reads it once, inlines the correlation value as a1177 * literal, and deletes the join. **Snapshot semantics** — the inlined value does not1178 * react to later child changes. Defaults to `false` (a live `EXISTS` join).1179 */1180 scalar?: boolean;1181}11821183function existsImpl(1184 child: AnyTable,1185 corr: { parent: string[]; child: string[] },1186 build: ((q: unknown) => unknown) | undefined,1187 op: ExistsOp,1188 // `"permissions"` ⇒ a server-only, non-syncing gate (`exists_noSync`): the normalized1189 // serializer prunes its witnesses so the permission table is never synced to the client.1190 system?: "permissions",1191 opts?: ExistsOpts,1192): Condition {1193 const sub = childAst(child, build);1194 sub.alias = child[SCHEMA].name;1195 const related: CorrelatedSubquery = {1196 correlation: { parentField: corr.parent, childField: corr.child },1197 subquery: sub,1198 };1199 if (system) related.system = system;1200 return {1201 type: "correlatedSubquery",1202 op,1203 related,1204 ...(opts?.scalar ? { scalar: true } : {}),1205 };1206}12071208/** `EXISTS` by a named {@link Relationship} — correlation from the rel; the parent row type is the1209 * relationship's parent, so the condition lands on the matching `.where()`. */1210export function exists<PC extends AnyCols, CC extends AnyCols>(1211 relationship: Relationship<PC, CC>,1212 build?: (q: Query<CC>) => Query<CC, unknown>,1213 opts?: ExistsOpts,1214): Cond<RowOf<PC>>;1215/** `EXISTS (<correlated subquery>)` — a condition (use inside `where`/`or`/`and`). */1216export function exists<CC extends AnyCols, R = unknown>(1217 child: TableLike<CC>,1218 corr: { parent: Array<keyof R & string>; child: Array<keyof CC & string> },1219 build?: (q: Query<CC>) => Query<CC, unknown>,1220 opts?: ExistsOpts,1221): Cond<R>;1222export function exists(a: AnyTable | AnyRelationship, b?: unknown, c?: unknown, d?: unknown): Cond<unknown> {1223 const { child, corr, build } = resolveCorrelated(a, b, c);1224 const opts = (isRelationship(a) ? c : d) as ExistsOpts | undefined;1225 return existsImpl(child, corr, build, "EXISTS", undefined, opts) as Cond<unknown>;1226}12271228/** `NOT EXISTS` by a named {@link Relationship}. */1229export function notExists<PC extends AnyCols, CC extends AnyCols>(1230 relationship: Relationship<PC, CC>,1231 build?: (q: Query<CC>) => Query<CC, unknown>,1232 opts?: ExistsOpts,1233): Cond<RowOf<PC>>;1234/** `NOT EXISTS (<correlated subquery>)`. */1235export function notExists<CC extends AnyCols, R = unknown>(1236 child: TableLike<CC>,1237 corr: { parent: Array<keyof R & string>; child: Array<keyof CC & string> },1238 build?: (q: Query<CC>) => Query<CC, unknown>,1239 opts?: ExistsOpts,1240): Cond<R>;1241export function notExists(a: AnyTable | AnyRelationship, b?: unknown, c?: unknown, d?: unknown): Cond<unknown> {1242 const { child, corr, build } = resolveCorrelated(a, b, c);1243 const opts = (isRelationship(a) ? c : d) as ExistsOpts | undefined;1244 return existsImpl(child, corr, build, "NOT EXISTS", undefined, opts) as Cond<unknown>;1245}12461247/**1248 * `EXISTS (<correlated subquery>)` as a **server-only, non-syncing** gate (`exists_noSync`).1249 * Identical to {@link exists} for filtering parent visibility, but stamps the subquery1250 * `system: "permissions"`, so the engine's normalized serializer prunes its witnesses from the1251 * footprint — the permission table's rows are never synced to the client and the client never1252 * re-evaluates the gate. Use this when building the **server's** query (the user's API server);1253 * the client holds its own un-gated query.1254 */1255export function existsNoSync<PC extends AnyCols, CC extends AnyCols>(1256 relationship: Relationship<PC, CC>,1257 build?: (q: Query<CC>) => Query<CC, unknown>,1258): Cond<RowOf<PC>>;1259export function existsNoSync<CC extends AnyCols, R = unknown>(1260 child: TableLike<CC>,1261 corr: { parent: Array<keyof R & string>; child: Array<keyof CC & string> },1262 build?: (q: Query<CC>) => Query<CC, unknown>,1263): Cond<R>;1264export function existsNoSync(a: AnyTable | AnyRelationship, b?: unknown, c?: unknown): Cond<unknown> {1265 const { child, corr, build } = resolveCorrelated(a, b, c);1266 return existsImpl(child, corr, build, "EXISTS", "permissions") as Cond<unknown>;1267}12681269/** `NOT EXISTS (<correlated subquery>)` as a **server-only, non-syncing** gate — the `NOT EXISTS` form of {@link existsNoSync} (a deny-style permission rule). */1270export function notExistsNoSync<PC extends AnyCols, CC extends AnyCols>(1271 relationship: Relationship<PC, CC>,1272 build?: (q: Query<CC>) => Query<CC, unknown>,1273): Cond<RowOf<PC>>;1274export function notExistsNoSync<CC extends AnyCols, R = unknown>(1275 child: TableLike<CC>,1276 corr: { parent: Array<keyof R & string>; child: Array<keyof CC & string> },1277 build?: (q: Query<CC>) => Query<CC, unknown>,1278): Cond<R>;1279export function notExistsNoSync(a: AnyTable | AnyRelationship, b?: unknown, c?: unknown): Cond<unknown> {1280 const { child, corr, build } = resolveCorrelated(a, b, c);1281 return existsImpl(child, corr, build, "NOT EXISTS", "permissions") as Cond<unknown>;1282}12831284// ----------------------------- the query root (store.query.<table>) -----------------------------12851286export type QueryRoot<S extends ColsMap> = { [N in keyof S]: Query<S[N]> };12871288/** Scoping for {@link queries} (`201-LOCAL-ONLY-TABLES-DESIGN.md` §5). */1289export interface QueriesOptions {1290 /** Include {@link TableMeta.local local-only} tables in the builder's root scope. The **local**1291 * builder (`store.query`) sets this; the **server** builder ({@link newQueryBuilder}) does NOT,1292 * so naming a local table in a remote/named query is a build error (Q1 / E3 client half). */1293 includeLocal?: boolean;1294}12951296/** A typed query entry over a schema: `queries(schema).issue.where.closed(false)…`. */1297export function queries<S extends ColsMap>(1298 schema: Schema<S>,1299 onMaterialize?: (query: AnyQuery) => unknown,1300 opts?: QueriesOptions,1301): QueryRoot<S> {1302 const includeLocal = opts?.includeLocal ?? false;1303 return new Proxy({} as Record<string, unknown>, {1304 get(_t, prop) {1305 if (typeof prop !== "string") return undefined;1306 // Framework/JS introspection probes are never table names — return undefined instead of1307 // throwing so the query root (and a `Store` carrying it) can be safely enumerated by code1308 // that inspects objects. React 19's dev-mode prop diffing reads `$$typeof` on every prop1309 // value; `then` is the thenable check; `toJSON` is JSON serialization. A real typo'd table1310 // (`store.query.isue`) is a plain identifier and still throws below.1311 if (prop[0] === "$" || prop === "then" || prop === "toJSON") return undefined;1312 const meta = schema.tables[prop];1313 if (!meta) throw new Error(`unknown table: ${prop}`);1314 // Server scope: a local-only table is absent (Q1 / E3 client half) — a remote/named query1315 // may never reference one. The local builder (`store.query`) opts in via `includeLocal`.1316 if (meta.local && !includeLocal) {1317 throw new Error(1318 `local-only table "${prop}" may not be used in a server/named query — build it from ` +1319 `store.query (the local builder) and materialize it ad-hoc (201-LOCAL-ONLY-TABLES-DESIGN.md §5 / Q1).`,1320 );1321 }1322 // A SERVER-scope builder (`!includeLocal`) also guards CHILD references: the root proxy never1323 // sees a table passed by value to `sub`/`countAs`/`exists`, so the finalized AST is re-checked1324 // at `.ast()` (Q1 / E3 client half — the complete form of the root check above).1325 const guardAst = includeLocal ? undefined : (ast: Ast) => assertNoLocalTables(ast, schema);1326 return makeQuery(meta, emptyState(meta.name), onMaterialize, undefined, guardAst);1327 },1328 }) as QueryRoot<S>;1329}13301331/** A schema-bound query-builder factory for portable client/server query definitions. SERVER1332 * scope: local-only tables are excluded (a named/remote query may never reference one). */1333export function newQueryBuilder<S extends ColsMap>(schema: Schema<S>): QueryRoot<S> {1334 return queries(schema);1335}1336