@everystack/model 0.4.13 → 0.4.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/model",
3
- "version": "0.4.13",
3
+ "version": "0.4.15",
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": {
@@ -19,13 +19,13 @@ export type TableConstraint =
19
19
  /** `CHECK (<predicate>)` — a raw SQL predicate, typically spanning columns. */
20
20
  | { kind: 'check'; predicate: string }
21
21
  /** A standalone index — `CREATE [UNIQUE] INDEX [USING <method>] … (keys) [INCLUDE (cols)] [WHERE <predicate>]`. */
22
- | { kind: 'index'; columns: string[]; isUnique: boolean; predicate?: string; method?: string; includeColumns?: string[]; rawPositions?: boolean[] }
22
+ | { kind: 'index'; columns: string[]; isUnique: boolean; predicate?: string; method?: string; includeColumns?: string[]; rawPositions?: boolean[]; indexName?: string }
23
23
  /**
24
24
  * A multi-column `FOREIGN KEY (a, b) REFERENCES other (x, y)` — the composite a column-scoped
25
25
  * `.references()` can't carry. Local field keys, a model thunk for the target, and the target's
26
26
  * field keys; optional `onDelete`/`onUpdate` referential actions.
27
27
  */
28
- | { kind: 'foreignKey'; columns: string[]; references: () => unknown; refColumns: string[]; onDelete?: ReferentialAction; onUpdate?: ReferentialAction };
28
+ | { kind: 'foreignKey'; columns: string[]; references: () => unknown; refColumns: string[]; onDelete?: ReferentialAction; onUpdate?: ReferentialAction; constraintName?: string };
29
29
 
30
30
  /** A composite unique constraint over the given field keys: `unique('authorId', 'slug')`. */
31
31
  export function unique(...columns: string[]): TableConstraint {
@@ -47,7 +47,7 @@ export function foreignKey(
47
47
  columns: string[],
48
48
  references: () => unknown,
49
49
  refColumns: string[],
50
- opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction },
50
+ opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction; name?: string },
51
51
  ): TableConstraint {
52
52
  return {
53
53
  kind: 'foreignKey',
@@ -56,6 +56,8 @@ export function foreignKey(
56
56
  refColumns,
57
57
  ...(opts?.onDelete ? { onDelete: opts.onDelete } : {}),
58
58
  ...(opts?.onUpdate ? { onUpdate: opts.onUpdate } : {}),
59
+ // The generated name is the FALLBACK; an adopted database keeps the name it arrived with.
60
+ ...(opts?.name !== undefined ? { constraintName: assertConstraintName(opts.name, 'foreign key') } : {}),
59
61
  };
60
62
  }
61
63
 
@@ -114,6 +116,50 @@ export class IndexBuilder {
114
116
  this.includeColumns = columns;
115
117
  return this;
116
118
  }
119
+
120
+ /**
121
+ * The index's NAME, when it has to be a specific one.
122
+ *
123
+ * Undeclared, the compiler generates `<table>_<cols>_index`, which is fine for a greenfield
124
+ * model and wrong for an adopted database. A brownfield schema arrives with names its previous
125
+ * tooling chose — a Rails app's `index_contents_on_destination_id` becomes
126
+ * `contents_destination_id_index` on a rebuild — and on one consumer's database ~200 indexes
127
+ * renamed that way. Counts and definitions matched, so nothing failed loudly; what broke was
128
+ * everything that names an index: a planner regression test, runbooks, `pg_stat_user_indexes`
129
+ * history, REINDEX scripts.
130
+ *
131
+ * So the generated name is the FALLBACK, never the default. `db:pull` emits this whenever the
132
+ * live name is not the one the generator would have produced.
133
+ */
134
+ name(indexName: string): this {
135
+ this.indexName = assertConstraintName(indexName, 'index');
136
+ return this;
137
+ }
138
+
139
+ /** The declared name, when {@link IndexBuilder.name} set one. */
140
+ indexName?: string;
141
+ }
142
+
143
+ /**
144
+ * An unquoted PostgreSQL identifier. A declared object name reaches `CREATE INDEX`,
145
+ * `ALTER INDEX … RENAME TO` and `ALTER TABLE … RENAME CONSTRAINT` by interpolation, none of
146
+ * which takes a parameter — so it is validated at DECLARATION, far from the SQL, and never
147
+ * trusted at the point of emission. Same rule, and the same reason, as a function's owner.
148
+ */
149
+ const INDEX_NAME = /^[a-z_][a-z0-9_$]*$/;
150
+
151
+ /**
152
+ * Validate a declared SQL object name, returning it. ONE implementation, shared by every
153
+ * surface that lets a model name a database object — an index, a foreign key constraint —
154
+ * because two copies of a validation rule is how one of them ends up laxer than the other.
155
+ */
156
+ export function assertConstraintName(name: string, what = 'constraint'): string {
157
+ if (!INDEX_NAME.test(name)) {
158
+ throw new Error(
159
+ `${what} name '${name}' is not a bare lowercase identifier (${INDEX_NAME}). It is interpolated into DDL that takes no parameters, so it is refused here rather than built into SQL.`,
160
+ );
161
+ }
162
+ return name;
117
163
  }
