@everystack/model 0.4.14 → 0.4.16

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.14",
3
+ "version": "0.4.16",
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
@@ -138,6 +138,80 @@ export function isPublicReadAbility(ability: Ability): boolean {
138
138
  && (!ability.condition.role || ability.condition.role === 'anon');
139
139
  }
140
140
 
141
+ /**
142
+ * Does this ability hand out DELETE?
143
+ *
144
+ * `manage` is "all actions" and compiles to the admin-bypass ALL policy, so it carries DELETE
145
+ * even though nothing spells it. Counting only the literal `delete` verb would leave the
146
+ * soft-delete hazard one grant over — which is exactly the shape `@everystack/auth`'s own
147
+ * user model has: `deletedAt`, no public read, no `can('delete')`, and an admin `manage` that
148
+ * can still destroy the row.
149
+ */
150
+ export function isDeleteAbility(ability: Ability): boolean {
151
+ return ability.action === 'delete' || ability.action === 'manage';
152
+ }
153
+
154
+ /**
155
+ * Does a `privileges` map hand out DELETE?
156
+ *
157
+ * The second spelling of a reachable DELETE, and the one that slipped the first version of
158
+ * this rule. `can()` decides a grant AND a policy together; `privileges` is the key for a
159
+ * grant that has no policy — which is exactly what `db:pull` renders when it finds a live
160
+ * DELETE grant no policy covers. On a `rls: false` table that grant is unrestricted, so the
161
+ * table can be destroyed row by row without a single `can('delete')` in the model.
162
+ *
163
+ * Takes the map rather than a descriptor because both callers already hold one in the same
164
+ * shape (a model's `privileges`, and `db:pull`'s `DerivedAbilities.privileges`), so unlike the
165
+ * ability axis this one needs no second representation and cannot drift at all.
166
+ */
167
+ export function privilegesGrantDelete(privileges: Record<string, readonly string[]> | undefined): boolean {
168
+ return Object.values(privileges ?? {}).some((privs) => privs.includes('DELETE'));
169
+ }
170
+
171
+ /** The three facts the soft-delete rule turns on. See {@link needsSoftDeleteDeclaration}. */
172
+ export interface SoftDeleteFacts {
173
+ /** A `deletedAt` / `deleted_at` field is present. */
174
+ hasDeletedAtField: boolean;
175
+ /** Some ability is a PUBLIC read — see {@link isPublicReadAbility}. */
176
+ hasPublicRead: boolean;
177
+ /**
178
+ * A DELETE can reach this table — via an ability ({@link isDeleteAbility}) or via a
179
+ * `privileges` grant ({@link privilegesGrantDelete}). The SPELLING of the grant was never
180
+ * load-bearing; what matters is that rows can be destroyed.
181
+ */
182
+ grantsDelete: boolean;
183
+ }
184
+
185
+ /**
186
+ * THE rule deciding when `softDelete` must be declared rather than defaulted.
187
+ *
188
+ * `softDelete` changes observable behaviour on TWO axes, and the refusal has to cover both:
189
+ *
190
+ * - **visibility** — on a model with a public read, `true` AND-s `deleted_at IS NULL` into the
191
+ * anon policy. What a caller with no session can see.
192
+ * - **durability** — on ANY model that can be deleted from, `true` makes the handler mark
193
+ * `deleted_at` instead of removing the row. What a DELETE destroys.
194
+ *
195
+ * The guard originally asked only the visibility question, so a model with an owner-scoped
196
+ * read and a `delete` ability sailed past it and silently defaulted to `false`. That is how
197
+ * `uploads` and `image_variants` began hard-deleting rows, orphaning their S3 objects forever
198
+ * — on those models `softDelete: true` adds no policy predicate at all, so visibility was
199
+ * never the axis that mattered.
200
+ *
201
+ * Deliberately NOT "any model with a `deletedAt` field": where nothing reads publicly and
202
+ * nothing deletes, the flag provably changes neither SQL nor handler behaviour, and refusing
203
+ * there would be ceremony charged to every brownfield pull for no hazard.
204
+ *
205
+ * Stated over FACTS, not over a descriptor, because it has two callers holding two
206
+ * representations: `defineModel` has structured {@link Ability} objects, and `db:pull`'s
207
+ * renderer only ever holds rendered source text. Both extract the same three facts and share
208
+ * this one boolean, so the two surfaces cannot drift on the rule itself — the failure that
209
+ * already bit this guard once, when the refusal and the renderer disagreed about `role: 'anon'`.
210
+ */
211
+ export function needsSoftDeleteDeclaration(facts: SoftDeleteFacts): boolean {
212
+ return facts.hasDeletedAtField && (facts.hasPublicRead || facts.grantsDelete);
213
+ }
214
+
141
215
  /** Declare one authorization rule. */
