@pramen/server 0.0.40 → 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.
@@ -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. */
@@ -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 raw = await this.selectRaw(from, where, orderBy, spec.limit, spec.offset);
182
- return (await this.finishRows(from, raw, scope, spec.with));
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
- // Hidden columns are stripped even under an unrestricted/SYSTEM scope (never readable).
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
- const projected = this.stripHidden(from, projectRow(r, effectiveFields(scope, r, this.acl.identity)));
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
- const { sql, params } = compileSelect({ from: rel.target, where }, this.dialect);
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
- const linkSel = compileSelect({ from: rel.through, where: inList(rel.sourceColumn, parentIds) }, this.dialect);
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
- let sql = `SELECT * FROM ${dialect.id(spec.from)}`;
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.40",
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 raw = await this.selectRaw(from, where, orderBy, spec.limit, spec.offset);
288
- return (await this.finishRows(from, raw, scope, spec.with as Selected)) as (InferRow<FieldsOf<S[T]>> &
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(from: string, where: SqlExpr, orderBy?: OrderBy[], limit?: number, offset?: number): Promise<Row[]> {
444
- const { sql, params } = compileSelect({ from, where, orderBy, limit, offset }, this.dialect);
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
- // Hidden columns are stripped even under an unrestricted/SYSTEM scope (never readable).
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
- const projected = this.stripHidden(from, projectRow(r, effectiveFields(scope, r, this.acl.identity)));
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
- const { sql, params } = compileSelect({ from: rel.target, where }, this.dialect);
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
- const linkSel = compileSelect({ from: rel.through, where: inList(rel.sourceColumn, parentIds) }, this.dialect);
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
- let sql = `SELECT * FROM ${dialect.id(spec.from)}`;
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);