@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 +1 -1
- package/src/can.ts +74 -0
- 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/index.ts +2 -1
- package/src/model.ts +48 -11
package/package.json
CHANGED
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 };
|
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/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
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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
|
|
349
|
-
//
|
|
350
|
-
//
|
|
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
|
|
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
|
|
366
|
-
+ `
|
|
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
|
|