142
216
  export function can(action: Action, condition: AbilityCondition = {}): Ability {
143
217
  return { action, condition };
@@ -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/index.ts CHANGED
@@ -20,7 +20,8 @@ export type { TableConstraint } from './constraints';
20
20
  export { sql } from './raw-sql';
21
21
  export type { RawSql } from './raw-sql';
22
22
 
23
- export { can, isColumnAbility, isPublicReadAbility } from './can';
23
+ export { can, isColumnAbility, isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete } from './can';
24
+ export type { SoftDeleteFacts } from './can';
24
25
  export type { Ability, AbilityCondition, Action } from './can';
25
26
 
26
27
  export { belongsTo, hasMany } from './relations';
package/src/model.ts CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { FieldBuilder } from './field';
15
15
  import type { Ability } from './can';
16
- import { isPublicReadAbility } from './can';
16
+ import { isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete } from './can';
17
17
  import type { Relation } from './relations';
18
18
  import type { TableConstraint } from './constraints';
19
19
  import type { RawSql } from './raw-sql';
@@ -125,9 +125,27 @@ export interface ModelDefinition {
125
125
  */
126
126
  privileges?: Record<string, TablePrivilege[]>;
127
127
  /**
128
- * `true` excludes soft-deleted rows (`deleted_at IS NULL`) from the public read policies
129
- * and from the generic data API. **Required — not inferred — on any model that has a
130
- * `deletedAt` field and a public read.**
128
+ * What a soft delete MEANS on this table. It drives two different machines, and both
129
+ * consequences are real — a model declaring it should expect all of this:
130
+ *
131
+ * - **The generic data API** (`deriveHandlerConfig` → `createHandler`): `DELETE` becomes
132
+ * `UPDATE SET deleted_at = now()` instead of removing the row; and soft-deleted rows are
133
+ * then filtered out of GET (for EVERY role, unless `?deleted=include|only`), out of
134
+ * embedded relation reads, and out of UPDATE. This half applies to every model, whatever
135
+ * its audience.
136
+ * - **RLS**: `deleted_at IS NULL` is AND-ed into the PUBLIC read policies. This half applies
137
+ * only where there is a public read — on an owner-scoped or admin-only model the compiled
138
+ * contract is byte-identical either way (pinned in cli's `public-read-pin.test.ts`).
139
+ *
140
+ * **Required — not inferred — on any model with a `deletedAt` field that either has a public
141
+ * read OR grants DELETE** (`can('delete')`, or `can('manage')`, which is "all actions"). See
142
+ * {@link needsSoftDeleteDeclaration}, the one rule this and `db:pull` share.
143
+ *
144
+ * The guard used to ask only about the public read, which is the visibility axis. That let a
145
+ * model with an owner-scoped read and a `delete` ability default to `false` and begin
146
+ * destroying rows — with an S3 purge job keyed on `deleted_at IS NOT NULL`, that orphaned
147
+ * every file it should have cleaned up. On those models the flag adds no policy at all, so
148
+ * visibility was never the axis that mattered.
131
149
  *
132
150
  * It used to be inferred from the field's mere presence. A column named `deleted_at` is
133
151
  * not a statement of visibility intent: it is carried for audit, for app-layer filtering,
@@ -345,9 +363,12 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
345
363
  // The soft-delete guard used to be inferred from the field's presence, which meant a model
346
364
  // could gain — or, once the inference was removed, silently LOSE — a predicate that decides
347
365
  // what anonymous users can see, with nothing in any diff to review. Neither default is safe,
348
- // so there is no default: on the exact set of models where the answer changes behaviour (a
349
- // `deletedAt` field plus a public read), it must be declared. `softDelete: true` is what the
350
- // inference used to do.
366
+ // so there is no default: on the exact set of models where the answer changes behaviour, it
367
+ // must be declared. `softDelete: true` is what the inference used to do.
368
+ //
369
+ // That set is NOT "has a public read". It is the union of two axes — see
370
+ // `needsSoftDeleteDeclaration`, which is the whole rule and is shared with db:pull so the two
371
+ // surfaces cannot drift on it.
351
372
  // `isPublicReadAbility`, not a local copy: this refusal and db:pull's renderer were asking
352
373
  // the same question — "can a caller with no session see these rows?" — and answering it
353
374
  // differently. The renderer already counted `role: 'anon'`; this refusal did not, so a table
@@ -356,15 +377,31 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
356
377
  // spelled. Explicit `softDelete: false` remains the one-line answer, and pull writes that
357
378
  // line into every model it renders, so a pulled checkout keeps loading.
358
379
  const hasPublicRead = (def.abilities ?? []).some(isPublicReadAbility);
380
+ // Either spelling of a reachable DELETE. `privileges` matters most on an `rls: false` table,
381
+ // where the grant is unrestricted and no policy stands between it and every row.
382
+ const grantsDelete = (def.abilities ?? []).some(isDeleteAbility) || privilegesGrantDelete(def.privileges);
359
383
  // Both spellings of the field key. The guard used to test `'deletedAt' in def.fields`
360
384
  // literally, so a model keying the column `deleted_at` — the shape a snake_case brownfield
361
385
  // schema pulls as — carried a soft-delete column past a guard that never looked at it.
362
386
  const softDeleteField = 'deletedAt' in def.fields || 'deleted_at' in def.fields;
363
- if (softDeleteField && hasPublicRead && def.softDelete === undefined) {
387
+ if (needsSoftDeleteDeclaration({ hasDeletedAtField: softDeleteField, hasPublicRead, grantsDelete }) && def.softDelete === undefined) {
388
+ // DURABILITY FIRST. The old message called this "a visibility decision", which is false
389
+ // for exactly the models that broke: with no public read, `softDelete: true` adds no
390
+ // policy predicate at all. Naming the wrong consequence teaches the wrong fix, so each
391
+ // axis is stated only when it actually fired.
392
+ const why = grantsDelete
393
+ ? `grants DELETE (via can('delete'), can('manage') — which is "all actions" — or a privileges DELETE grant), `
394
+ + `so it must declare softDelete. `
395
+ + `This is a durability decision: softDelete: true makes a DELETE mark deleted_at instead of destroying the row; `
396
+ + `softDelete: false makes DELETE permanent.`
397
+ : `has a public read, so it must declare softDelete.`;
398
+ const visibility = hasPublicRead
399
+ ? ` It is also a visibility decision: softDelete: true excludes soft-deleted rows from the public read policies `
400
+ + `(what the compiler used to infer from the field's presence); softDelete: false leaves them readable.`
401
+ : '';
364
402
  throw new Error(
365
- `model '${table}': has a 'deletedAt' field and a public read, so it must declare softDelete. `
366
- + `softDelete: true excludes soft-deleted rows from the public read policies (what the compiler used to infer from the field's presence); `
367
- + `softDelete: false leaves them readable. This is a visibility decision, so it is declared, not guessed.`,
403
+ `model '${table}': has a 'deletedAt' field and ${why}${visibility} `
404
+ + `What a DELETE destroys is declared, not guessed — add softDelete: true or softDelete: false.`,
368
405
  );
369
406
  }
370
407