@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 +1 -1
- package/src/constraints.ts +49 -3
- package/src/derived.ts +120 -4
- package/src/drizzle.ts +15 -1
- package/src/field.ts +69 -1
- package/src/model.ts +50 -0
package/package.json
CHANGED
package/src/constraints.ts
CHANGED
|
@@ -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
|
-
/**
|
|
352
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?? [],
|