Rindle

API index and search · Build metadata

Source snapshot

packages/client/src/query.ts

Source revision 05d0bf2c2e56 · build details
Source revision: 05d0bf2c2e56.
TypeScript input SHA-256: aabe6cfcc4172b870d5e272142958e9ea8d8784c2aa23133156e5a7ee633318e
Generated 2026-09-04T23:58:25.590Z with TypeScript 6.0.3. Public TypeScript checks and declaration emit passed. Package runtime tests are separate.
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