@everystack/model 0.4.9 → 0.4.11

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/README.md CHANGED
@@ -96,7 +96,19 @@ The table-level surface, composed in `constraints: [...]`, for what a column-sco
96
96
  - `{ where: 'deleted_at IS NULL' }` — a column/scalar predicate, compiled to the policy's `USING` clause.
97
97
  - `{ sql: … }` — a verbatim RLS predicate escape hatch.
98
98
 
99
- These compile to RLS policies and grants (via `@everystack/cli`'s `compileTableContract`, what `db:generate` emits). `can('manage', …)` covers all actions.
99
+ These compile to RLS policies and grants (via `@everystack/cli`'s `compileTableContract`, what `db:generate` emits). `can('manage', …)` covers all four DML actions: `SELECT`, `INSERT`, `UPDATE`, `DELETE`.
100
+
101
+ Exactly those four, and no more. A verb that expanded to "every privilege" would hand out `TRUNCATE`, which no `DELETE` policy filters. The other table privileges are named one at a time:
102
+
103
+ ```typescript
104
+ defineModel('posts', {
105
+ fields: { … },
106
+ abilities: [can('read'), can('manage', { role: 'admin' })],
107
+ privileges: { admin: ['REFERENCES', 'TRIGGER', 'TRUNCATE'] },
108
+ });
109
+ ```
110
+
111
+ `privileges` takes only those three, and it is additive — it widens what a role holds and can never narrow it. DML stays in `can()`, where the grant and the row policy are decided together. `db:pull --abilities live` writes this key from the live grants, so an existing database that grants its admin role all three keeps them.
100
112
 
101
113
  ## Relations
102
114
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/model",
3
- "version": "0.4.9",
3
+ "version": "0.4.11",
4
4
  "description": "Declare a table's schema, validation, and authorization once — compiled to migrations, RLS, and handler config",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
package/src/can.ts CHANGED
@@ -15,6 +15,26 @@ export type Action = 'read' | 'create' | 'update' | 'delete' | 'manage' | 'execu
15
15
  export interface AbilityCondition {
16
16
  /** Role-gated: policy `TO <role>` + grant. */
17
17
  role?: string;
18
+ /**
19
+ * The policy's AUDIENCE, decoupled from its grantees. Exactly one value: `'public'`.
20
+ *
21
+ * A bare `can('read')` means two things at once — *who holds the privilege* (a GRANT to
22
+ * anon and to authenticated) and *who the policy names* (`TO anon`, `TO authenticated`).
23
+ * One knob, two axes. A brownfield schema that wrote the ordinary Postgres idiom — a
24
+ * single policy `TO PUBLIC` — could not be expressed at all, so the plan proposed dropping
25
+ * it and creating two role-scoped ones. PUBLIC is an OPEN set and `{anon, authenticated}`
26
+ * is not, so that is a real narrowing: any other role loses the read.
27
+ *
28
+ * `policyRoles: 'public'` emits ONE policy `TO PUBLIC` and leaves the GRANTS exactly as
29
+ * they are derived today. **It can never add a grantee** — an open audience over a closed
30
+ * grant set widens nothing, because Postgres checks the GRANT first.
31
+ *
32
+ * Only `'public'` is admitted, deliberately. A free-form role list here would be a second
33
+ * way to spell the `role:` axis and could name a role no grant backs, which reads as access
34
+ * that does not exist. With the domain restricted to one open set, `policyRoles ⊇ grantees`
35
+ * holds structurally and there is no inverse case to guard.
36
+ */
37
+ policyRoles?: 'public';
18
38
  /** Row owner only: the column holding the owner id (`USING (<col> = auth.uid())`). */
19
39
  owner?: string;
20
40
  /**
@@ -49,11 +69,12 @@ export interface AbilityCondition {
49
69
  */
50
70
  ownerExpr?: unknown;
51
71
  /**
52
- * Column-scoped exposure for a `read` ability. Names the field keys a role may read
53
- * (compiled to a `GRANT SELECT (cols)` column grant, not a table grant) instead of the
54
- * whole row — e.g. `can('read', { owner: 'id', columns: ['id', 'email', 'role'] })` on
55
- * `auth.users` exposes id/email/role to the owner, never the password hash. Pairs with
56
- * `owner` (the self policy scopes the rows; the columns scope the fields).
72
+ * Column-scoped exposure for a `read` or `update` ability. Names the field keys a role may
73
+ * read or update (compiled to `GRANT SELECT|UPDATE (cols)`, never a table grant) instead of
74
+ * the whole row — e.g. `can('read', { owner: 'id', columns: ['id', 'email', 'role'] })` on
75
+ * `auth.users` exposes id/email/role to the owner, never the password hash. Every column
76
+ * ability must name a `role`, an `owner`, or both: the policy scopes rows and the columns
77
+ * scope fields.
57
78
  */
58
79
  columns?: string[];
59
80
  /** Scalar/column predicate — a sql-tagged expression compiled to an RLS USING clause. */
@@ -85,6 +106,38 @@ export interface Ability {
85
106
  condition: AbilityCondition;
86
107
  }
87
108
 
109
+ /**
110
+ * Is this an ability whose privilege is scoped to listed columns rather than the
111
+ * whole table? `defineModel` rejects every malformed near-miss before a descriptor
112
+ * reaches a compiler, so consumers can use this as the one classification rule.
113
+ */
114
+ export function isColumnAbility(ability: Ability): boolean {
115
+ return (ability.action === 'read' || ability.action === 'update')
116
+ && Array.isArray(ability.condition.columns)
117
+ && ability.condition.columns.length > 0
118
+ && Boolean(ability.condition.role || ability.condition.owner);
119
+ }
120
+
121
+ /**
122
+ * Is this ability a PUBLIC read — a read path open to a caller with no session?
123
+ *
124
+ * The visibility question the soft-delete guard turns on: "what can someone with no session
125
+ * see?". Both a role-less read and `role: 'anon'` answer it the same way, because `anon` IS
126
+ * the anonymous role; an `owner` or `via` read answers it differently, because those scope
127
+ * rows to a session that exists.
128
+ *
129
+ * ONE definition, because there were two. `defineModel`'s refusal fired only on a role-less
130
+ * read while `db:pull`'s renderer already counted `role: 'anon'` — so a table whose
131
+ * authenticated read diverges (which pull renders as an anon-scoped read) slipped past the
132
+ * refusal while still being rendered as public. Two surfaces, two answers, one question.
133
+ */
134
+ export function isPublicReadAbility(ability: Ability): boolean {
135
+ return ability.action === 'read'
136
+ && !ability.condition.owner
137
+ && !ability.condition.via
138
+ && (!ability.condition.role || ability.condition.role === 'anon');
139
+ }
140
+
88
141
  /** Declare one authorization rule. */
89
142
  export function can(action: Action, condition: AbilityCondition = {}): Ability {
90
143
  return { action, condition };
package/src/drizzle.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  pgTable,
19
19
  pgSchema,
20
20
  pgEnum,
21
+ customType,
21
22
  text,
22
23
  uuid,
23
24
  integer,
@@ -106,6 +107,13 @@ function baseColumn(columnName: string, spec: FieldSpec): PgColumnBuilderBase {
106
107
  return json(columnName);
107
108
  case 'enum':
108
109
  return getEnum(spec)(columnName);
110
+ case 'pgType': {
111
+ // A verbatim type: drizzle carries the exact string as the column's SQL
112
+ // type and the value stays `unknown` — no client-side typing is promised.
113
+ const typeName = spec.pgTypeName;
114
+ if (!typeName) throw new Error('pgType field is missing pgTypeName');
115
+ return customType<{ data: unknown }>({ dataType: () => typeName })(columnName);
116
+ }
109
117
  default: {
110
118
  const exhaustive: never = spec.type;
111
119
  throw new Error(`Unsupported field type: ${String(exhaustive)}`);
package/src/field.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { RawSql } from './raw-sql';
15
+ import { pgTypeRefusal } from './pg-type';
15
16
 
16
17
  export type FieldType =
17
18
  | 'text'
@@ -30,7 +31,8 @@ export type FieldType =
30
31
  | 'varchar'
31
32
  | 'json'
32
33
  | 'jsonb'
33
- | 'enum';
34
+ | 'enum'
35
+ | 'pgType';
34
36
 
35
37
  export type DefaultKind = 'value' | 'now' | 'random' | 'sql';
36
38
 
@@ -49,6 +51,14 @@ export interface FieldSpec {
49
51
  scale?: number;
50
52
  /** `varchar(length)` — maximum character length. */
51
53
  length?: number;
54
+ /**
55
+ * The verbatim Postgres type of a `pgType` field — the exact canonical
56
+ * `format_type` spelling (`hstore`, `geometry(Geometry,4326)`, `tsvector`).
57
+ * The string IS the type's identity: introspection, the differ, and the
58
+ * fingerprint all compare it byte-for-byte, so a pulled column round-trips
59
+ * with no coercion and no drift.
60
+ */
61
+ pgTypeName?: string;
52
62
  isPrimaryKey: boolean;
53
63
  isNotNull: boolean;
54
64
  isUnique: boolean;
@@ -94,7 +104,7 @@ export interface FieldSpec {
94
104
  export class FieldBuilder {
95
105
  readonly spec: FieldSpec;
96
106
 
97
- constructor(type: FieldType, extra?: Pick<FieldSpec, 'enumName' | 'enumValues' | 'precision' | 'scale' | 'length'>) {
107
+ constructor(type: FieldType, extra?: Pick<FieldSpec, 'enumName' | 'enumValues' | 'precision' | 'scale' | 'length' | 'pgTypeName'>) {
98
108
  this.spec = {
99
109
  type,
100
110
  enumName: extra?.enumName,
@@ -102,6 +112,7 @@ export class FieldBuilder {
102
112
  precision: extra?.precision,
103
113
  scale: extra?.scale,
104
114
  length: extra?.length,
115
+ pgTypeName: extra?.pgTypeName,
105
116
  isPrimaryKey: false,
106
117
  isNotNull: false,
107
118
  isUnique: false,
@@ -252,4 +263,19 @@ export const field = {
252
263
  json: (): FieldBuilder => new FieldBuilder('json'),
253
264
  /** A Postgres enum. The `name` is the `CREATE TYPE` name and is always explicit, so `db:pull` round-trips it. */
254
265
  enum: (name: string, values: readonly string[]): FieldBuilder => new FieldBuilder('enum', { enumName: name, enumValues: values }),
266
+ /**
267
+ * A verbatim Postgres type the vocabulary has no first-class field for —
268
+ * `field.pgType('hstore')`, `field.pgType('geometry(Geometry,4326)')`,
269
+ * `field.pgType('tsvector')`. The exact string is the column's type, end to
270
+ * end: DDL emits it verbatim, introspection reports it verbatim, and the
271
+ * differ/fingerprint compare it byte-for-byte — so an extension-typed column
272
+ * round-trips with no coercion. The spelling must be canonical and must not
273
+ * shadow a first-class field (one type, one spelling): non-canonical or
274
+ * first-class spellings are refused with the correct call named.
275
+ */
276
+ pgType: (typeName: string): FieldBuilder => {
277
+ const refusal = pgTypeRefusal(typeName);
278
+ if (refusal) throw new Error(refusal);
279
+ return new FieldBuilder('pgType', { pgTypeName: typeName });
280
+ },
255
281
  } as const;
package/src/index.ts CHANGED
@@ -10,20 +10,22 @@
10
10
  export { field, FieldBuilder } from './field';
11
11
  export type { FieldSpec, FieldType, DefaultKind, ReferentialAction } from './field';
12
12
 
13
+ export { canonicalColumnType, fieldFactoryCall, pgTypeRefusal } from './pg-type';
14
+
13
15
  export { unique, check, index, foreignKey, IndexBuilder } from './constraints';
14
16
  export type { TableConstraint } from './constraints';
15
17
 
16
18
  export { sql } from './raw-sql';
17
19
  export type { RawSql } from './raw-sql';
18
20
 
19
- export { can } from './can';
21
+ export { can, isColumnAbility, isPublicReadAbility } from './can';
20
22
  export type { Ability, AbilityCondition, Action } from './can';
21
23
 
22
24
  export { belongsTo, hasMany } from './relations';
23
25
  export type { Relation } from './relations';
24
26
 
25
- export { defineModel, defineMaterializedTable } from './model';
26
- export type { ModelDescriptor, ModelDefinition, ModelHooks, WrittenBy, MaterializedTableMarker, MaterializedTableDefinition } from './model';
27
+ export { defineModel, defineMaterializedTable, EXTRA_PRIVILEGES } from './model';
28
+ export type { ModelDescriptor, ModelDefinition, ModelHooks, WrittenBy, ExtraPrivilege, MaterializedTableMarker, MaterializedTableDefinition } from './model';
27
29
 
28
30
  export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql, moduleSequences, moduleDerived, moduleGovernedRoles } from './module';
29
31
  export type { Module, ModuleDefinition } from './module';
package/src/model.ts CHANGED
@@ -13,6 +13,7 @@
13
13
 
14
14
  import type { FieldBuilder } from './field';
15
15
  import type { Ability } from './can';
16
+ import { isPublicReadAbility } from './can';
16
17
  import type { Relation } from './relations';
17
18
  import type { TableConstraint } from './constraints';
18
19
  import type { RawSql } from './raw-sql';
@@ -50,6 +51,23 @@ export interface ModelHooks {
50
51
  */
51
52
  export type WrittenBy = 'app' | 'worker' | 'functions';
52
53
 
54
+ /**
55
+ * A table privilege that is NOT one of the four CRUD verbs — the ones `can()` has no word for.
56
+ *
57
+ * `can('manage')` means exactly INSERT/SELECT/UPDATE/DELETE, and that is deliberate: a verb
58
+ * that quietly expanded to "every privilege" would hand out TRUNCATE, which is not a DELETE
59
+ * and is not filtered by a DELETE policy. So these three are named one at a time, by the
60
+ * author, in {@link ModelDefinition.privileges}.
61
+ *
62
+ * Why the vocabulary stops here: a real database routinely grants its admin role REFERENCES,
63
+ * TRIGGER and TRUNCATE, and everystack had no way to SAY that — so the first plan against an
64
+ * existing schema revoked all three on every table. That is our spelling, not their bug.
65
+ */
66
+ export type ExtraPrivilege = 'REFERENCES' | 'TRIGGER' | 'TRUNCATE';
67
+
68
+ /** The declarable privilege set — the one definition; the CLI's classifier reads it too. */
69
+ export const EXTRA_PRIVILEGES: readonly ExtraPrivilege[] = ['REFERENCES', 'TRIGGER', 'TRUNCATE'];
70
+
53
71
  export interface ModelDefinition {
54
72
  fields: Record<string, FieldBuilder>;
55
73
  abilities?: Ability[];
@@ -59,6 +77,21 @@ export interface ModelDefinition {
59
77
  constraints?: TableConstraint[];
60
78
  /** The write principal — drives RLS posture. Default `'app'`. See {@link WrittenBy}. */
61
79
  writtenBy?: WrittenBy;
80
+ /**
81
+ * Privileges a role holds on this table BEYOND the CRUD an ability compiles to —
82
+ * `privileges: { admin: ['REFERENCES', 'TRIGGER', 'TRUNCATE'] }`.
83
+ *
84
+ * Only {@link ExtraPrivilege} values are accepted. The four DML privileges are NOT
85
+ * declarable here on purpose: `SELECT`/`INSERT`/`UPDATE`/`DELETE` come from `can()`, which
86
+ * decides the grant and the RLS policy together. A grant declared without its policy is a
87
+ * privilege with no row gate — the exact shape `db:pull` refuses to render.
88
+ *
89
+ * ADDITIVE, never subtractive: these are unioned with what the abilities grant, so this key
90
+ * can widen a role's privileges and can never narrow them. Naming a role here GOVERNS it —
91
+ * same rule as `can(…, { role })` — so a role that appears only in this map has its other
92
+ * privileges reconciled everywhere, not just on this table.
93
+ */
94
+ privileges?: Record<string, ExtraPrivilege[]>;
62
95
  /**
63
96
  * `true` excludes soft-deleted rows (`deleted_at IS NULL`) from the public read policies
64
97
  * and from the generic data API. **Required — not inferred — on any model that has a
@@ -132,6 +165,8 @@ export interface ModelDescriptor {
132
165
  constraints: TableConstraint[];
133
166
  /** The resolved write principal (default `'app'`) — drives RLS posture. See {@link WrittenBy}. */
134
167
  writtenBy: WrittenBy;
168
+ /** Beyond-CRUD privileges per role, unioned with what the abilities grant. See {@link ModelDefinition.privileges}. */
169
+ privileges: Record<string, ExtraPrivilege[]>;
135
170
  /** `true` excludes `deleted_at IS NULL` rows from public reads and the data API. Declared, never inferred. */
136
171
  softDelete: boolean;
137
172
  /** The resolved Postgres schema (default `'public'`). Package tables live in their own. */
@@ -173,6 +208,72 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
173
208
  if (a.action === 'execute') {
174
209
  throw new Error(`model '${table}': can('execute') is a function ability — declare it on the defineFunction it gates.`);
175
210
  }
211
+
212
+ // A column list is authorization, not a selection hint. If it reaches a table-grant path
213
+ // unnoticed, Postgres receives a whole-table privilege. Admit only the shapes the column
214
+ // compiler understands, and reject an empty list because it is indistinguishable from no
215
+ // column scope at all.
216
+ if (a.condition.columns !== undefined) {
217
+ if (a.action !== 'read' && a.action !== 'update') {
218
+ throw new Error(
219
+ `model '${table}': columns apply only to can('read') or can('update'), not can('${a.action}').`,
220
+ );
221
+ }
222
+ if (!Array.isArray(a.condition.columns) || a.condition.columns.length === 0) {
223
+ throw new Error(
224
+ `model '${table}': columns must be a non-empty array on can('${a.action}'); an empty list would erase the column scope.`,
225
+ );
226
+ }
227
+ if (!a.condition.role && !a.condition.owner) {
228
+ throw new Error(
229
+ `model '${table}': columns require role and/or owner on can('${a.action}'); otherwise the intended column grant has no declared grantee or row gate.`,
230
+ );
231
+ }
232
+ if (a.condition.policyRoles !== undefined) {
233
+ throw new Error(
234
+ `model '${table}': columns cannot use policyRoles. A column ability's policy is TO its declared role (or authenticated for owner-only), never an open audience.`,
235
+ );
236
+ }
237
+ if (a.condition.owner && !(a.condition.owner in def.fields)) {
238
+ throw new Error(
239
+ `model '${table}': columns names owner '${a.condition.owner}', but no such field exists.`,
240
+ );
241
+ }
242
+ for (const column of a.condition.columns) {
243
+ if (!(column in def.fields)) {
244
+ throw new Error(
245
+ `model '${table}': columns names '${column}', but no such field exists. Column abilities use model field keys.`,
246
+ );
247
+ }
248
+ }
249
+ }
250
+
251
+ // `policyRoles` decouples the policy's AUDIENCE from its GRANTEES, and it is deliberately
252
+ // amputated to one open set. Anything else would be a second spelling of the `role:` axis
253
+ // and could name a role no grant backs — a policy that reads as access which does not
254
+ // exist. A key that cannot say NO is not a fix, so it says NO here, at authoring time.
255
+ if (a.condition.policyRoles !== undefined) {
256
+ if (a.condition.policyRoles !== 'public') {
257
+ throw new Error(
258
+ `model '${table}': policyRoles admits exactly one value, 'public' — got ` +
259
+ `${JSON.stringify(a.condition.policyRoles)}. It exists to express a single policy TO PUBLIC, ` +
260
+ `the open audience a brownfield schema already wrote. To gate on a named role use role: '<name>', ` +
261
+ `which sets the grant too.`,
262
+ );
263
+ }
264
+ if (a.action !== 'read') {
265
+ throw new Error(
266
+ `model '${table}': policyRoles is a READ audience — can('${a.action}', { policyRoles: 'public' }) would ` +
267
+ `open a write policy to every role. Declare writes with role:/owner:.`,
268
+ );
269
+ }
270
+ if (a.condition.role) {
271
+ throw new Error(
272
+ `model '${table}': policyRoles: 'public' and role: '${a.condition.role}' both name the policy's audience. ` +
273
+ `Declare one. policyRoles states the audience and leaves the grants alone; role states both.`,
274
+ );
275
+ }
276
+ }
176
277
  }
177
278
 
178
279
  // An array field's value default must be a JS array — `.default([])`, not the
@@ -196,10 +297,19 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
196
297
  // so there is no default: on the exact set of models where the answer changes behaviour (a
197
298
  // `deletedAt` field plus a public read), it must be declared. `softDelete: true` is what the
198
299
  // inference used to do.
199
- const hasPublicRead = (def.abilities ?? []).some(
200
- (a) => a.action === 'read' && !a.condition.role && !a.condition.owner && !a.condition.via,
201
- );
202
- if ('deletedAt' in def.fields && hasPublicRead && def.softDelete === undefined) {
300
+ // `isPublicReadAbility`, not a local copy: this refusal and db:pull's renderer were asking
301
+ // the same question — "can a caller with no session see these rows?" — and answering it
302
+ // differently. The renderer already counted `role: 'anon'`; this refusal did not, so a table
303
+ // whose authenticated read diverges (rendered as an anon-scoped read) slipped past the very
304
+ // decision the refusal exists to force. It now fires on the anonymous audience however it is
305
+ // spelled. Explicit `softDelete: false` remains the one-line answer, and pull writes that
306
+ // line into every model it renders, so a pulled checkout keeps loading.
307
+ const hasPublicRead = (def.abilities ?? []).some(isPublicReadAbility);
308
+ // Both spellings of the field key. The guard used to test `'deletedAt' in def.fields`
309
+ // literally, so a model keying the column `deleted_at` — the shape a snake_case brownfield
310
+ // schema pulls as — carried a soft-delete column past a guard that never looked at it.
311
+ const softDeleteField = 'deletedAt' in def.fields || 'deleted_at' in def.fields;
312
+ if (softDeleteField && hasPublicRead && def.softDelete === undefined) {
203
313
  throw new Error(
204
314
  `model '${table}': has a 'deletedAt' field and a public read, so it must declare softDelete. `
205
315
  + `softDelete: true excludes soft-deleted rows from the public read policies (what the compiler used to infer from the field's presence); `
@@ -207,6 +317,26 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
207
317
  );
208
318
  }
209
319
 
320
+ // A privilege outside the beyond-CRUD set is refused rather than passed through. The DML
321
+ // four are the dangerous ones: declared here they would compile to a GRANT with no policy
322
+ // beside it, which is a privilege nobody row-gated. They come from can(), where the grant
323
+ // and the policy are decided in one place.
324
+ for (const [role, privs] of Object.entries(def.privileges ?? {})) {
325
+ if (!Array.isArray(privs)) {
326
+ throw new Error(`model '${table}': privileges['${role}'] must be an array of ${EXTRA_PRIVILEGES.join(' | ')}.`);
327
+ }
328
+ for (const p of privs) {
329
+ if (EXTRA_PRIVILEGES.includes(p)) continue;
330
+ const dml = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'].includes(String(p).toUpperCase());
331
+ throw new Error(
332
+ `model '${table}': privileges['${role}'] contains '${p}', which is not one of ${EXTRA_PRIVILEGES.join(', ')}. `
333
+ + (dml
334
+ ? `Declare ${String(p).toUpperCase()} with can() — that is what decides the row policy beside the grant. A grant declared here would have no policy.`
335
+ : `This key names only the table privileges can() has no verb for.`),
336
+ );
337
+ }
338
+ }
339
+
210
340
  validateTriggersFor('table', table, def.triggers ?? []);
211
341
 
212
342
  // A rename and a move in one increment can't be composed from two independent markers
@@ -230,6 +360,7 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
230
360
  primaryKey,
231
361
  constraints: def.constraints ?? [],
232
362
  writtenBy: def.writtenBy ?? 'app',
363
+ privileges: def.privileges ?? {},
233
364
  softDelete: def.softDelete ?? false,
234
365
  schema: def.schema ?? 'public',
235
366
  private: def.private ?? false,
@@ -266,6 +397,8 @@ export interface MaterializedTableDefinition {
266
397
  writtenBy?: WrittenBy;
267
398
  private?: boolean;
268
399
  renamedFrom?: string;
400
+ /** Beyond-CRUD privileges per role. See {@link ModelDefinition.privileges}. */
401
+ privileges?: Record<string, ExtraPrivilege[]>;
269
402
  }
270
403
 
271
404
  export function defineMaterializedTable(table: string, def: MaterializedTableDefinition): ModelDescriptor {
@@ -282,6 +415,7 @@ export function defineMaterializedTable(table: string, def: MaterializedTableDef
282
415
  constraints: def.constraints,
283
416
  writtenBy: def.writtenBy ?? 'worker',
284
417
  private: def.private,
418
+ ...(def.privileges ? { privileges: def.privileges } : {}),
285
419
  ...(def.renamedFrom ? { renamedFrom: def.renamedFrom } : {}),
286
420
  });
287
421
  model.materialized = {
package/src/pg-type.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * pg-type — the ONE definition of a column type's spelling.
3
+ *
4
+ * Three questions, answered here and only here, so every surface (the field
5
+ * factory, db:pull's renderer, introspection, the differ, the fingerprint)
6
+ * agrees by construction:
7
+ *
8
+ * 1. What is the canonical spelling of a `format_type` string?
9
+ * (`canonicalColumnType` — the v5 rule: a redundant default precision is
10
+ * not part of a type's identity.)
11
+ * 2. Which spellings are already owned by a first-class `field.*()` factory?
12
+ * (`fieldFactoryCall` — the inverse map db:pull renders with, and the map
13
+ * `field.pgType` refuses against, so one type always has one spelling.)
14
+ * 3. Everything else is carried VERBATIM by `field.pgType(...)` — the type
15
+ * string itself is the identity, end to end.
16
+ */
17
+
18
+ /**
19
+ * Canonicalize a `format_type` spelling. Only the redundant default precision,
20
+ * only the timestamp/time family: `timestamp(6) without time zone` is the same
21
+ * type as `timestamp without time zone` (6 is the family default), so both
22
+ * spell the canonical, shorter form. `timestamp(3)` is a real difference and
23
+ * passes through untouched.
24
+ */
25
+ export function canonicalColumnType(type: string): string {
26
+ return type.replace(/^(timestamp|timestamptz|time|timetz)\(6\)(\s|$)/, '$1$2');
27
+ }
28
+
29
+ /** `format_type` → the `field.*()` factory that produces it (the non-parameterized types). */
30
+ const FIELD_FACTORY: Record<string, string> = {
31
+ uuid: 'uuid',
32
+ text: 'text',
33
+ integer: 'integer',
34
+ bigint: 'bigint',
35
+ real: 'real',
36
+ 'double precision': 'doublePrecision',
37
+ boolean: 'boolean',
38
+ json: 'json',
39
+ jsonb: 'jsonb',
40
+ date: 'date',
41
+ 'timestamp with time zone': 'timestamptz',
42
+ 'timestamp without time zone': 'timestamp',
43
+ timestamp: 'timestamp',
44
+ };
45
+
46
+ /**
47
+ * The full `field.*()` call for a `format_type` string, or null when no
48
+ * first-class field maps it. Handles the parameterized types `format_type`
49
+ * spells out — `numeric(p,s)` and `character varying(n)` — so a pulled column
50
+ * round-trips back to the same DDL. A scale-0 numeric renders as the cleaner
51
+ * `field.numeric(p)` (identical to `(p, 0)`).
52
+ */
53
+ export function fieldFactoryCall(type: string): string | null {
54
+ // An array is its base type + `.array()` — exactly how the compiler emits it
55
+ // (`format_type` spells arrays `text[]`). An array of an unmapped base stays null.
56
+ if (type.endsWith('[]')) {
57
+ const base = fieldFactoryCall(type.slice(0, -2));
58
+ return base ? `${base}.array()` : null;
59
+ }
60
+
61
+ const direct = FIELD_FACTORY[type];
62
+ if (direct) return `field.${direct}()`;
63
+
64
+ const num = type.match(/^numeric(?:\((\d+),(\d+)\))?$/);
65
+ if (num) {
66
+ if (num[1] == null) return 'field.numeric()';
67
+ return num[2] === '0' ? `field.numeric(${num[1]})` : `field.numeric(${num[1]}, ${num[2]})`;
68
+ }
69
+
70
+ const vc = type.match(/^character varying(?:\((\d+)\))?$/);
71
+ if (vc) return vc[1] != null ? `field.varchar(${vc[1]})` : 'field.varchar()';
72
+
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Validate the argument of `field.pgType(...)`. Returns the reason the spelling
78
+ * is refused, or null when it may be carried verbatim. Refusals exist so a type
79
+ * never has two spellings in the vocabulary — the split-identity family of bugs.
80
+ */
81
+ export function pgTypeRefusal(typeName: string): string | null {
82
+ const t = typeName.trim();
83
+ if (!t) return 'field.pgType() needs a Postgres type name';
84
+ if (t !== typeName) return `field.pgType('${typeName}'): no surrounding whitespace — write '${t}'`;
85
+ if (t.endsWith('[]')) {
86
+ return `field.pgType('${t}'): declare the scalar and add .array() — field.pgType('${t.slice(0, -2)}').array()`;
87
+ }
88
+ const canonical = canonicalColumnType(t);
89
+ if (canonical !== t) {
90
+ return `field.pgType('${t}'): the canonical spelling is '${canonical}' — a redundant default precision is not part of the type's identity`;
91
+ }
92
+ const firstClass = fieldFactoryCall(t);
93
+ if (firstClass) {
94
+ return `field.pgType('${t}'): this type is first-class — use ${firstClass}`;
95
+ }
96
+ return null;
97
+ }