@pramen/server 0.0.7 → 0.0.8

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/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, defaultTo, primaryKey, generated, expr, ExprDefault } from "./sdk/schema";
1
+ export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault } from "./sdk/schema";
2
2
  export { isValidUuid } from "./sdk/uuid";
3
3
  export type { DefaultValue, FieldType, FieldDef, EntityFields, EntityDef, SchemaDef, RelationDef, RelationDefs, BelongsToDef, HasManyDef, } from "./sdk/schema";
4
4
  export { createApp } from "./sdk/app";
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // which only exists in the Workers runtime; keeping it separate lets the CLI, tests,
8
8
  // and codegen load an app.ts for its schema without dragging in the DO runtime.
9
9
  // --- schema authoring ---
10
- export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, defaultTo, primaryKey, generated, expr, ExprDefault } from "./sdk/schema";
10
+ export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault } from "./sdk/schema";
11
11
  export { isValidUuid } from "./sdk/uuid";
12
12
  // --- app + handlers ---
13
13
  export { createApp } from "./sdk/app";
@@ -120,6 +120,10 @@ export declare class Db<S extends SchemaDef = SchemaDef> {
120
120
  private assertReadableWhere;
121
121
  private selectRaw;
122
122
  private jsonColsOf;
123
+ /** Columns marked `hidden()` — never projected on an ORM read (even SYSTEM/full). */
124
+ private hiddenColsOf;
125
+ /** Drop hidden columns from a row (copying only if any are present). */
126
+ private stripHidden;
123
127
  /** Mint a UUID for every `generated()` uuid column the caller omitted, mutating
124
128
  * `vals`. Returns the columns it filled — server-minted, so the insert path treats
125
129
  * them like forced `set` values (bypassing the writable-field ACL check). */
@@ -145,7 +149,8 @@ export declare class Db<S extends SchemaDef = SchemaDef> {
145
149
  * would: the caller's readable fields for this row, PLUS the columns they just
146
150
  * wrote (which they already know) and the primary key (so a write-only caller
147
151
  * still gets the generated id). Full read access -> the whole row; SYSTEM -> as-is.
148
- * This makes create/update echoes field-ACL-safe without ever collapsing to {}. */
152
+ * This makes create/update echoes field-ACL-safe without ever collapsing to {}.
153
+ * Hidden columns are never echoed, even when written or under SYSTEM. */
149
154
  private projectWrite;
150
155
  /** Update a row by id. ACL row-scope is AND-ed into the WHERE, so a caller can
151
156
  * only update rows within scope; returns undefined if none matched. */
@@ -264,6 +264,25 @@ export class Db {
264
264
  .filter(([, f]) => f.type === "json" || f.type === "fileRef")
265
265
  .map(([n]) => n);
266
266
  }
267
+ /** Columns marked `hidden()` — never projected on an ORM read (even SYSTEM/full). */
268
+ hiddenColsOf(table) {
269
+ const fields = this.schema[table]?.fields;
270
+ if (!fields)
271
+ return [];
272
+ return Object.entries(fields)
273
+ .filter(([, f]) => f.hidden)
274
+ .map(([n]) => n);
275
+ }
276
+ /** Drop hidden columns from a row (copying only if any are present). */
277
+ stripHidden(table, row) {
278
+ const hidden = this.hiddenColsOf(table);
279
+ if (hidden.length === 0)
280
+ return row;
281
+ const out = { ...row };
282
+ for (const c of hidden)
283
+ delete out[c];
284
+ return out;
285
+ }
267
286
  /** Mint a UUID for every `generated()` uuid column the caller omitted, mutating
268
287
  * `vals`. Returns the columns it filled — server-minted, so the insert path treats
269
288
  * them like forced `set` values (bypassing the writable-field ACL check). */
@@ -325,7 +344,8 @@ export class Db {
325
344
  }
326
345
  /** Fetch one row by id within an ACL row-scope (for per-row write evaluation). */
327
346
  async fetchOne(from, id, scopeWhere) {
328
- const where = scopeWhere ? and(eq("id", id), scopeWhere) : eq("id", id);
347
+ const pk = this.pkOf(from);
348
+ const where = scopeWhere ? and(eq(pk, id), scopeWhere) : eq(pk, id);
329
349
  const { sql, params } = compileSelect({ from, where, limit: 1 }, this.dialect);
330
350
  return this.decodeRow(from, (await this.driver.exec(sql, params))[0]);
331
351
  }
@@ -333,10 +353,11 @@ export class Db {
333
353
  const relNames = withSel ? Object.keys(withSel).filter((k) => withSel[k]) : [];
334
354
  for (const relName of relNames)
335
355
  await this.loadRelation(from, raw, relName);
356
+ // Hidden columns are stripped even under an unrestricted/SYSTEM scope (never readable).
336
357
  if (scope.fields === null)
337
- return raw; // base unrestricted -> no per-row narrowing possible
358
+ return raw.map((r) => this.stripHidden(from, r));
338
359
  return raw.map((r) => {
339
- const projected = projectRow(r, effectiveFields(scope, r, this.acl.identity));
360
+ const projected = this.stripHidden(from, projectRow(r, effectiveFields(scope, r, this.acl.identity)));
340
361
  for (const relName of relNames)
341
362
  projected[relName] = r[relName]; // relations survive projection
342
363
  return projected;
@@ -382,7 +403,7 @@ export class Db {
382
403
  // parent[column] -> target.id
383
404
  const keys = [...new Set(rows.map((r) => r[rel.column]).filter((v) => v != null))];
384
405
  const byId = new Map();
385
- for (const { key, row } of await fetchBy("id", keys))
406
+ for (const { key, row } of await fetchBy(this.pkOf(rel.target), keys))
386
407
  byId.set(key, row);
387
408
  for (const r of rows)
388
409
  r[relName] = r[rel.column] != null ? (byId.get(r[rel.column]) ?? null) : null;
@@ -428,19 +449,22 @@ export class Db {
428
449
  * would: the caller's readable fields for this row, PLUS the columns they just
429
450
  * wrote (which they already know) and the primary key (so a write-only caller
430
451
  * still gets the generated id). Full read access -> the whole row; SYSTEM -> as-is.
431
- * This makes create/update echoes field-ACL-safe without ever collapsing to {}. */
452
+ * This makes create/update echoes field-ACL-safe without ever collapsing to {}.
453
+ * Hidden columns are never echoed, even when written or under SYSTEM. */
432
454
  projectWrite(table, row, writtenCols) {
433
455
  if (this.acl.system)
434
- return row;
456
+ return this.stripHidden(table, row);
435
457
  const visible = new Set([this.pkOf(table), ...writtenCols]);
436
458
  const readScope = this.scopeFor(table, "read");
437
459
  if (readScope.allowed) {
438
460
  const readable = effectiveFields(readScope, row, this.acl.identity);
439
461
  if (readable === null)
440
- return row; // unrestricted read -> echo everything
462
+ return this.stripHidden(table, row); // unrestricted read -> echo everything (minus hidden)
441
463
  for (const f of readable)
442
464
  visible.add(f);
443
465
  }
466
+ for (const c of this.hiddenColsOf(table))
467
+ visible.delete(c);
444
468
  return projectRow(row, [...visible]);
445
469
  }
446
470
  /** Update a row by id. ACL row-scope is AND-ed into the WHERE, so a caller can
@@ -478,7 +502,7 @@ export class Db {
478
502
  })
479
503
  .join(", ");
480
504
  params.push(this.dialect.encode(id));
481
- let sql = `UPDATE ${this.dialect.id(table)} SET ${assignments} WHERE ${this.dialect.id("id")} = ${this.dialect.placeholder(params.length)}`;
505
+ let sql = `UPDATE ${this.dialect.id(table)} SET ${assignments} WHERE ${this.dialect.id(this.pkOf(table))} = ${this.dialect.placeholder(params.length)}`;
482
506
  sql += this.scopeClause(scope.where, params);
483
507
  sql += this.returningClause("*");
484
508
  const updated = this.decodeRow(table, (await this.driver.exec(sql, params))[0]);
@@ -492,9 +516,9 @@ export class Db {
492
516
  if (!scope.allowed)
493
517
  throw new AclDenied(table, "delete");
494
518
  const params = [this.dialect.encode(id)];
495
- let sql = `DELETE FROM ${this.dialect.id(table)} WHERE ${this.dialect.id("id")} = ${this.dialect.placeholder(1)}`;
519
+ let sql = `DELETE FROM ${this.dialect.id(table)} WHERE ${this.dialect.id(this.pkOf(table))} = ${this.dialect.placeholder(1)}`;
496
520
  sql += this.scopeClause(scope.where, params);
497
- sql += this.returningClause("id");
521
+ sql += this.returningClause("*");
498
522
  return (await this.driver.exec(sql, params)).length > 0;
499
523
  }
500
524
  /** Escape hatch — raw SQL, NOT ACL-checked. For system/internal use only. */
@@ -24,6 +24,12 @@ export interface FieldDef {
24
24
  * rebuilds the table, copying data from the old column. A diff cannot tell a
25
25
  * rename from a drop+add, so the rename must be declared explicitly. */
26
26
  readonly renamedFrom?: string;
27
+ /** Never project this column on any ORM read — find/get, mutation echoes, relation
28
+ * loads, and the SYSTEM-mode admin data API all strip it, even under a full-access
29
+ * (allow()) or SYSTEM scope. Writable on insert/update, and still visible to raw
30
+ * `ctx.db.exec` (the escape hatch credential code uses). For secrets/internal
31
+ * columns like a password hash. Set by the `hidden()` modifier. */
32
+ readonly hidden?: boolean;
27
33
  }
28
34
  declare const builders: {
29
35
  id: () => {
@@ -124,6 +130,13 @@ export declare function unique<F extends FieldDef>(field: F): F & {
124
130
  export declare function indexed<F extends FieldDef>(field: F): F & {
125
131
  readonly index: true;
126
132
  };
133
+ /** Mark a column never-readable through the ORM: stripped from every read projection
134
+ * (find/get, mutation echoes, relation loads, admin data) even under full/SYSTEM
135
+ * access. Still writable, and still visible to raw `ctx.db.exec`. For secrets such as
136
+ * a password hash, e.g. `passwordHash: hidden(t.text())`. */
137
+ export declare function hidden<F extends FieldDef>(field: F): F & {
138
+ readonly hidden: true;
139
+ };
127
140
  /** A raw-SQL column DEFAULT (emitted unquoted in the DDL), produced by the `expr`
128
141
  * helpers below. Distinct from a literal default so `defaultTo` can render
129
142
  * `DEFAULT datetime('now')` rather than the quoted string `DEFAULT 'datetime(...)'`. */
@@ -57,6 +57,13 @@ export function unique(field) {
57
57
  export function indexed(field) {
58
58
  return { ...field, index: true };
59
59
  }
60
+ /** Mark a column never-readable through the ORM: stripped from every read projection
61
+ * (find/get, mutation echoes, relation loads, admin data) even under full/SYSTEM
62
+ * access. Still writable, and still visible to raw `ctx.db.exec`. For secrets such as
63
+ * a password hash, e.g. `passwordHash: hidden(t.text())`. */
64
+ export function hidden(field) {
65
+ return { ...field, hidden: true };
66
+ }
60
67
  /** A raw-SQL column DEFAULT (emitted unquoted in the DDL), produced by the `expr`
61
68
  * helpers below. Distinct from a literal default so `defaultTo` can render
62
69
  * `DEFAULT datetime('now')` rather than the quoted string `DEFAULT 'datetime(...)'`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/server",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
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/index.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  // and codegen load an app.ts for its schema without dragging in the DO runtime.
9
9
 
10
10
  // --- schema authoring ---
11
- export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, defaultTo, primaryKey, generated, expr, ExprDefault } from "./sdk/schema";
11
+ export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault } from "./sdk/schema";
12
12
  export { isValidUuid } from "./sdk/uuid";
13
13
  export type {
14
14
  DefaultValue,
package/src/runtime/db.ts CHANGED
@@ -385,6 +385,24 @@ export class Db<S extends SchemaDef = SchemaDef> {
385
385
  .map(([n]) => n);
386
386
  }
387
387
 
388
+ /** Columns marked `hidden()` — never projected on an ORM read (even SYSTEM/full). */
389
+ private hiddenColsOf(table: string): string[] {
390
+ const fields = this.schema[table]?.fields;
391
+ if (!fields) return [];
392
+ return Object.entries(fields)
393
+ .filter(([, f]) => (f as FieldDef).hidden)
394
+ .map(([n]) => n);
395
+ }
396
+
397
+ /** Drop hidden columns from a row (copying only if any are present). */
398
+ private stripHidden(table: string, row: Row): Row {
399
+ const hidden = this.hiddenColsOf(table);
400
+ if (hidden.length === 0) return row;
401
+ const out = { ...row };
402
+ for (const c of hidden) delete out[c];
403
+ return out;
404
+ }
405
+
388
406
  /** Mint a UUID for every `generated()` uuid column the caller omitted, mutating
389
407
  * `vals`. Returns the columns it filled — server-minted, so the insert path treats
390
408
  * them like forced `set` values (bypassing the writable-field ACL check). */
@@ -444,7 +462,8 @@ export class Db<S extends SchemaDef = SchemaDef> {
444
462
 
445
463
  /** Fetch one row by id within an ACL row-scope (for per-row write evaluation). */
446
464
  private async fetchOne(from: string, id: Id, scopeWhere: SqlExpr | null): Promise<Row | undefined> {
447
- const where = scopeWhere ? and(eq("id", id), scopeWhere) : eq("id", id);
465
+ const pk = this.pkOf(from);
466
+ const where = scopeWhere ? and(eq(pk, id), scopeWhere) : eq(pk, id);
448
467
  const { sql, params } = compileSelect({ from, where, limit: 1 }, this.dialect);
449
468
  return this.decodeRow(from, (await this.driver.exec(sql, params))[0] as Row | undefined);
450
469
  }
@@ -452,9 +471,10 @@ export class Db<S extends SchemaDef = SchemaDef> {
452
471
  private async finishRows(from: string, raw: Row[], scope: Scope, withSel: Selected): Promise<Row[]> {
453
472
  const relNames = withSel ? Object.keys(withSel).filter((k) => withSel[k]) : [];
454
473
  for (const relName of relNames) await this.loadRelation(from, raw, relName);
455
- if (scope.fields === null) return raw; // base unrestricted -> no per-row narrowing possible
474
+ // Hidden columns are stripped even under an unrestricted/SYSTEM scope (never readable).
475
+ if (scope.fields === null) return raw.map((r) => this.stripHidden(from, r));
456
476
  return raw.map((r) => {
457
- const projected = projectRow(r, effectiveFields(scope, r, this.acl.identity));
477
+ const projected = this.stripHidden(from, projectRow(r, effectiveFields(scope, r, this.acl.identity)));
458
478
  for (const relName of relNames) projected[relName] = r[relName]; // relations survive projection
459
479
  return projected;
460
480
  });
@@ -498,7 +518,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
498
518
  // parent[column] -> target.id
499
519
  const keys = [...new Set(rows.map((r) => r[rel.column]).filter((v) => v != null))];
500
520
  const byId = new Map<unknown, Row>();
501
- for (const { key, row } of await fetchBy("id", keys)) byId.set(key, row);
521
+ for (const { key, row } of await fetchBy(this.pkOf(rel.target), keys)) byId.set(key, row);
502
522
  for (const r of rows) r[relName] = r[rel.column] != null ? (byId.get(r[rel.column]) ?? null) : null;
503
523
  } else {
504
524
  // hasMany: target[column] -> parent.id
@@ -545,16 +565,18 @@ export class Db<S extends SchemaDef = SchemaDef> {
545
565
  * would: the caller's readable fields for this row, PLUS the columns they just
546
566
  * wrote (which they already know) and the primary key (so a write-only caller
547
567
  * still gets the generated id). Full read access -> the whole row; SYSTEM -> as-is.
548
- * This makes create/update echoes field-ACL-safe without ever collapsing to {}. */
568
+ * This makes create/update echoes field-ACL-safe without ever collapsing to {}.
569
+ * Hidden columns are never echoed, even when written or under SYSTEM. */
549
570
  private projectWrite(table: string, row: Row, writtenCols: string[]): Row {
550
- if (this.acl.system) return row;
571
+ if (this.acl.system) return this.stripHidden(table, row);
551
572
  const visible = new Set<string>([this.pkOf(table), ...writtenCols]);
552
573
  const readScope = this.scopeFor(table, "read");
553
574
  if (readScope.allowed) {
554
575
  const readable = effectiveFields(readScope, row, this.acl.identity);
555
- if (readable === null) return row; // unrestricted read -> echo everything
576
+ if (readable === null) return this.stripHidden(table, row); // unrestricted read -> echo everything (minus hidden)
556
577
  for (const f of readable) visible.add(f);
557
578
  }
579
+ for (const c of this.hiddenColsOf(table)) visible.delete(c);
558
580
  return projectRow(row, [...visible]);
559
581
  }
560
582
 
@@ -596,7 +618,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
596
618
  })
597
619
  .join(", ");
598
620
  params.push(this.dialect.encode(id));
599
- let sql = `UPDATE ${this.dialect.id(table)} SET ${assignments} WHERE ${this.dialect.id("id")} = ${this.dialect.placeholder(params.length)}`;
621
+ let sql = `UPDATE ${this.dialect.id(table)} SET ${assignments} WHERE ${this.dialect.id(this.pkOf(table))} = ${this.dialect.placeholder(params.length)}`;
600
622
  sql += this.scopeClause(scope.where, params);
601
623
  sql += this.returningClause("*");
602
624
  const updated = this.decodeRow(table, (await this.driver.exec(sql, params))[0]);
@@ -610,9 +632,9 @@ export class Db<S extends SchemaDef = SchemaDef> {
610
632
  const scope = this.scopeFor(table, "delete");
611
633
  if (!scope.allowed) throw new AclDenied(table, "delete");
612
634
  const params: unknown[] = [this.dialect.encode(id)];
613
- let sql = `DELETE FROM ${this.dialect.id(table)} WHERE ${this.dialect.id("id")} = ${this.dialect.placeholder(1)}`;
635
+ let sql = `DELETE FROM ${this.dialect.id(table)} WHERE ${this.dialect.id(this.pkOf(table))} = ${this.dialect.placeholder(1)}`;
614
636
  sql += this.scopeClause(scope.where, params);
615
- sql += this.returningClause("id");
637
+ sql += this.returningClause("*");
616
638
  return (await this.driver.exec(sql, params)).length > 0;
617
639
  }
618
640
 
package/src/sdk/schema.ts CHANGED
@@ -39,6 +39,12 @@ export interface FieldDef {
39
39
  * rebuilds the table, copying data from the old column. A diff cannot tell a
40
40
  * rename from a drop+add, so the rename must be declared explicitly. */
41
41
  readonly renamedFrom?: string;
42
+ /** Never project this column on any ORM read — find/get, mutation echoes, relation
43
+ * loads, and the SYSTEM-mode admin data API all strip it, even under a full-access
44
+ * (allow()) or SYSTEM scope. Writable on insert/update, and still visible to raw
45
+ * `ctx.db.exec` (the escape hatch credential code uses). For secrets/internal
46
+ * columns like a password hash. Set by the `hidden()` modifier. */
47
+ readonly hidden?: boolean;
42
48
  }
43
49
 
44
50
  const builders = {
@@ -134,6 +140,14 @@ export function indexed<F extends FieldDef>(field: F): F & { readonly index: tru
134
140
  return { ...field, index: true };
135
141
  }
136
142
 
143
+ /** Mark a column never-readable through the ORM: stripped from every read projection
144
+ * (find/get, mutation echoes, relation loads, admin data) even under full/SYSTEM
145
+ * access. Still writable, and still visible to raw `ctx.db.exec`. For secrets such as
146
+ * a password hash, e.g. `passwordHash: hidden(t.text())`. */
147
+ export function hidden<F extends FieldDef>(field: F): F & { readonly hidden: true } {
148
+ return { ...field, hidden: true };
149
+ }
150
+
137
151
  /** A raw-SQL column DEFAULT (emitted unquoted in the DDL), produced by the `expr`
138
152
  * helpers below. Distinct from a literal default so `defaultTo` can render
139
153
  * `DEFAULT datetime('now')` rather than the quoted string `DEFAULT 'datetime(...)'`. */