118
164
 
119
165
  export function index(...entries: Array<string | RawSql>): IndexBuilder {
package/src/derived.ts CHANGED
@@ -348,8 +348,49 @@ export interface FunctionDefinition {
348
348
  language: 'sql' | 'plpgsql';
349
349
  /** Default `'invoker'` (the safe one). `'definer'` REQUIRES a pinned searchPath. */
350
350
  security?: 'invoker' | 'definer';
351
- /** An ARRAY of schemas, not a comma string. */
352
- searchPath?: string[];
351
+ /**
352
+ * The role that OWNS the function — a privilege boundary, not a cosmetic attribute.
353
+ *
354
+ * A `SECURITY DEFINER` function executes with its owner's rights. Undeclared, the owner is
355
+ * whoever ran the build, so a database built by an RDS master runs every definer function
356
+ * as the master, and `db:fingerprint` still reports MATCH. Declare it and the boundary
357
+ * becomes part of the state the fingerprint and `db:check` can see.
358
+ *
359
+ * Also load-bearing for the deliberate case: a function owned by a narrow role — one whose
360
+ * RLS policy folds to a constant so an index can drive the lookup, granted to nobody —
361
+ * loses that barrier entirely the moment it is owned by someone else.
362
+ *
363
+ * Two things the DATABASE requires of whoever applies this, both measured on PG16:
364
+ * the applying role must be able to `SET ROLE` to the owner, and the owner must hold
365
+ * `CREATE` on the function's schema. Membership can be granted as
366
+ * `GRANT <owner> TO <builder> WITH INHERIT FALSE, SET TRUE` — that satisfies the first
367
+ * without the builder inheriting any of the owner's privileges.
368
+ *
369
+ * Defaults to undefined: the function is owned by whoever creates it (PostgreSQL's own
370
+ * behaviour, unchanged). `db:check` warns for a definer function with no declared owner.
371
+ */
372
+ owner?: string;
373
+ /**
374
+ * An ARRAY of schemas, not a comma string — or the sentinel `'unpinned'`.
375
+ *
376
+ * `'unpinned'` says the function has NO `SET search_path`, and it is how a brownfield
377
+ * function gets declared truthfully. It exists because the alternative was worse: `db:pull`
378
+ * used to INVENT `['pg_catalog']` for any definer function whose live `proconfig` was empty,
379
+ * and a build from those models then applied the invented pin for real, so functions that
380
+ * had always resolved `public` stopped resolving it. Sign-up and password change died on a
381
+ * database built from the consumer's own models, with every gate green beforehand.
382
+ *
383
+ * An unpinned SECURITY DEFINER function is a latent vulnerability that WORKS. An invented pin
384
+ * is an outage that passes every check. Worse, fabrication corrodes fingerprint, plan and
385
+ * apply, all of which rest on the model telling the truth. So the parser records what is
386
+ * there, and the opinion lives in the linter: `db:check` reports every unpinned definer
387
+ * function and can SUGGEST a pin from what the body references. It never applies one.
388
+ *
389
+ * The sentinel is explicit on purpose. `null` would blur with `undefined` across
390
+ * serialization and read as a mistake; OMITTING it on a definer function still throws,
391
+ * exactly as it always has. Strictness by explicitness, never by provenance.
392
+ */
393
+ searchPath?: string[] | 'unpinned';
353
394
  /** Default `'volatile'` (PostgreSQL's own). `immutable` enables expression indexes. */
354
395
  volatility?: 'immutable' | 'stable' | 'volatile';
355
396
  dependsOn?: DependsOnRef[];
@@ -386,7 +427,10 @@ export interface FunctionDescriptor {
386
427
  returns: string | SetofReturn;
387
428
  language: 'sql' | 'plpgsql';
388
429
  security: 'invoker' | 'definer';
389
- searchPath?: string[];
430
+ /** The declared owning role — see {@link FunctionDefinition.owner}. */
431
+ owner?: string;
432
+ /** See {@link FunctionDefinition.searchPath}. `'unpinned'` = no `SET search_path`, declared. */
433
+ searchPath?: string[] | 'unpinned';
390
434
  volatility: 'immutable' | 'stable' | 'volatile';
391
435
  dependsOn: DependsOnRef[];
392
436
  abilities: Ability[];
@@ -396,12 +440,45 @@ export interface FunctionDescriptor {
396
440
  body: string;
397
441
  }
398
442
 
443
+ /**
444
+ * An unquoted PostgreSQL role name. The owner is interpolated into `SET ROLE` and
445
+ * `ALTER … OWNER TO`, neither of which takes a parameter, so it is validated HERE — at
446
+ * declaration time, far from the SQL — rather than trusted at the point of emission.
447
+ * Same rule as @everystack/server's withRole.
448
+ */
449
+ const OWNER_ROLE_NAME = /^[a-z_][a-z0-9_]*$/;
450
+
451
+ /**
452
+ * Refuse an owner that is not a bare lowercase role name. ONE implementation across
453
+ * defineFunction, defineView and defineMaterializedView — the owner is interpolated into
454
+ * `SET ROLE` / `ALTER … OWNER TO`, and three copies of that gate is how one of them ends up
455
+ * missing it.
456
+ */
457
+ function assertOwnerRole(kind: string, name: string, owner: string | undefined): void {
458
+ if (owner === undefined || OWNER_ROLE_NAME.test(owner)) return;
459
+ throw new Error(
460
+ `${kind} '${name}': owner '${owner}' is not a bare lowercase role name (${OWNER_ROLE_NAME}). The owner is interpolated into SET ROLE / ALTER ... OWNER TO, which take no parameters — a quoted, mixed-case or generated name is refused here rather than built into SQL.`,
461
+ );
462
+ }
463
+
399
464
  export function defineFunction(name: string, def: FunctionDefinition): FunctionDescriptor {
400
465
  rejectHoles(`function '${name}'`, 'dependsOn', def.dependsOn);
401
466
  const security = def.security ?? 'invoker';
467
+ assertOwnerRole('function', name, def.owner);
468
+ // The sentinel is the ONLY string this takes. A typo would otherwise read as "unpinned" and
469
+ // silently strip a real pin — the exact class of silent security change this feature exists
470
+ // to stop, arriving through the door marked "honesty".
471
+ if (typeof def.searchPath === 'string' && def.searchPath !== 'unpinned') {
472
+ throw new Error(
473
+ `function '${name}': searchPath must be an array of schemas, or the sentinel 'unpinned' — got '${def.searchPath}'.`,
474
+ );
475
+ }
476
+ // OMISSION still throws. `searchPath: 'unpinned'` is an ANSWER — "this function has no
477
+ // SET search_path, and I am saying so" — while leaving it out is silence, which is what the
478
+ // landmine looks like. Strictness by explicitness, never by provenance.
402
479
  if (security === 'definer' && (!def.searchPath || def.searchPath.length === 0)) {
403
480
  throw new Error(
404
- `function '${name}': SECURITY DEFINER without a pinned searchPath is the classic landmine — pin searchPath: ['pg_catalog'] (plus the schemas the body needs), or use security: 'invoker'.`,
481
+ `function '${name}': SECURITY DEFINER without a pinned searchPath is the classic landmine — pin searchPath: ['pg_catalog'] (plus the schemas the body needs), declare searchPath: 'unpinned' if the live function genuinely has none, or use security: 'invoker'.`,
405
482
  );
406
483
  }
407
484
  const args = def.args ?? [];
@@ -443,6 +520,7 @@ export function defineFunction(name: string, def: FunctionDefinition): FunctionD
443
520
  returns: def.returns,
444
521
  language: def.language,
445
522
  security,
523
+ ...(def.owner ? { owner: def.owner } : {}),
446
524
  ...(def.searchPath ? { searchPath: def.searchPath } : {}),
447
525
  volatility: def.volatility ?? 'volatile',
448
526
  dependsOn: def.dependsOn ?? [],
@@ -465,6 +543,28 @@ export interface ViewDefinition {
465
543
  * the body must seal what it exposes).
466
544
  */
467
545
  securityInvoker: boolean;
546
+ /**
547
+ * The role that OWNS the relation — a privilege boundary whenever the view is not
548
+ * `securityInvoker`.
549
+ *
550
+ * A `security_invoker = false` view executes with its OWNER's rights: the body reads through
551
+ * the owner's grants and the owner's RLS, and the reader needs only a grant on the view. That
552
+ * is the whole sealed-slice pattern, and it means the owner IS the security boundary, exactly
553
+ * as it is for a `SECURITY DEFINER` function.
554
+ *
555
+ * Undeclared, the owner is whoever ran the build. An `admin`-owned view rebuilt by a migrator
556
+ * becomes migrator-owned: `admin` can no longer read through it, the slice now reads with the
557
+ * migrator's rights instead, and `db:fingerprint` still reports MATCH. Measured on a
558
+ * consumer's `public.users_self`.
559
+ *
560
+ * Two things the DATABASE requires of whoever applies this, the same pair `defineFunction`
561
+ * documents: the applying role must be able to `SET ROLE` to the owner, and the owner must
562
+ * hold `CREATE` on the relation's schema. `GRANT <owner> TO <builder> WITH INHERIT FALSE,
563
+ * SET TRUE` satisfies the first without the builder inheriting the owner's privileges.
564
+ *
565
+ * Defaults to undefined: owned by whoever creates it (PostgreSQL's own behaviour, unchanged).
566
+ */
567
+ owner?: string;
468
568
  abilities?: Ability[];
469
569
  /** Grants that EXIST, recorded rather than chosen — see {@link validateRelationPrivileges}. */
470
570
  privileges?: Record<string, string[]>;
@@ -494,6 +594,8 @@ export interface ViewDescriptor {
494
594
  kind: 'view';
495
595
  name: string;
496
596
  securityInvoker: boolean;
597
+ /** The declared owning role — see {@link ViewDefinition.owner}. */
598
+ owner?: string;
497
599
  abilities: Ability[];
498
600
  privileges: Record<string, string[]>;
499
601
  dependsOn: DependsOnRef[];
@@ -516,10 +618,12 @@ export function defineView(name: string, def: ViewDefinition): ViewDescriptor {
516
618
  validatePrivateContradiction('view', name, def.private, def.abilities);
517
619
  validateRelationFields('view', name, def.fields, def.private);
518
620
  validateTriggersFor('view', name, def.triggers ?? []);
621
+ assertOwnerRole('view', name, def.owner);
519
622
  return {
520
623
  kind: 'view',
521
624
  name,
522
625
  securityInvoker: def.securityInvoker,
626
+ ...(def.owner ? { owner: def.owner } : {}),
523
627
  abilities: def.abilities ?? [],
524
628
  privileges,
525
629
  dependsOn: def.dependsOn ?? [],
@@ -536,6 +640,14 @@ export function defineView(name: string, def: ViewDefinition): ViewDescriptor {
536
640
  // ---------------------------------------------------------------------------
537
641
 
538
642
  export interface MaterializedViewDefinition {
643
+ /**
644
+ * The role that OWNS the materialized view — see {@link ViewDefinition.owner}.
645
+ *
646
+ * A matview has no `security_invoker`: its contents are computed at REFRESH time with the
647
+ * owner's rights, always. So the owner decides what the stored rows can contain, and a
648
+ * rebuild that changes it silently changes what a refresh is able to see.
649
+ */
650
+ owner?: string;
539
651
  abilities?: Ability[];
540
652
  /** Grants that EXIST, recorded rather than chosen — see {@link validateRelationPrivileges}. */
541
653
  privileges?: Record<string, string[]>;
@@ -555,6 +667,8 @@ export interface MaterializedViewDefinition {
555
667
  export interface MaterializedViewDescriptor {
556
668
  kind: 'materialized view';
557
669
  name: string;
670
+ /** The declared owning role — see {@link ViewDefinition.owner}. */
671
+ owner?: string;
558
672
  abilities: Ability[];
559
673
  privileges: Record<string, string[]>;
560
674
  dependsOn: DependsOnRef[];
@@ -579,9 +693,11 @@ export function defineMaterializedView(name: string, def: MaterializedViewDefini
579
693
  `materialized view '${name}': REFRESH CONCURRENTLY requires a unique, non-partial index — declare indexes: [index('...').unique()].`,
580
694
  );
581
695
  }
696
+ assertOwnerRole('materialized view', name, def.owner);
582
697
  return {
583
698
  kind: 'materialized view',
584
699
  name,
700
+ ...(def.owner ? { owner: def.owner } : {}),
585
701
  abilities: def.abilities ?? [],
586
702
  privileges,
587
703
  dependsOn: def.dependsOn ?? [],
package/src/drizzle.ts CHANGED
@@ -68,6 +68,13 @@ function getEnum(spec: FieldSpec): ReturnType<typeof pgEnum> {
68
68
  return e;
69
69
  }
70
70
 
71
+ /**
72
+ * Field types whose drizzle builder carries `generatedAlwaysAsIdentity` — the integer family
73
+ * (`PgIntColumnBaseBuilder`). `pgType` can also be an identity column in Postgres (`smallint`),
74
+ * but it compiles to `customType`, which has no identity builder.
75
+ */
76
+ const INTEGER_IDENTITY_TYPES = new Set<FieldSpec['type']>(['integer', 'bigint']);
77
+
71
78
  /** Map a field's Postgres type to a drizzle pg-core column builder (no modifiers yet). */
72
79
  function baseColumn(columnName: string, spec: FieldSpec): PgColumnBuilderBase {
73
80
  switch (spec.type) {
@@ -143,7 +150,14 @@ export function toDrizzleTable(model: ModelDescriptor): PgTableWithColumns<any>
143
150
  let col: any = baseColumn(columnName, spec);
144
151
 
145
152
  if (spec.isArray) col = col.array();
146
- if (spec.isNotNull) col = col.notNull();
153
+ // Identity comes FIRST and stands in for notNull: drizzle's builder sets notNull AND
154
+ // hasDefault, and hasDefault is what makes the column optional on insert. Only the integer
155
+ // family carries the builder, so a `pgType` identity column keeps plain `.notNull()` — the
156
+ // DDL still declares the identity, only the runtime type is less precise.
157
+ const identity = INTEGER_IDENTITY_TYPES.has(spec.type) ? spec.identity : undefined;
158
+ if (identity === 'always') col = col.generatedAlwaysAsIdentity();
159
+ else if (identity === 'byDefault') col = col.generatedByDefaultAsIdentity();
160
+ else if (spec.isNotNull) col = col.notNull();
147
161
  if (spec.isUnique) col = col.unique();
148
162
 
149
163
  if (spec.defaultKind === 'now') col = col.defaultNow();
package/src/field.ts CHANGED
@@ -13,6 +13,7 @@
13
13
 
14
14
  import type { RawSql } from './raw-sql';
15
15
  import { pgTypeRefusal } from './pg-type';
16
+ import { assertConstraintName } from './constraints';
16
17
 
17
18
  export type FieldType =
18
19
  | 'text'
@@ -82,6 +83,27 @@ export interface FieldSpec {
82
83
  onDelete?: ReferentialAction;
83
84
  /** `ON UPDATE` action for the FK. Omitted (or `'no action'`) is the Postgres default. */
84
85
  onUpdate?: ReferentialAction;
86
+ /**
87
+ * The FK CONSTRAINT's name, when it has to be a specific one.
88
+ *
89
+ * Undeclared, the compiler generates `<table>_<cols>_<target>_<refcols>_fk`. An adopted
90
+ * database arrives with its previous tooling's names — a Rails app's `fk_rails_9a1b2c3d4e` —
91
+ * and rebuilding under ours renames every one of them. Measured: the differ keys a foreign key
92
+ * on its CONTENT with no name at all, so that rename produced zero changes and every gate
93
+ * stayed green. Same defect as index names, one object over.
94
+ */
95
+ constraintName?: string;
96
+ /**
97
+ * `GENERATED ALWAYS AS IDENTITY` / `GENERATED BY DEFAULT AS IDENTITY`.
98
+ *
99
+ * The SQL-standard replacement for `serial`, and a DIFFERENT mechanism: serial is sugar for a
100
+ * default drawing on an owned sequence, identity lives on the column itself (`attidentity`) and
101
+ * has no default at all. `ALWAYS` additionally refuses a client-supplied value unless the write
102
+ * says `OVERRIDING SYSTEM VALUE` — that refusal is the point, so it is declared, not inferred.
103
+ *
104
+ * Postgres makes an identity column NOT NULL, so the builder sets that too.
105
+ */
106
+ identity?: 'always' | 'byDefault';
85
107
  default?: unknown;
86
108
  defaultKind?: DefaultKind;
87
109
  /** A Zod schema, stored verbatim. Consumed by the field bridge (Phase 4); inert today. */
@@ -96,6 +118,16 @@ export interface FieldSpec {
96
118
  renamedFrom?: string;
97
119
  }
98
120
 
121
+ /**
122
+ * The field types an identity column may sit on.
123
+ *
124
+ * Postgres restricts `GENERATED … AS IDENTITY` to the integer family. `pgType` is included
125
+ * because that is how the vocabulary carries a `smallint` — a real identity type this factory
126
+ * has no first-class call for — and the exact string is the author's word about the type; a
127
+ * wrong one fails at CREATE TABLE with Postgres's own message.
128
+ */
129
+ const IDENTITY_TYPES = new Set<FieldType>(['integer', 'bigint', 'pgType']);
130
+
99
131
  /**
100
132
  * Fluent, mutable builder. Every modifier returns `this` so declarations chain:
101
133
  * `field.text().notNull().private()`. The accumulated {@link FieldSpec} is read
@@ -179,10 +211,46 @@ export class FieldBuilder {
179
211
  * `onDelete`/`onUpdate` referential actions are column-scoped, so they live here, not on
180
212
  * the table — `field.uuid().references(() => User, { onDelete: 'cascade' })`.
181
213
  */
182
- references(target: () => unknown, opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction }): this {
214
+ references(target: () => unknown, opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction; name?: string }): this {
183
215
  this.spec.references = target;
184
216
  if (opts?.onDelete) this.spec.onDelete = opts.onDelete;
185
217
  if (opts?.onUpdate) this.spec.onUpdate = opts.onUpdate;
218
+ if (opts?.name !== undefined) this.spec.constraintName = assertConstraintName(opts.name, 'foreign key');
219
+ return this;
220
+ }
221
+
222
+ /**
223
+ * `GENERATED ALWAYS AS IDENTITY` — Postgres supplies the value and REFUSES a client-supplied
224
+ * one (a write must say `OVERRIDING SYSTEM VALUE` to get past it). The strict form, and the
225
+ * right default for a surrogate key nothing outside the database should choose.
226
+ */
227
+ generatedAlwaysAsIdentity(): this {
228
+ return this.setIdentity('always');
229
+ }
230
+
231
+ /**
232
+ * `GENERATED BY DEFAULT AS IDENTITY` — Postgres supplies the value when the write omits the
233
+ * column, and accepts an explicit one when it does not. The permissive form; it is what a
234
+ * `serial` column behaves like, spelled the standard way.
235
+ */
236
+ generatedByDefaultAsIdentity(): this {
237
+ return this.setIdentity('byDefault');
238
+ }
239
+
240
+ private setIdentity(kind: 'always' | 'byDefault'): this {
241
+ // Postgres allows identity only on an integer type. `serial` is the other sequence-backed
242
+ // mechanism, not a second one to stack — a column has one auto-value or none. Refused at
243
+ // DECLARATION so the error names the call, rather than surfacing as a CREATE TABLE failure
244
+ // several commands later.
245
+ if (this.spec.type === 'serial' || this.spec.type === 'bigserial') {
246
+ throw new Error(`field.${this.spec.type}() is already sequence-backed — use field.${this.spec.type === 'serial' ? 'integer' : 'bigint'}().${kind === 'always' ? 'generatedAlwaysAsIdentity' : 'generatedByDefaultAsIdentity'}() instead of both`);
247
+ }
248
+ if (!IDENTITY_TYPES.has(this.spec.type)) {
249
+ throw new Error(`GENERATED … AS IDENTITY is only valid on an integer column; field.${this.spec.type}() is not one`);
250
+ }
251
+ this.spec.identity = kind;
252
+ // Postgres makes an identity column NOT NULL whether or not anyone wrote it.
253
+ this.spec.isNotNull = true;
186
254
  return this;
187
255
  }
188
256
 
package/src/model.ts CHANGED
@@ -174,6 +174,23 @@ export interface ModelDefinition {
174
174
  * just not exposed. Named `private` to avoid colliding with `schema: 'public'`.
175
175
  */
176
176
  private?: boolean;
177
+ /**
178
+ * `rls: false` declares this table's row security OFF — authorization is grants-only,
179
+ * exactly as a brownfield database holds it (`db:pull` writes this line when the live
180
+ * table has RLS disabled and no ability renders). Without it the compiler declares RLS
181
+ * ENABLED, and the first plan after adopting such a table proposes
182
+ * `ENABLE ROW LEVEL SECURITY` — which, with zero policies, denies every non-owner role
183
+ * the table it already uses. Declaring the live truth is the fix; forcing the flag on
184
+ * was a silent lockdown nobody asked for.
185
+ *
186
+ * `false` is the only value: RLS-on is the default and has one spelling (omit this key).
187
+ * Refused with abilities (a policy under disabled RLS is a dead policy — declare
188
+ * `privileges` for grants-only access) and with `writtenBy: 'app'` (the app principal
189
+ * writes through its policies; there are none). Turning security OFF on a live table is
190
+ * a widening: the emitted `DISABLE ROW LEVEL SECURITY` carries the destructive-plan
191
+ * ceremony.
192
+ */
193
+ rls?: false;
177
194
  /**
178
195
  * Triggers on this table — declared HERE because a trigger is existentially bound
179
196
  * to its table and the table's owner must see what runs on every write (owner
@@ -205,6 +222,8 @@ export interface ModelDescriptor {
205
222
  schema: string;
206
223
  /** `true` removes the table from the generic data API (not in `exposedTables`). Default `false`. */
207
224
  private: boolean;
225
+ /** Row-security posture — `false` = grants-only, no RLS (see {@link ModelDefinition.rls}). Default `true`. */
226
+ rls: boolean;
208
227
  /** One-shot rename hint: this table used to be named `renamedFrom` (same schema). */
209
228
  renamedFrom?: string;
210
229
  /** One-shot move hint: this table used to live in schema `movedFrom` (same name) — emits SET SCHEMA. */
@@ -369,6 +388,36 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
369
388
  }
370
389
  }
371
390
 
391
+ // `rls: false` is the ONLY spelling — RLS-on is the default and one meaning gets one
392
+ // spelling, so `rls: true` (redundant today, load-bearing the day the default moves) is
393
+ // refused rather than accepted as a synonym.
394
+ if (def.rls !== undefined && def.rls !== false) {
395
+ throw new Error(
396
+ `model '${table}': rls: ${JSON.stringify(def.rls)} — RLS enabled is the default; omit the key. ` +
397
+ `The only declaration is rls: false (grants-only authorization, row security off).`,
398
+ );
399
+ }
400
+ if (def.rls === false) {
401
+ // An ability compiles to a policy, and a policy under disabled RLS is a dead policy —
402
+ // it filters nothing while looking like it does. The grants-only spelling is `privileges`.
403
+ if ((def.abilities ?? []).length) {
404
+ throw new Error(
405
+ `model '${table}': rls: false with abilities — an ability compiles to an RLS policy, and a ` +
406
+ `policy under disabled row security is dead: it filters nothing while reading as though it does. ` +
407
+ `Either drop rls: false (policies need RLS) or declare grants-only access via privileges.`,
408
+ );
409
+ }
410
+ // `writtenBy: 'app'` means the app writes THROUGH its policies (RLS FORCEd). With RLS
411
+ // off there are no policies to write through — the combination is a contradiction.
412
+ if ((def.writtenBy ?? 'app') === 'app') {
413
+ throw new Error(
414
+ `model '${table}': rls: false with writtenBy: 'app' (the default) — 'app' writes through RLS ` +
415
+ `policies, and rls: false declares there are none. Declare writtenBy: 'worker' or 'functions' ` +
416
+ `(owner-connection writes), which is how a grants-only table is actually written.`,
417
+ );
418
+ }
419
+ }
420
+
372
421
  validateTriggersFor('table', table, def.triggers ?? []);
373
422
 
374
423
  // A rename and a move in one increment can't be composed from two independent markers
@@ -396,6 +445,7 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
396
445
  softDelete: def.softDelete ?? false,
397
446
  schema: def.schema ?? 'public',
398
447
  private: def.private ?? false,
448
+ rls: def.rls ?? true,
399
449
  ...(def.renamedFrom ? { renamedFrom: def.renamedFrom } : {}),
400
450
  ...(def.movedFrom ? { movedFrom: def.movedFrom } : {}),
401
451
  triggers: def.triggers ?? [],