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