@pramen/server 0.0.41 → 0.0.42
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/dist/runtime/db.d.ts +33 -0
- package/dist/runtime/db.js +147 -13
- package/dist/runtime/read-engine.d.ts +6 -0
- package/dist/runtime/read-engine.js +2 -1
- package/package.json +1 -1
- package/src/runtime/db.ts +155 -12
- package/src/runtime/read-engine.ts +8 -1
package/dist/runtime/db.d.ts
CHANGED
|
@@ -17,6 +17,12 @@ export interface FindSpec<S extends SchemaDef, T extends keyof S> {
|
|
|
17
17
|
offset?: number;
|
|
18
18
|
/** Eager-load relations. Each loaded relation is independently ACL-checked. */
|
|
19
19
|
with?: Partial<Record<keyof RelationsOf<S[T]> & string, true>>;
|
|
20
|
+
/** Fetch only these columns (a projection). Each must be readable — a hidden or
|
|
21
|
+
* unreadable column is a 403, like ordering by one. This narrows the SQL SELECT so a
|
|
22
|
+
* wide `json`/`text` column the handler doesn't need never crosses RPC on the D1 path;
|
|
23
|
+
* the PK, order, and relation-join columns are always fetched internally regardless.
|
|
24
|
+
* (Unselected columns are absent at runtime; the return type is not yet narrowed.) */
|
|
25
|
+
select?: readonly (keyof FieldsOf<S[T]> & string)[];
|
|
20
26
|
}
|
|
21
27
|
/** Cursor (keyset) pagination input — `after` is an opaque cursor from a prior page. */
|
|
22
28
|
export interface PageSpec<S extends SchemaDef, T extends keyof S> {
|
|
@@ -26,6 +32,9 @@ export interface PageSpec<S extends SchemaDef, T extends keyof S> {
|
|
|
26
32
|
limit?: number;
|
|
27
33
|
after?: string;
|
|
28
34
|
with?: Partial<Record<keyof RelationsOf<S[T]> & string, true>>;
|
|
35
|
+
/** Fetch only these columns (see FindSpec.select). Order/cursor columns are always
|
|
36
|
+
* fetched regardless, so a projection never breaks keyset pagination. */
|
|
37
|
+
select?: readonly (keyof FieldsOf<S[T]> & string)[];
|
|
29
38
|
}
|
|
30
39
|
export interface Page<R> {
|
|
31
40
|
items: R[];
|
|
@@ -126,6 +135,30 @@ export declare class Db<S extends SchemaDef = SchemaDef> {
|
|
|
126
135
|
* key, so checking the top-level keys is sufficient. */
|
|
127
136
|
private assertReadableWhere;
|
|
128
137
|
private selectRaw;
|
|
138
|
+
/** All column names of a table (schema order). */
|
|
139
|
+
private allColsOf;
|
|
140
|
+
/** The parent-side join column an eager-load of `relName` reads off each parent row:
|
|
141
|
+
* belongsTo/oneHasOne carry the FK on this row; the others group by this row's PK
|
|
142
|
+
* (which the projection adds separately). Null for an unknown relation. */
|
|
143
|
+
private joinColOf;
|
|
144
|
+
/** Validate a `select` projection: every column must be readable. Hidden columns are
|
|
145
|
+
* never selectable (as with ordering); a base or conditionally-granted column is fine,
|
|
146
|
+
* but a column outside the caller's read grant is a 403. Skipped when the scope is
|
|
147
|
+
* unrestricted or a field-resolver may grant anything. */
|
|
148
|
+
private assertSelectable;
|
|
149
|
+
/** The columns to SELECT for a read over `from` under `scope` — replacing `SELECT *`,
|
|
150
|
+
* which fetches every column (wide `json`/`text` + `hidden()`) and drops them in JS,
|
|
151
|
+
* paying full RPC cost on the D1 path. Names exactly what can surface:
|
|
152
|
+
* - the visible field set: an explicit `select`, else the base readable fields ∪ every
|
|
153
|
+
* conditional-`when` field, else all non-hidden columns when the scope is
|
|
154
|
+
* unrestricted (fields === null) or a field-resolver (fieldsFn) may expose anything;
|
|
155
|
+
* - PLUS the columns the machinery needs regardless of visibility — the PK (identity /
|
|
156
|
+
* relation grouping / cursor), the order-by columns, each eager-loaded relation's
|
|
157
|
+
* parent-side join column, and any cell-`when` input columns — some of which the
|
|
158
|
+
* caller can't read; they're fetched, used, then stripped by projectRow/stripHidden
|
|
159
|
+
* before return.
|
|
160
|
+
* Returns undefined (→ `SELECT *`) only for a schemaless/unknown table. */
|
|
161
|
+
private projectionColumns;
|
|
129
162
|
private jsonColsOf;
|
|
130
163
|
/** Boolean columns — stored as INTEGER 0/1 (SQLite has no boolean), decoded back to
|
|
131
164
|
* true/false on read so handlers see the `boolean` the InferRow type promises. */
|
package/dist/runtime/db.js
CHANGED
|
@@ -33,6 +33,33 @@ function normalizeOrder(orderBy) {
|
|
|
33
33
|
return undefined;
|
|
34
34
|
return (Array.isArray(orderBy) ? orderBy : [orderBy]);
|
|
35
35
|
}
|
|
36
|
+
/** Single-table columns a compiled predicate reads (for cell-`when` grants: the SELECT
|
|
37
|
+
* must fetch a `when`'s input columns or the per-row visibility check can't evaluate).
|
|
38
|
+
* cell-`when` is single-table, so there's never a `sub` node to cross into. */
|
|
39
|
+
function columnsInExpr(expr, out = new Set()) {
|
|
40
|
+
if (!expr)
|
|
41
|
+
return out;
|
|
42
|
+
switch (expr.t) {
|
|
43
|
+
case "cmp":
|
|
44
|
+
case "in":
|
|
45
|
+
case "null":
|
|
46
|
+
case "strmatch":
|
|
47
|
+
out.add(expr.col);
|
|
48
|
+
break;
|
|
49
|
+
case "and":
|
|
50
|
+
case "or":
|
|
51
|
+
for (const p of expr.parts)
|
|
52
|
+
columnsInExpr(p, out);
|
|
53
|
+
break;
|
|
54
|
+
case "not":
|
|
55
|
+
columnsInExpr(expr.expr, out);
|
|
56
|
+
break;
|
|
57
|
+
case "sub":
|
|
58
|
+
out.add(expr.outerCol); // inner `where` is over another table — not this row's columns
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
36
63
|
function encodeCursor(order, row) {
|
|
37
64
|
const vals = order.map((o) => row[o.column]);
|
|
38
65
|
return btoa(JSON.stringify(vals)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
@@ -178,8 +205,12 @@ export class Db {
|
|
|
178
205
|
const orderBy = normalizeOrder(spec.orderBy);
|
|
179
206
|
if (orderBy)
|
|
180
207
|
this.assertReadableCols(from, scope, orderBy.map((o) => o.column));
|
|
181
|
-
const
|
|
182
|
-
|
|
208
|
+
const select = spec.select && spec.select.length > 0 ? [...new Set(spec.select)] : undefined;
|
|
209
|
+
if (select)
|
|
210
|
+
this.assertSelectable(from, scope, select);
|
|
211
|
+
const columns = this.projectionColumns(from, scope, { select, orderBy, withSel: spec.with });
|
|
212
|
+
const raw = await this.selectRaw(from, where, orderBy, spec.limit, spec.offset, columns);
|
|
213
|
+
return (await this.finishRows(from, raw, scope, spec.with, select));
|
|
183
214
|
}
|
|
184
215
|
/** Cursor (keyset) pagination. Stable under inserts/deletes; the PK is appended
|
|
185
216
|
* to `orderBy` as a tiebreaker so the keyset is unique. Returns the page plus an
|
|
@@ -196,14 +227,20 @@ export class Db {
|
|
|
196
227
|
let where = this.readWhere(from, spec.where, scope);
|
|
197
228
|
if (spec.after != null)
|
|
198
229
|
where = and(where, keysetAfter(order, decodeCursor(spec.after)));
|
|
230
|
+
const select = spec.select && spec.select.length > 0 ? [...new Set(spec.select)] : undefined;
|
|
231
|
+
if (select)
|
|
232
|
+
this.assertSelectable(from, scope, select);
|
|
233
|
+
// `order` (incl. the PK tiebreaker) is threaded into the projection, so the cursor
|
|
234
|
+
// can always be encoded from the raw row even when the caller narrowed `select`.
|
|
235
|
+
const columns = this.projectionColumns(from, scope, { select, orderBy: order, withSel: spec.with });
|
|
199
236
|
const limit = spec.limit ?? DEFAULT_PAGE_SIZE;
|
|
200
|
-
const raw = await this.selectRaw(from, where, order, limit + 1); // +1 to detect a next page
|
|
237
|
+
const raw = await this.selectRaw(from, where, order, limit + 1, undefined, columns); // +1 to detect a next page
|
|
201
238
|
const hasMore = raw.length > limit;
|
|
202
239
|
if (hasMore)
|
|
203
240
|
raw.length = limit;
|
|
204
241
|
const last = raw[raw.length - 1];
|
|
205
242
|
const cursor = last ? encodeCursor(order, last) : null; // from raw row (has all order cols)
|
|
206
|
-
const items = (await this.finishRows(from, raw, scope, spec.with));
|
|
243
|
+
const items = (await this.finishRows(from, raw, scope, spec.with, select));
|
|
207
244
|
return { items, cursor, hasMore };
|
|
208
245
|
}
|
|
209
246
|
/** Count rows visible to the caller (ACL read scope applied). */
|
|
@@ -327,10 +364,93 @@ export class Db {
|
|
|
327
364
|
}
|
|
328
365
|
}
|
|
329
366
|
}
|
|
330
|
-
async selectRaw(from, where, orderBy, limit, offset) {
|
|
331
|
-
const { sql, params } = compileSelect({ from, where, orderBy, limit, offset }, this.dialect);
|
|
367
|
+
async selectRaw(from, where, orderBy, limit, offset, columns) {
|
|
368
|
+
const { sql, params } = compileSelect({ from, where, orderBy, limit, offset, columns }, this.dialect);
|
|
332
369
|
return this.decodeRows(from, await this.driver.exec(sql, params));
|
|
333
370
|
}
|
|
371
|
+
/** All column names of a table (schema order). */
|
|
372
|
+
allColsOf(table) {
|
|
373
|
+
const fields = this.schema[table]?.fields;
|
|
374
|
+
return fields ? Object.keys(fields) : [];
|
|
375
|
+
}
|
|
376
|
+
/** The parent-side join column an eager-load of `relName` reads off each parent row:
|
|
377
|
+
* belongsTo/oneHasOne carry the FK on this row; the others group by this row's PK
|
|
378
|
+
* (which the projection adds separately). Null for an unknown relation. */
|
|
379
|
+
joinColOf(from, relName) {
|
|
380
|
+
const rel = this.schema[from]?.relations?.[relName];
|
|
381
|
+
if (!rel)
|
|
382
|
+
return null;
|
|
383
|
+
return rel.kind === "belongsTo" || rel.kind === "oneHasOne" ? rel.column : this.pkOf(from);
|
|
384
|
+
}
|
|
385
|
+
/** Validate a `select` projection: every column must be readable. Hidden columns are
|
|
386
|
+
* never selectable (as with ordering); a base or conditionally-granted column is fine,
|
|
387
|
+
* but a column outside the caller's read grant is a 403. Skipped when the scope is
|
|
388
|
+
* unrestricted or a field-resolver may grant anything. */
|
|
389
|
+
assertSelectable(from, scope, cols) {
|
|
390
|
+
const hidden = new Set(this.hiddenColsOf(from));
|
|
391
|
+
for (const c of cols)
|
|
392
|
+
if (hidden.has(c))
|
|
393
|
+
throw new AclDenied(from, "read", c);
|
|
394
|
+
if (scope.fields === null || scope.fieldsFns.length > 0)
|
|
395
|
+
return;
|
|
396
|
+
const allowed = new Set(scope.fields);
|
|
397
|
+
for (const cg of scope.conditional)
|
|
398
|
+
for (const f of cg.fields)
|
|
399
|
+
allowed.add(f);
|
|
400
|
+
for (const c of cols)
|
|
401
|
+
if (!allowed.has(c))
|
|
402
|
+
throw new AclDenied(from, "read", c);
|
|
403
|
+
}
|
|
404
|
+
/** The columns to SELECT for a read over `from` under `scope` — replacing `SELECT *`,
|
|
405
|
+
* which fetches every column (wide `json`/`text` + `hidden()`) and drops them in JS,
|
|
406
|
+
* paying full RPC cost on the D1 path. Names exactly what can surface:
|
|
407
|
+
* - the visible field set: an explicit `select`, else the base readable fields ∪ every
|
|
408
|
+
* conditional-`when` field, else all non-hidden columns when the scope is
|
|
409
|
+
* unrestricted (fields === null) or a field-resolver (fieldsFn) may expose anything;
|
|
410
|
+
* - PLUS the columns the machinery needs regardless of visibility — the PK (identity /
|
|
411
|
+
* relation grouping / cursor), the order-by columns, each eager-loaded relation's
|
|
412
|
+
* parent-side join column, and any cell-`when` input columns — some of which the
|
|
413
|
+
* caller can't read; they're fetched, used, then stripped by projectRow/stripHidden
|
|
414
|
+
* before return.
|
|
415
|
+
* Returns undefined (→ `SELECT *`) only for a schemaless/unknown table. */
|
|
416
|
+
projectionColumns(from, scope, opts = {}) {
|
|
417
|
+
const all = this.allColsOf(from);
|
|
418
|
+
if (all.length === 0)
|
|
419
|
+
return undefined; // unknown schema — leave as SELECT *
|
|
420
|
+
const hidden = new Set(this.hiddenColsOf(from));
|
|
421
|
+
let visible;
|
|
422
|
+
if (opts.select && scope.fieldsFns.length === 0) {
|
|
423
|
+
visible = opts.select.filter((c) => !hidden.has(c));
|
|
424
|
+
}
|
|
425
|
+
else if (scope.fields === null || scope.fieldsFns.length > 0) {
|
|
426
|
+
visible = all.filter((c) => !hidden.has(c)); // unrestricted / resolver may expose anything
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
const set = new Set(scope.fields);
|
|
430
|
+
for (const cg of scope.conditional)
|
|
431
|
+
for (const f of cg.fields)
|
|
432
|
+
set.add(f);
|
|
433
|
+
visible = [...set].filter((c) => !hidden.has(c));
|
|
434
|
+
}
|
|
435
|
+
const cols = new Set(visible);
|
|
436
|
+
cols.add(this.pkOf(from));
|
|
437
|
+
for (const o of opts.orderBy ?? [])
|
|
438
|
+
cols.add(o.column);
|
|
439
|
+
for (const cg of scope.conditional)
|
|
440
|
+
for (const c of columnsInExpr(cg.when))
|
|
441
|
+
cols.add(c); // cell-`when` inputs
|
|
442
|
+
if (opts.withSel) {
|
|
443
|
+
for (const relName of Object.keys(opts.withSel)) {
|
|
444
|
+
if (!opts.withSel[relName])
|
|
445
|
+
continue;
|
|
446
|
+
const jc = this.joinColOf(from, relName);
|
|
447
|
+
if (jc)
|
|
448
|
+
cols.add(jc);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const allSet = new Set(all);
|
|
452
|
+
return [...cols].filter((c) => allSet.has(c)); // only ever name real columns
|
|
453
|
+
}
|
|
334
454
|
// --- JSON codec: a `json` or `fileRef` column is stored as a JSON TEXT cell but
|
|
335
455
|
// handlers see/write the parsed value. Decode on read, encode (stringify) on write. ---
|
|
336
456
|
jsonColsOf(table) {
|
|
@@ -480,15 +600,23 @@ export class Db {
|
|
|
480
600
|
const { sql, params } = compileSelect({ from, where, limit: 1 }, this.dialect);
|
|
481
601
|
return this.decodeRow(from, (await this.driver.exec(sql, params))[0]);
|
|
482
602
|
}
|
|
483
|
-
async finishRows(from, raw, scope, withSel) {
|
|
603
|
+
async finishRows(from, raw, scope, withSel, select) {
|
|
484
604
|
const relNames = withSel ? Object.keys(withSel).filter((k) => withSel[k]) : [];
|
|
485
605
|
for (const relName of relNames)
|
|
486
606
|
await this.loadRelation(from, raw, relName);
|
|
487
|
-
|
|
488
|
-
if (scope.fields === null)
|
|
489
|
-
return raw.map((r) => this.stripHidden(from, r));
|
|
607
|
+
const sel = select && select.length > 0 ? new Set(select) : undefined;
|
|
490
608
|
return raw.map((r) => {
|
|
491
|
-
|
|
609
|
+
// Per-row visible fields = ACL effective set (null = all), then narrowed to the
|
|
610
|
+
// caller's `select`. Hidden columns are stripped even under unrestricted/SYSTEM.
|
|
611
|
+
const eff = effectiveFields(scope, r, this.acl.identity);
|
|
612
|
+
let base;
|
|
613
|
+
if (eff === null && !sel)
|
|
614
|
+
base = r;
|
|
615
|
+
else {
|
|
616
|
+
const fields = eff === null ? [...sel] : sel ? eff.filter((f) => sel.has(f)) : eff;
|
|
617
|
+
base = projectRow(r, fields);
|
|
618
|
+
}
|
|
619
|
+
const projected = this.stripHidden(from, base);
|
|
492
620
|
for (const relName of relNames)
|
|
493
621
|
projected[relName] = r[relName]; // relations survive projection
|
|
494
622
|
return projected;
|
|
@@ -528,7 +656,11 @@ export class Db {
|
|
|
528
656
|
if (values.length === 0)
|
|
529
657
|
return [];
|
|
530
658
|
const where = scope.where ? and(inList(col, values), scope.where) : inList(col, values);
|
|
531
|
-
|
|
659
|
+
// Project the target to its readable columns (+ the join `col`, matched before
|
|
660
|
+
// projecting) so a wide relation target isn't shipped whole over RPC either.
|
|
661
|
+
const cols = this.projectionColumns(rel.target, scope);
|
|
662
|
+
const columns = cols ? [...new Set([...cols, col])] : undefined;
|
|
663
|
+
const { sql, params } = compileSelect({ from: rel.target, where, columns }, this.dialect);
|
|
532
664
|
const rows = this.decodeRows(rel.target, await this.driver.exec(sql, params));
|
|
533
665
|
return rows.map((row) => ({ key: row[col], row: project(row) }));
|
|
534
666
|
};
|
|
@@ -565,7 +697,9 @@ export class Db {
|
|
|
565
697
|
return;
|
|
566
698
|
}
|
|
567
699
|
this.touched.add(rel.through);
|
|
568
|
-
|
|
700
|
+
// The junction is read for ONLY its two link columns (source→target); its other
|
|
701
|
+
// columns are irrelevant to the join and needn't cross RPC.
|
|
702
|
+
const linkSel = compileSelect({ from: rel.through, where: inList(rel.sourceColumn, parentIds), columns: [rel.sourceColumn, rel.targetColumn] }, this.dialect);
|
|
569
703
|
const links = this.decodeRows(rel.through, await this.driver.exec(linkSel.sql, linkSel.params));
|
|
570
704
|
const targetIds = [...new Set(links.map((l) => l[rel.targetColumn]).filter((v) => v != null))];
|
|
571
705
|
const byTarget = new Map();
|
|
@@ -77,6 +77,12 @@ export interface QuerySpec {
|
|
|
77
77
|
readonly orderBy?: OrderBy[];
|
|
78
78
|
readonly limit?: number;
|
|
79
79
|
readonly offset?: number;
|
|
80
|
+
/** Explicit projection: the columns to SELECT (already validated schema keys, quoted
|
|
81
|
+
* here). When absent, falls back to `SELECT *`. The Db layer computes this from the
|
|
82
|
+
* caller's readable field set (+ the PK / order / relation-join columns the read
|
|
83
|
+
* machinery needs), so wide `json`/`text` and `hidden()` columns don't cross RPC on
|
|
84
|
+
* the D1 path — instead of `SELECT *` then dropping them in JS. */
|
|
85
|
+
readonly columns?: readonly string[];
|
|
80
86
|
}
|
|
81
87
|
export type AggFn = "count" | "sum" | "avg" | "min" | "max";
|
|
82
88
|
export declare function compileCount(from: string, dialect: Dialect, where?: SqlExpr): CompiledSql;
|
|
@@ -249,7 +249,8 @@ export function compileAggregate(spec, dialect) {
|
|
|
249
249
|
}
|
|
250
250
|
export function compileSelect(spec, dialect) {
|
|
251
251
|
const params = [];
|
|
252
|
-
|
|
252
|
+
const cols = spec.columns && spec.columns.length > 0 ? spec.columns.map((c) => dialect.id(c)).join(", ") : "*";
|
|
253
|
+
let sql = `SELECT ${cols} FROM ${dialect.id(spec.from)}`;
|
|
253
254
|
if (spec.where && spec.where.t !== "true") {
|
|
254
255
|
const { sql: where } = compileExpr(spec.where, dialect, params);
|
|
255
256
|
sql += ` WHERE ${where}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.42",
|
|
4
4
|
"description": "pramen server runtime — schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/src/runtime/db.ts
CHANGED
|
@@ -69,6 +69,32 @@ function normalizeOrder(orderBy: unknown): OrderBy[] | undefined {
|
|
|
69
69
|
return (Array.isArray(orderBy) ? orderBy : [orderBy]) as OrderBy[];
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** Single-table columns a compiled predicate reads (for cell-`when` grants: the SELECT
|
|
73
|
+
* must fetch a `when`'s input columns or the per-row visibility check can't evaluate).
|
|
74
|
+
* cell-`when` is single-table, so there's never a `sub` node to cross into. */
|
|
75
|
+
function columnsInExpr(expr: SqlExpr | null | undefined, out: Set<string> = new Set()): Set<string> {
|
|
76
|
+
if (!expr) return out;
|
|
77
|
+
switch (expr.t) {
|
|
78
|
+
case "cmp":
|
|
79
|
+
case "in":
|
|
80
|
+
case "null":
|
|
81
|
+
case "strmatch":
|
|
82
|
+
out.add(expr.col);
|
|
83
|
+
break;
|
|
84
|
+
case "and":
|
|
85
|
+
case "or":
|
|
86
|
+
for (const p of expr.parts) columnsInExpr(p, out);
|
|
87
|
+
break;
|
|
88
|
+
case "not":
|
|
89
|
+
columnsInExpr(expr.expr, out);
|
|
90
|
+
break;
|
|
91
|
+
case "sub":
|
|
92
|
+
out.add(expr.outerCol); // inner `where` is over another table — not this row's columns
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
72
98
|
type OrderSpec<S extends SchemaDef, T extends keyof S> = {
|
|
73
99
|
column: keyof FieldsOf<S[T]> & string;
|
|
74
100
|
dir?: "asc" | "desc";
|
|
@@ -82,6 +108,12 @@ export interface FindSpec<S extends SchemaDef, T extends keyof S> {
|
|
|
82
108
|
offset?: number;
|
|
83
109
|
/** Eager-load relations. Each loaded relation is independently ACL-checked. */
|
|
84
110
|
with?: Partial<Record<keyof RelationsOf<S[T]> & string, true>>;
|
|
111
|
+
/** Fetch only these columns (a projection). Each must be readable — a hidden or
|
|
112
|
+
* unreadable column is a 403, like ordering by one. This narrows the SQL SELECT so a
|
|
113
|
+
* wide `json`/`text` column the handler doesn't need never crosses RPC on the D1 path;
|
|
114
|
+
* the PK, order, and relation-join columns are always fetched internally regardless.
|
|
115
|
+
* (Unselected columns are absent at runtime; the return type is not yet narrowed.) */
|
|
116
|
+
select?: readonly (keyof FieldsOf<S[T]> & string)[];
|
|
85
117
|
}
|
|
86
118
|
|
|
87
119
|
/** Cursor (keyset) pagination input — `after` is an opaque cursor from a prior page. */
|
|
@@ -92,6 +124,9 @@ export interface PageSpec<S extends SchemaDef, T extends keyof S> {
|
|
|
92
124
|
limit?: number;
|
|
93
125
|
after?: string;
|
|
94
126
|
with?: Partial<Record<keyof RelationsOf<S[T]> & string, true>>;
|
|
127
|
+
/** Fetch only these columns (see FindSpec.select). Order/cursor columns are always
|
|
128
|
+
* fetched regardless, so a projection never breaks keyset pagination. */
|
|
129
|
+
select?: readonly (keyof FieldsOf<S[T]> & string)[];
|
|
95
130
|
}
|
|
96
131
|
|
|
97
132
|
export interface Page<R> {
|
|
@@ -284,8 +319,11 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
284
319
|
const where = this.readWhere(from, spec.where, scope);
|
|
285
320
|
const orderBy = normalizeOrder(spec.orderBy);
|
|
286
321
|
if (orderBy) this.assertReadableCols(from, scope, orderBy.map((o) => o.column));
|
|
287
|
-
const
|
|
288
|
-
|
|
322
|
+
const select = spec.select && spec.select.length > 0 ? [...new Set(spec.select as readonly string[])] : undefined;
|
|
323
|
+
if (select) this.assertSelectable(from, scope, select);
|
|
324
|
+
const columns = this.projectionColumns(from, scope, { select, orderBy, withSel: spec.with as Selected });
|
|
325
|
+
const raw = await this.selectRaw(from, where, orderBy, spec.limit, spec.offset, columns);
|
|
326
|
+
return (await this.finishRows(from, raw, scope, spec.with as Selected, select)) as (InferRow<FieldsOf<S[T]>> &
|
|
289
327
|
RelationsResult<S, T>)[];
|
|
290
328
|
}
|
|
291
329
|
|
|
@@ -306,14 +344,20 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
306
344
|
let where = this.readWhere(from, spec.where, scope);
|
|
307
345
|
if (spec.after != null) where = and(where, keysetAfter(order, decodeCursor(spec.after)));
|
|
308
346
|
|
|
347
|
+
const select = spec.select && spec.select.length > 0 ? [...new Set(spec.select as readonly string[])] : undefined;
|
|
348
|
+
if (select) this.assertSelectable(from, scope, select);
|
|
349
|
+
// `order` (incl. the PK tiebreaker) is threaded into the projection, so the cursor
|
|
350
|
+
// can always be encoded from the raw row even when the caller narrowed `select`.
|
|
351
|
+
const columns = this.projectionColumns(from, scope, { select, orderBy: order, withSel: spec.with as Selected });
|
|
352
|
+
|
|
309
353
|
const limit = spec.limit ?? DEFAULT_PAGE_SIZE;
|
|
310
|
-
const raw = await this.selectRaw(from, where, order, limit + 1); // +1 to detect a next page
|
|
354
|
+
const raw = await this.selectRaw(from, where, order, limit + 1, undefined, columns); // +1 to detect a next page
|
|
311
355
|
const hasMore = raw.length > limit;
|
|
312
356
|
if (hasMore) raw.length = limit;
|
|
313
357
|
|
|
314
358
|
const last = raw[raw.length - 1];
|
|
315
359
|
const cursor = last ? encodeCursor(order, last) : null; // from raw row (has all order cols)
|
|
316
|
-
const items = (await this.finishRows(from, raw, scope, spec.with as Selected)) as (InferRow<FieldsOf<S[T]>> &
|
|
360
|
+
const items = (await this.finishRows(from, raw, scope, spec.with as Selected, select)) as (InferRow<FieldsOf<S[T]>> &
|
|
317
361
|
RelationsResult<S, T>)[];
|
|
318
362
|
return { items, cursor, hasMore };
|
|
319
363
|
}
|
|
@@ -440,11 +484,93 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
440
484
|
}
|
|
441
485
|
}
|
|
442
486
|
|
|
443
|
-
private async selectRaw(
|
|
444
|
-
|
|
487
|
+
private async selectRaw(
|
|
488
|
+
from: string,
|
|
489
|
+
where: SqlExpr,
|
|
490
|
+
orderBy?: OrderBy[],
|
|
491
|
+
limit?: number,
|
|
492
|
+
offset?: number,
|
|
493
|
+
columns?: readonly string[],
|
|
494
|
+
): Promise<Row[]> {
|
|
495
|
+
const { sql, params } = compileSelect({ from, where, orderBy, limit, offset, columns }, this.dialect);
|
|
445
496
|
return this.decodeRows(from, await this.driver.exec(sql, params));
|
|
446
497
|
}
|
|
447
498
|
|
|
499
|
+
/** All column names of a table (schema order). */
|
|
500
|
+
private allColsOf(table: string): string[] {
|
|
501
|
+
const fields = this.schema[table]?.fields;
|
|
502
|
+
return fields ? Object.keys(fields) : [];
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** The parent-side join column an eager-load of `relName` reads off each parent row:
|
|
506
|
+
* belongsTo/oneHasOne carry the FK on this row; the others group by this row's PK
|
|
507
|
+
* (which the projection adds separately). Null for an unknown relation. */
|
|
508
|
+
private joinColOf(from: string, relName: string): string | null {
|
|
509
|
+
const rel = this.schema[from]?.relations?.[relName] as RelationDef | undefined;
|
|
510
|
+
if (!rel) return null;
|
|
511
|
+
return rel.kind === "belongsTo" || rel.kind === "oneHasOne" ? rel.column : this.pkOf(from);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Validate a `select` projection: every column must be readable. Hidden columns are
|
|
515
|
+
* never selectable (as with ordering); a base or conditionally-granted column is fine,
|
|
516
|
+
* but a column outside the caller's read grant is a 403. Skipped when the scope is
|
|
517
|
+
* unrestricted or a field-resolver may grant anything. */
|
|
518
|
+
private assertSelectable(from: string, scope: Scope, cols: readonly string[]): void {
|
|
519
|
+
const hidden = new Set(this.hiddenColsOf(from));
|
|
520
|
+
for (const c of cols) if (hidden.has(c)) throw new AclDenied(from, "read", c);
|
|
521
|
+
if (scope.fields === null || scope.fieldsFns.length > 0) return;
|
|
522
|
+
const allowed = new Set(scope.fields);
|
|
523
|
+
for (const cg of scope.conditional) for (const f of cg.fields) allowed.add(f);
|
|
524
|
+
for (const c of cols) if (!allowed.has(c)) throw new AclDenied(from, "read", c);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The columns to SELECT for a read over `from` under `scope` — replacing `SELECT *`,
|
|
528
|
+
* which fetches every column (wide `json`/`text` + `hidden()`) and drops them in JS,
|
|
529
|
+
* paying full RPC cost on the D1 path. Names exactly what can surface:
|
|
530
|
+
* - the visible field set: an explicit `select`, else the base readable fields ∪ every
|
|
531
|
+
* conditional-`when` field, else all non-hidden columns when the scope is
|
|
532
|
+
* unrestricted (fields === null) or a field-resolver (fieldsFn) may expose anything;
|
|
533
|
+
* - PLUS the columns the machinery needs regardless of visibility — the PK (identity /
|
|
534
|
+
* relation grouping / cursor), the order-by columns, each eager-loaded relation's
|
|
535
|
+
* parent-side join column, and any cell-`when` input columns — some of which the
|
|
536
|
+
* caller can't read; they're fetched, used, then stripped by projectRow/stripHidden
|
|
537
|
+
* before return.
|
|
538
|
+
* Returns undefined (→ `SELECT *`) only for a schemaless/unknown table. */
|
|
539
|
+
private projectionColumns(
|
|
540
|
+
from: string,
|
|
541
|
+
scope: Scope,
|
|
542
|
+
opts: { select?: readonly string[]; orderBy?: OrderBy[]; withSel?: Selected } = {},
|
|
543
|
+
): readonly string[] | undefined {
|
|
544
|
+
const all = this.allColsOf(from);
|
|
545
|
+
if (all.length === 0) return undefined; // unknown schema — leave as SELECT *
|
|
546
|
+
const hidden = new Set(this.hiddenColsOf(from));
|
|
547
|
+
|
|
548
|
+
let visible: string[];
|
|
549
|
+
if (opts.select && scope.fieldsFns.length === 0) {
|
|
550
|
+
visible = opts.select.filter((c) => !hidden.has(c));
|
|
551
|
+
} else if (scope.fields === null || scope.fieldsFns.length > 0) {
|
|
552
|
+
visible = all.filter((c) => !hidden.has(c)); // unrestricted / resolver may expose anything
|
|
553
|
+
} else {
|
|
554
|
+
const set = new Set(scope.fields);
|
|
555
|
+
for (const cg of scope.conditional) for (const f of cg.fields) set.add(f);
|
|
556
|
+
visible = [...set].filter((c) => !hidden.has(c));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const cols = new Set<string>(visible);
|
|
560
|
+
cols.add(this.pkOf(from));
|
|
561
|
+
for (const o of opts.orderBy ?? []) cols.add(o.column);
|
|
562
|
+
for (const cg of scope.conditional) for (const c of columnsInExpr(cg.when)) cols.add(c); // cell-`when` inputs
|
|
563
|
+
if (opts.withSel) {
|
|
564
|
+
for (const relName of Object.keys(opts.withSel)) {
|
|
565
|
+
if (!opts.withSel[relName]) continue;
|
|
566
|
+
const jc = this.joinColOf(from, relName);
|
|
567
|
+
if (jc) cols.add(jc);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
const allSet = new Set(all);
|
|
571
|
+
return [...cols].filter((c) => allSet.has(c)); // only ever name real columns
|
|
572
|
+
}
|
|
573
|
+
|
|
448
574
|
// --- JSON codec: a `json` or `fileRef` column is stored as a JSON TEXT cell but
|
|
449
575
|
// handlers see/write the parsed value. Decode on read, encode (stringify) on write. ---
|
|
450
576
|
|
|
@@ -593,13 +719,21 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
593
719
|
return this.decodeRow(from, (await this.driver.exec(sql, params))[0] as Row | undefined);
|
|
594
720
|
}
|
|
595
721
|
|
|
596
|
-
private async finishRows(from: string, raw: Row[], scope: Scope, withSel: Selected): Promise<Row[]> {
|
|
722
|
+
private async finishRows(from: string, raw: Row[], scope: Scope, withSel: Selected, select?: readonly string[]): Promise<Row[]> {
|
|
597
723
|
const relNames = withSel ? Object.keys(withSel).filter((k) => withSel[k]) : [];
|
|
598
724
|
for (const relName of relNames) await this.loadRelation(from, raw, relName);
|
|
599
|
-
|
|
600
|
-
if (scope.fields === null) return raw.map((r) => this.stripHidden(from, r));
|
|
725
|
+
const sel = select && select.length > 0 ? new Set(select) : undefined;
|
|
601
726
|
return raw.map((r) => {
|
|
602
|
-
|
|
727
|
+
// Per-row visible fields = ACL effective set (null = all), then narrowed to the
|
|
728
|
+
// caller's `select`. Hidden columns are stripped even under unrestricted/SYSTEM.
|
|
729
|
+
const eff = effectiveFields(scope, r, this.acl.identity);
|
|
730
|
+
let base: Row;
|
|
731
|
+
if (eff === null && !sel) base = r;
|
|
732
|
+
else {
|
|
733
|
+
const fields = eff === null ? [...sel!] : sel ? eff.filter((f) => sel.has(f)) : eff;
|
|
734
|
+
base = projectRow(r, fields);
|
|
735
|
+
}
|
|
736
|
+
const projected = this.stripHidden(from, base);
|
|
603
737
|
for (const relName of relNames) projected[relName] = r[relName]; // relations survive projection
|
|
604
738
|
return projected;
|
|
605
739
|
});
|
|
@@ -636,7 +770,11 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
636
770
|
const fetchBy = async (col: string, values: unknown[]): Promise<Array<{ key: unknown; row: Row }>> => {
|
|
637
771
|
if (values.length === 0) return [];
|
|
638
772
|
const where = scope.where ? and(inList(col, values), scope.where) : inList(col, values);
|
|
639
|
-
|
|
773
|
+
// Project the target to its readable columns (+ the join `col`, matched before
|
|
774
|
+
// projecting) so a wide relation target isn't shipped whole over RPC either.
|
|
775
|
+
const cols = this.projectionColumns(rel.target, scope);
|
|
776
|
+
const columns = cols ? [...new Set([...cols, col])] : undefined;
|
|
777
|
+
const { sql, params } = compileSelect({ from: rel.target, where, columns }, this.dialect);
|
|
640
778
|
const rows = this.decodeRows(rel.target, await this.driver.exec(sql, params));
|
|
641
779
|
return rows.map((row) => ({ key: row[col], row: project(row) }));
|
|
642
780
|
};
|
|
@@ -666,7 +804,12 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
666
804
|
return;
|
|
667
805
|
}
|
|
668
806
|
this.touched.add(rel.through);
|
|
669
|
-
|
|
807
|
+
// The junction is read for ONLY its two link columns (source→target); its other
|
|
808
|
+
// columns are irrelevant to the join and needn't cross RPC.
|
|
809
|
+
const linkSel = compileSelect(
|
|
810
|
+
{ from: rel.through, where: inList(rel.sourceColumn, parentIds), columns: [rel.sourceColumn, rel.targetColumn] },
|
|
811
|
+
this.dialect,
|
|
812
|
+
);
|
|
670
813
|
const links = this.decodeRows(rel.through, await this.driver.exec(linkSel.sql, linkSel.params));
|
|
671
814
|
const targetIds = [...new Set(links.map((l) => l[rel.targetColumn]).filter((v) => v != null))];
|
|
672
815
|
const byTarget = new Map<unknown, Row>();
|
|
@@ -233,6 +233,12 @@ export interface QuerySpec {
|
|
|
233
233
|
readonly orderBy?: OrderBy[];
|
|
234
234
|
readonly limit?: number;
|
|
235
235
|
readonly offset?: number;
|
|
236
|
+
/** Explicit projection: the columns to SELECT (already validated schema keys, quoted
|
|
237
|
+
* here). When absent, falls back to `SELECT *`. The Db layer computes this from the
|
|
238
|
+
* caller's readable field set (+ the PK / order / relation-join columns the read
|
|
239
|
+
* machinery needs), so wide `json`/`text` and `hidden()` columns don't cross RPC on
|
|
240
|
+
* the D1 path — instead of `SELECT *` then dropping them in JS. */
|
|
241
|
+
readonly columns?: readonly string[];
|
|
236
242
|
}
|
|
237
243
|
|
|
238
244
|
export type AggFn = "count" | "sum" | "avg" | "min" | "max";
|
|
@@ -274,7 +280,8 @@ export function compileAggregate(
|
|
|
274
280
|
|
|
275
281
|
export function compileSelect(spec: QuerySpec, dialect: Dialect): CompiledSql {
|
|
276
282
|
const params: unknown[] = [];
|
|
277
|
-
|
|
283
|
+
const cols = spec.columns && spec.columns.length > 0 ? spec.columns.map((c) => dialect.id(c)).join(", ") : "*";
|
|
284
|
+
let sql = `SELECT ${cols} FROM ${dialect.id(spec.from)}`;
|
|
278
285
|
|
|
279
286
|
if (spec.where && spec.where.t !== "true") {
|
|
280
287
|
const { sql: where } = compileExpr(spec.where, dialect, params);
|