@everystack/model 0.4.22 → 0.4.24
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/CHANGELOG.md +67 -0
- package/README.md +288 -3
- package/package.json +1 -1
- package/src/can.ts +8 -0
- package/src/constraints.ts +21 -3
- package/src/dependents.ts +236 -0
- package/src/derive.ts +46 -11
- package/src/derived.ts +1 -1
- package/src/drizzle.ts +4 -0
- package/src/external.ts +119 -0
- package/src/field.ts +27 -4
- package/src/index.ts +10 -5
- package/src/lifecycle-hooks.ts +53 -0
- package/src/link.ts +40 -0
- package/src/model.ts +294 -6
- package/src/module.ts +8 -3
- package/src/relation-display.ts +48 -0
- package/src/relations.ts +207 -5
- package/src/wire.ts +4 -1
package/src/link.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { ModelDescriptor } from './model';
|
|
2
2
|
import { parseQualified } from './qualified';
|
|
3
|
+
import { assertExternalTargets } from './external';
|
|
4
|
+
import { relationDeleteActions } from './dependents';
|
|
5
|
+
import { hasDisplayOptions, normalizeRelationDisplay } from './relation-display';
|
|
3
6
|
|
|
4
7
|
/** A failed or conflicting name binding must never become a usable model. */
|
|
5
8
|
export class ModelLinkError extends Error {
|
|
@@ -97,11 +100,14 @@ export function linkModels<T extends readonly ModelDescriptor[]>(models: T): T {
|
|
|
97
100
|
// Resolve everything before changing descriptors, so a typo can be corrected and retried
|
|
98
101
|
// without leaving earlier references bound to a different, incomplete set.
|
|
99
102
|
const replacements: Array<() => void> = [];
|
|
103
|
+
// What each name reference resolves to in THIS set, before anything is bound.
|
|
104
|
+
const resolvedNames = new Map<unknown, ModelDescriptor>();
|
|
100
105
|
for (const model of uniqueModels) {
|
|
101
106
|
for (const [key, field] of Object.entries(model.fields)) {
|
|
102
107
|
const target = field.spec.references;
|
|
103
108
|
if (target && isNameReference(target)) {
|
|
104
109
|
const resolved = resolve(target as NameReference<ModelDescriptor>, model, 'field', key);
|
|
110
|
+
resolvedNames.set(target, resolved);
|
|
105
111
|
if (target.boundTarget) continue;
|
|
106
112
|
const linked = Object.assign(() => resolved, {
|
|
107
113
|
refName: target.refName, refLocation: target.refLocation, boundTarget: resolved,
|
|
@@ -113,6 +119,7 @@ export function linkModels<T extends readonly ModelDescriptor[]>(models: T): T {
|
|
|
113
119
|
const target = relation.target;
|
|
114
120
|
if (isNameReference(target)) {
|
|
115
121
|
const resolved = resolve(target, model, 'relation', key);
|
|
122
|
+
resolvedNames.set(target, resolved);
|
|
116
123
|
if (target.boundTarget) continue;
|
|
117
124
|
const linked = Object.assign(() => resolved, {
|
|
118
125
|
refName: target.refName, refLocation: target.refLocation, boundTarget: resolved,
|
|
@@ -121,6 +128,39 @@ export function linkModels<T extends readonly ModelDescriptor[]>(models: T): T {
|
|
|
121
128
|
}
|
|
122
129
|
}
|
|
123
130
|
}
|
|
131
|
+
// An external `{ schema, table }` target naming a table this set models is refused: the
|
|
132
|
+
// author must use the model. Checked before anything is bound, like every error above.
|
|
133
|
+
try {
|
|
134
|
+
assertExternalTargets(uniqueModels);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
throw new ModelLinkError(err instanceof Error ? err.message : String(err));
|
|
137
|
+
}
|
|
138
|
+
// hasMany `dependent` → the child FK's ON DELETE: checked here, before anything is bound, and
|
|
139
|
+
// never written into a spec (the compiler derives it per set). Only a relation that declares a
|
|
140
|
+
// dependent has its target (or its child's FK target) evaluated.
|
|
141
|
+
try {
|
|
142
|
+
relationDeleteActions(uniqueModels, (thunk) => resolvedNames.get(thunk) ?? thunk() as ModelDescriptor);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (err instanceof ModelLinkError) throw err;
|
|
145
|
+
throw new ModelLinkError(err instanceof Error ? err.message : String(err));
|
|
146
|
+
}
|
|
147
|
+
// Display options (orderBy, where; every hasOne) name TARGET fields: checked here, before
|
|
148
|
+
// anything is bound, and the orderBy normalized once (the target's primary key appended).
|
|
149
|
+
// Only such a relation has its target evaluated. Display options never reach a dependent.
|
|
150
|
+
for (const model of uniqueModels) {
|
|
151
|
+
for (const [key, relation] of Object.entries(model.relations)) {
|
|
152
|
+
if (relation.kind === 'belongsTo' || !hasDisplayOptions(relation)) continue;
|
|
153
|
+
let orderBy: ReturnType<typeof normalizeRelationDisplay>;
|
|
154
|
+
try {
|
|
155
|
+
const target = resolvedNames.get(relation.target) ?? relation.target();
|
|
156
|
+
orderBy = normalizeRelationDisplay(modelName(model), key, relation, target);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (err instanceof ModelLinkError) throw err;
|
|
159
|
+
throw new ModelLinkError(err instanceof Error ? err.message : String(err));
|
|
160
|
+
}
|
|
161
|
+
if (orderBy) replacements.push(() => { relation.orderBy = orderBy; });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
124
164
|
for (const replace of replacements) replace();
|
|
125
165
|
return models;
|
|
126
166
|
}
|
package/src/model.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dependentWhereRefusal } from './dependents';
|
|
1
2
|
/**
|
|
2
3
|
* `defineModel` — Layer 2. One call colocates everything about a table: its
|
|
3
4
|
* fields, its authorization (`abilities`), its mutation hooks, and its relations.
|
|
@@ -11,16 +12,20 @@
|
|
|
11
12
|
* compiler (`compileTableContract`, what `db:generate` emits as RLS + grants).
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
|
-
import type
|
|
15
|
+
import { field, type FieldBuilder } from './field';
|
|
15
16
|
import { referenceLocation } from './link';
|
|
16
17
|
import type { Ability } from './can';
|
|
17
|
-
import { isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete, sessionlessAudience } from './can';
|
|
18
|
-
import type
|
|
18
|
+
import { can, isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete, sessionlessAudience } from './can';
|
|
19
|
+
import { assertRelationShape, type Relation } from './relations';
|
|
19
20
|
import { assertCheckNames, type TableConstraint } from './constraints';
|
|
21
|
+
import { lifecycleCascadeHarm } from './dependents';
|
|
20
22
|
import type { RawSql } from './raw-sql';
|
|
21
23
|
import { validateTriggersFor, type TriggerSpec, type DependsOnRef } from './derived';
|
|
22
24
|
import { TABLE_PRIVILEGES, type TablePrivilege } from './privileges';
|
|
23
25
|
|
|
26
|
+
import type { AfterLifecycleHook, BeforeLifecycleHook, DestroyHook } from './lifecycle-hooks';
|
|
27
|
+
export type { AfterLifecycleHook, BeforeLifecycleHook, DestroyContext, DestroyHook, HookVeto, LifecycleContext } from './lifecycle-hooks';
|
|
28
|
+
|
|
24
29
|
/**
|
|
25
30
|
* Per-table mutation hooks. Imperative function bodies the framework cannot
|
|
26
31
|
* absorb (purge an S3 object on delete, stamp a column on create) — held
|
|
@@ -33,6 +38,22 @@ export interface ModelHooks {
|
|
|
33
38
|
afterUpdate?: (...args: any[]) => any;
|
|
34
39
|
beforeDelete?: (...args: any[]) => any;
|
|
35
40
|
afterDelete?: (...args: any[]) => any;
|
|
41
|
+
/** Runs after the DELETE statement, before commit. A throw rolls back only the database
|
|
42
|
+
* delete, never an external effect. No transaction is passed. Must be idempotent.
|
|
43
|
+
* Treat the row as read-only: it is the same object later serialized into the response. */
|
|
44
|
+
beforeDestroy?: DestroyHook;
|
|
45
|
+
/** Runs after commit, best-effort. Cannot undo the removal. Must be idempotent.
|
|
46
|
+
* Treat the row as read-only: it is the same object later serialized into the response. */
|
|
47
|
+
afterDestroy?: DestroyHook;
|
|
48
|
+
/** Lifecycle tables: before the API DELETE stamps, once per matched root row, inside its transaction.
|
|
49
|
+
* Return `veto(reason)` to refuse the whole request (409). Cascaded children run no hook: guard them with `deleteWhen`. */
|
|
50
|
+
beforeStamp?: BeforeLifecycleHook;
|
|
51
|
+
/** Lifecycle tables: after a stamping DELETE commits, once per batch, with that batch's root rows. Best-effort. */
|
|
52
|
+
afterStamp?: AfterLifecycleHook;
|
|
53
|
+
/** Lifecycle tables: after `POST {table}/restore` commits, with `[{ <key>: id }]`, the batch and the counts. Best-effort. */
|
|
54
|
+
afterRestore?: AfterLifecycleHook;
|
|
55
|
+
/** Reserved for a future release; createHandler refuses it. */
|
|
56
|
+
beforeRestore?: never;
|
|
36
57
|
}
|
|
37
58
|
|
|
38
59
|
/**
|
|
@@ -115,6 +136,18 @@ export interface ModelDefinition {
|
|
|
115
136
|
* was always explicit; this matches it. Convention may propose it, never enforce it.
|
|
116
137
|
*/
|
|
117
138
|
softDelete?: boolean;
|
|
139
|
+
/**
|
|
140
|
+
* `lifecycle: true` — a soft delete with a delay. defineModel generates four nullable columns
|
|
141
|
+
* ({@link LIFECYCLE_COLUMNS}): `deletedAt` timestamptz, `deletedBy` text (no foreign key),
|
|
142
|
+
* `deletedBatch` uuid, `deletedXid` bigint. The author never declares them, never combines
|
|
143
|
+
* lifecycle with `softDelete`, and declares a single-column primary key; each is refused here.
|
|
144
|
+
*
|
|
145
|
+
* `{ deleteWhen: sql\`<predicate>\` }` is lifecycle too, with a guard: a row may be stamped
|
|
146
|
+
* deleted only when the predicate is TRUE for it (FALSE or NULL refuses, SQLSTATE 23514), whether
|
|
147
|
+
* the delete is the row's own or a parent's cascade. Bare column names, like a policy predicate.
|
|
148
|
+
* Purge keeps the row (a `skipped` receipt) when the predicate no longer holds. `{}` is `true`.
|
|
149
|
+
*/
|
|
150
|
+
lifecycle?: true | LifecycleOptions;
|
|
118
151
|
/**
|
|
119
152
|
* The Postgres schema the table lives in. Default `'public'`. Package-owned
|
|
120
153
|
* tables declare their own (`'dist'`, `'ops'`, `'auth'`) so one db:generate run
|
|
@@ -195,6 +228,11 @@ export interface ModelDescriptor {
|
|
|
195
228
|
privileges: Record<string, TablePrivilege[]>;
|
|
196
229
|
/** `true` excludes `deleted_at IS NULL` rows from public reads and the data API. Declared, never inferred. */
|
|
197
230
|
softDelete: boolean;
|
|
231
|
+
/** Present only when declared: `fields` carries the generated {@link LIFECYCLE_COLUMNS}. Always
|
|
232
|
+
* `true` on the descriptor, whichever form the definition used. */
|
|
233
|
+
lifecycle?: true;
|
|
234
|
+
/** `lifecycle: { deleteWhen }`'s predicate text. Absent when there is no guard. */
|
|
235
|
+
deleteWhen?: string;
|
|
198
236
|
/** The resolved Postgres schema (default `'public'`). Package tables live in their own. */
|
|
199
237
|
schema: string;
|
|
200
238
|
/** `true` removes the table from the generic data API (not in `exposedTables`). Default `false`. */
|
|
@@ -227,12 +265,224 @@ export interface MaterializedTableMarker {
|
|
|
227
265
|
comment?: string;
|
|
228
266
|
}
|
|
229
267
|
|
|
268
|
+
/** The field keys `lifecycle: true` generates, in the order defineModel appends them. */
|
|
269
|
+
export const LIFECYCLE_COLUMNS = ['deletedAt', 'deletedBy', 'deletedBatch', 'deletedXid'] as const;
|
|
270
|
+
|
|
271
|
+
/** Both spellings of each generated key: an author declaring either collides with the generated column. */
|
|
272
|
+
const LIFECYCLE_RESERVED = [...LIFECYCLE_COLUMNS, 'deleted_at', 'deleted_by', 'deleted_batch', 'deleted_xid'];
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The role a lifecycle model grants to its delete machinery. defineModel appends its abilities
|
|
276
|
+
* to every `lifecycle: true` model, so no author ability may name it, on any model.
|
|
277
|
+
*/
|
|
278
|
+
export const LIFECYCLE_OWNER_ROLE = 'everystack_lifecycle';
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The lifecycle role's abilities: read every row (deleted ones included), update only the four
|
|
282
|
+
* generated columns, delete. No insert, no manage.
|
|
283
|
+
*/
|
|
284
|
+
function lifecycleAbilities(): Ability[] {
|
|
285
|
+
const role = LIFECYCLE_OWNER_ROLE;
|
|
286
|
+
return [
|
|
287
|
+
can('read', { role }),
|
|
288
|
+
can('update', { role, columns: [...LIFECYCLE_COLUMNS] }),
|
|
289
|
+
can('delete', { role }),
|
|
290
|
+
];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function assertRoleNotReserved(table: string, def: ModelDefinition): void {
|
|
294
|
+
// A privileges entry is refused too, even an empty list: TABLE_PRIVILEGES includes TRUNCATE
|
|
295
|
+
// and TRIGGER, which would let the role bypass RLS or attach triggers beside the guards.
|
|
296
|
+
if (def.privileges && Object.prototype.hasOwnProperty.call(def.privileges, LIFECYCLE_OWNER_ROLE)) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
`model '${table}': privileges['${LIFECYCLE_OWNER_ROLE}'] — the role '${LIFECYCLE_OWNER_ROLE}' is reserved for lifecycle; ` +
|
|
299
|
+
`the compiler grants it automatically on a lifecycle: true model. Remove the entry.`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
for (const a of def.abilities ?? []) {
|
|
303
|
+
if (a.condition.role !== LIFECYCLE_OWNER_ROLE) continue;
|
|
304
|
+
throw new Error(
|
|
305
|
+
`model '${table}': can('${a.action}', { role: '${LIFECYCLE_OWNER_ROLE}' }) — the role '${LIFECYCLE_OWNER_ROLE}' ` +
|
|
306
|
+
`is reserved for lifecycle; the compiler grants it automatically on a lifecycle: true model. Remove the ability.`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Fresh builders on every call: a descriptor owns its fields, never shares them with another model. */
|
|
312
|
+
function lifecycleFields(): Record<(typeof LIFECYCLE_COLUMNS)[number], FieldBuilder> {
|
|
313
|
+
return {
|
|
314
|
+
deletedAt: field.timestamptz().nullable(),
|
|
315
|
+
deletedBy: field.text().nullable(),
|
|
316
|
+
deletedBatch: field.uuid().nullable(),
|
|
317
|
+
deletedXid: field.bigint().nullable(),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** `lifecycle: { … }` options. */
|
|
322
|
+
export interface LifecycleOptions {
|
|
323
|
+
/** A row may be stamped deleted only when this is TRUE for it; FALSE or NULL refuses. */
|
|
324
|
+
deleteWhen?: RawSql;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** True for `lifecycle: true` and for `lifecycle: { … }` (validated by assertLifecycle). */
|
|
328
|
+
function isLifecycle(def: ModelDefinition): boolean {
|
|
329
|
+
return def.lifecycle === true || (typeof def.lifecycle === 'object' && def.lifecycle !== null);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The guard predicate text, or undefined. Call after assertLifecycle. */
|
|
333
|
+
function deleteWhenOf(def: ModelDefinition): string | undefined {
|
|
334
|
+
const value = def.lifecycle;
|
|
335
|
+
return typeof value === 'object' && value !== null && value.deleteWhen !== undefined ? value.deleteWhen.sql : undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function assertLifecycle(table: string, def: ModelDefinition): void {
|
|
339
|
+
const value: unknown = def.lifecycle;
|
|
340
|
+
if (value === undefined) return;
|
|
341
|
+
if (value === false) {
|
|
342
|
+
throw new Error(`model '${table}': lifecycle: false is not supported yet. Omit the key for a model without lifecycle.`);
|
|
343
|
+
}
|
|
344
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
345
|
+
const unknown = Object.keys(value).filter((k) => k !== 'deleteWhen');
|
|
346
|
+
if (unknown.length > 0) {
|
|
347
|
+
throw new Error(`model '${table}': lifecycle: { ${unknown.join(', ')} } — the only key is deleteWhen (lifecycle: { deleteWhen: sql\`…\` }).`);
|
|
348
|
+
}
|
|
349
|
+
const when = (value as { deleteWhen?: unknown }).deleteWhen;
|
|
350
|
+
if (when !== undefined && !(typeof when === 'object' && when !== null && typeof (when as RawSql).sql === 'string' && (when as RawSql).sql.trim() !== '')) {
|
|
351
|
+
throw new Error(`model '${table}': lifecycle: { deleteWhen } — deleteWhen must be sql\`…\`, a non-empty predicate over this table's columns.`);
|
|
352
|
+
}
|
|
353
|
+
} else if (value !== true) {
|
|
354
|
+
throw new Error(`model '${table}': lifecycle: ${JSON.stringify(value)} — the only values are lifecycle: true and lifecycle: { deleteWhen: sql\`…\` }.`);
|
|
355
|
+
}
|
|
356
|
+
if (def.softDelete !== undefined) {
|
|
357
|
+
throw new Error(
|
|
358
|
+
`model '${table}': lifecycle replaces softDelete — delete softDelete: ${String(def.softDelete)}. ` +
|
|
359
|
+
`lifecycle: true already declares what a delete does.`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
for (const name of LIFECYCLE_RESERVED) {
|
|
363
|
+
if (name in def.fields) {
|
|
364
|
+
throw new Error(
|
|
365
|
+
`model '${table}': field '${name}' — with lifecycle: true the compiler generates this column; delete the declaration.`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// The lifecycle role's policies and the deleted_at read guard are RLS policies; under disabled
|
|
370
|
+
// row security they filter nothing while reading as though they do.
|
|
371
|
+
if (def.rls === false) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
`model '${table}': lifecycle: true with rls: false — lifecycle hides deleted rows with RLS policies, ` +
|
|
374
|
+
`and rls: false declares there are none, so the guard would be dead. Either drop rls: false (lifecycle needs RLS) ` +
|
|
375
|
+
`or drop lifecycle: true.`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
// A cascade into this table, declared on this table's own foreign key. A parent's
|
|
379
|
+
// hasMany { dependent: 'cascade' } is checked in relationDeleteActions (allowed from a
|
|
380
|
+
// lifecycle parent, where it compiles to RESTRICT and the triggers soft-delete the children).
|
|
381
|
+
const tableName = def.schema && def.schema !== 'public' ? `${def.schema}.${table}` : table;
|
|
382
|
+
for (const [name, f] of Object.entries(def.fields)) {
|
|
383
|
+
if (f.spec.onDelete !== 'cascade') continue;
|
|
384
|
+
throw new Error(`model '${tableName}': field '${name}' declares onDelete 'cascade'. ${lifecycleCascadeHarm(`${tableName}.${name}`)}.`);
|
|
385
|
+
}
|
|
386
|
+
for (const c of def.constraints ?? []) {
|
|
387
|
+
if (c.kind !== 'foreignKey' || c.onDelete !== 'cascade') continue;
|
|
388
|
+
const where = `${tableName}.${c.columns.join(', ')}`;
|
|
389
|
+
throw new Error(`model '${tableName}': foreignKey(${c.columns.join(', ')}) declares onDelete 'cascade'. ${lifecycleCascadeHarm(where)}.`);
|
|
390
|
+
}
|
|
391
|
+
const pk = Object.entries(def.fields).filter(([, f]) => f.spec.isPrimaryKey).length;
|
|
392
|
+
if (pk !== 1) {
|
|
393
|
+
throw new Error(
|
|
394
|
+
`model '${table}': lifecycle needs a single-column primary key — this model declares ${pk === 0 ? 'none' : `${pk} (composite)`}. ` +
|
|
395
|
+
`Mark exactly one field .primaryKey().`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
// The generated trigger and function names are `<table>_lifecycle_transition` and `_guard`.
|
|
399
|
+
// PostgreSQL silently truncates an identifier past 63 bytes, so the declared name would not be the live one.
|
|
400
|
+
// UTF-8 bytes, as PostgreSQL counts them. TextEncoder rather than Buffer: a model also loads outside Node.
|
|
401
|
+
const longest = `${table}_lifecycle_transition`;
|
|
402
|
+
const bytes = new TextEncoder().encode(longest).length;
|
|
403
|
+
if (bytes > 63) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`model '${table}': lifecycle generates '${longest}' (${bytes} bytes), past PostgreSQL's 63-byte identifier limit. ` +
|
|
406
|
+
`A lifecycle table name may be at most 42 bytes; shorten the table name.`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Restore runs the table's delete rule inside a SECURITY DEFINER function as the lifecycle role.
|
|
413
|
+
* Refuse a delete rule whose meaning would change there: a subquery (`via`, or SELECT in
|
|
414
|
+
* where/sql) runs with that role's privileges, and current_user/session_user name that role or
|
|
415
|
+
* the login, not the caller. TRUNCATE is refused because it skips the delete triggers.
|
|
416
|
+
*/
|
|
417
|
+
function assertLifecycleRestoreSafe(table: string, def: ModelDefinition): void {
|
|
418
|
+
if (!isLifecycle(def)) return;
|
|
419
|
+
const why = 'restore runs the delete rule inside a SECURITY DEFINER function as the lifecycle owner';
|
|
420
|
+
const abilities = def.abilities ?? [];
|
|
421
|
+
// A role-less (or authenticated) delete on a model scoped only through `via` compiles to the
|
|
422
|
+
// via subquery too, though the ability itself names no via.
|
|
423
|
+
const viaScopedModel = !abilities.some((a) => a.condition.owner) && abilities.some((a) => a.condition.via);
|
|
424
|
+
for (const a of abilities) {
|
|
425
|
+
if (a.action !== 'delete' && a.action !== 'manage') continue;
|
|
426
|
+
// PUBLIC would get EXECUTE on restore, and a branch with no role behind it: anon restores.
|
|
427
|
+
if (a.condition.role && a.condition.role.toLowerCase() === 'public') {
|
|
428
|
+
throw new Error(
|
|
429
|
+
`model '${table}': can('${a.action}', { role: '${a.condition.role}' }) on a lifecycle: true model — ${why}, and ` +
|
|
430
|
+
`restore is granted to whoever may delete, so every role, anon included, could restore rows. ` +
|
|
431
|
+
`Use a named role (authenticated, or one of your own) for the delete rule.`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
const authClass = !a.condition.role || a.condition.role === 'authenticated';
|
|
435
|
+
if (a.condition.via || (viaScopedModel && authClass)) {
|
|
436
|
+
const spelled = a.condition.via ? `can('${a.action}', { via: '${a.condition.via}' })` : `can('${a.action}') on a model scoped by via`;
|
|
437
|
+
throw new Error(
|
|
438
|
+
`model '${table}': ${spelled} on a lifecycle: true model — ${why}, where a via ` +
|
|
439
|
+
`subquery would read the parent with that role's privileges. Use owner, or a role with a plain where, for the delete rule.`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
for (const key of ['where', 'sql'] as const) {
|
|
443
|
+
const text = (a.condition[key] as { sql?: unknown } | undefined)?.sql;
|
|
444
|
+
if (typeof text !== 'string') continue;
|
|
445
|
+
const offender = /\bselect\b/i.test(text)
|
|
446
|
+
? 'a subquery (SELECT)'
|
|
447
|
+
: /\bcurrent_user\b/i.test(text)
|
|
448
|
+
? 'current_user'
|
|
449
|
+
: /\bsession_user\b/i.test(text)
|
|
450
|
+
? 'session_user'
|
|
451
|
+
: null;
|
|
452
|
+
if (!offender) continue;
|
|
453
|
+
throw new Error(
|
|
454
|
+
`model '${table}': can('${a.action}', { ${key} }) uses ${offender} on a lifecycle: true model — ${why}, ` +
|
|
455
|
+
`where a subquery runs with that role's privileges and current_user/session_user do not name the caller. ` +
|
|
456
|
+
`Replace it with a predicate on the row's own columns or the request claims.`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
for (const [role, privs] of Object.entries(def.privileges ?? {})) {
|
|
461
|
+
if (!Array.isArray(privs) || !privs.includes('TRUNCATE')) continue;
|
|
462
|
+
throw new Error(
|
|
463
|
+
`model '${table}': privileges['${role}'] grants TRUNCATE on a lifecycle: true model — TRUNCATE skips the delete ` +
|
|
464
|
+
`triggers, so it would erase rows past the soft delete and restore. Remove TRUNCATE from the entry.`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
230
469
|
export function defineModel(table: string, def: ModelDefinition): ModelDescriptor {
|
|
470
|
+
// Refuse first, then inject: the author-declared collisions are checked against what the author wrote.
|
|
471
|
+
assertRoleNotReserved(table, def);
|
|
472
|
+
assertLifecycle(table, def);
|
|
473
|
+
assertLifecycleRestoreSafe(table, def);
|
|
474
|
+
const lifecycle = isLifecycle(def);
|
|
475
|
+
const deleteWhen = deleteWhenOf(def);
|
|
476
|
+
const fields: Record<string, FieldBuilder> = lifecycle ? { ...def.fields, ...lifecycleFields() } : def.fields;
|
|
477
|
+
|
|
231
478
|
for (const [name, field] of Object.entries(def.fields)) {
|
|
232
479
|
referenceLocation(field.spec.references, `${table}.${name}`);
|
|
233
480
|
}
|
|
234
481
|
for (const [name, relation] of Object.entries(def.relations ?? {})) {
|
|
482
|
+
assertRelationShape(def.schema && def.schema !== 'public' ? `${def.schema}.${table}` : table, name, relation);
|
|
235
483
|
referenceLocation(relation.target, `${table}.${name}`);
|
|
484
|
+
const filtered = lifecycle ? dependentWhereRefusal(def.schema && def.schema !== 'public' ? `${def.schema}.${table}` : table, name, relation) : undefined;
|
|
485
|
+
if (filtered) throw new Error(filtered);
|
|
236
486
|
}
|
|
237
487
|
const primaryKey = Object.entries(def.fields)
|
|
238
488
|
.filter(([, f]) => f.spec.isPrimaryKey)
|
|
@@ -243,6 +493,28 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
243
493
|
throw new Error(`model '${table}': can('execute') is a function ability — declare it on the defineFunction it gates.`);
|
|
244
494
|
}
|
|
245
495
|
|
|
496
|
+
if (a.condition.deleted !== undefined) {
|
|
497
|
+
if (a.action !== 'read') {
|
|
498
|
+
throw new Error(`model '${table}': deleted applies only to can('read'), not can('${a.action}').`);
|
|
499
|
+
}
|
|
500
|
+
if (a.condition.deleted !== 'include' && a.condition.deleted !== 'only') {
|
|
501
|
+
throw new Error(`model '${table}': can('read', { deleted: ${JSON.stringify(a.condition.deleted)} }) — deleted must be 'include' or 'only'.`);
|
|
502
|
+
}
|
|
503
|
+
if (!lifecycle) {
|
|
504
|
+
throw new Error(`model '${table}': can('read', { deleted }) — deleted applies only to a lifecycle model; this model has no deleted rows to include.`);
|
|
505
|
+
}
|
|
506
|
+
if (!a.condition.role) {
|
|
507
|
+
throw new Error(`model '${table}': can('read', { deleted }) — deleted needs a role: it changes that role's own read policy. Name the role that may see deleted rows.`);
|
|
508
|
+
}
|
|
509
|
+
// A public tier is every caller of the API, and db:pull could not fold the table back.
|
|
510
|
+
if (['anon', 'authenticated', 'public'].includes(a.condition.role.toLowerCase())) {
|
|
511
|
+
throw new Error(
|
|
512
|
+
`model '${table}': can('read', { role: '${a.condition.role}', deleted }) — deleted rows are for a named internal role, ` +
|
|
513
|
+
`never a public tier (anon, authenticated, public). Name the role that searches or restores, such as 'support_role'.`,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
246
518
|
// A column list is authorization, not a selection hint. If it reaches a table-grant path
|
|
247
519
|
// unnoticed, Postgres receives a whole-table privilege. Admit only the shapes the column
|
|
248
520
|
// compiler understands, and reject an empty list because it is indistinguishable from no
|
|
@@ -274,7 +546,9 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
274
546
|
);
|
|
275
547
|
}
|
|
276
548
|
for (const column of a.condition.columns) {
|
|
277
|
-
|
|
549
|
+
// deletedAt is visible to every reader of a lifecycle model, so a column read may list it.
|
|
550
|
+
// deletedBy, deletedBatch and deletedXid are hidden by design and stay refused.
|
|
551
|
+
if (!(column in def.fields) && !(lifecycle && a.action === 'read' && column === 'deletedAt')) {
|
|
278
552
|
throw new Error(
|
|
279
553
|
`model '${table}': columns names '${column}', but no such field exists. Column abilities use model field keys.`,
|
|
280
554
|
);
|
|
@@ -282,6 +556,15 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
282
556
|
}
|
|
283
557
|
}
|
|
284
558
|
|
|
559
|
+
// A generated lifecycle column is not an author field, so no author ability may scope by it.
|
|
560
|
+
// (A column ability's owner is already refused above against the author's fields.)
|
|
561
|
+
if (lifecycle && a.condition.owner && LIFECYCLE_RESERVED.includes(a.condition.owner)) {
|
|
562
|
+
throw new Error(
|
|
563
|
+
`model '${table}': can('${a.action}', { owner: '${a.condition.owner}' }) names '${a.condition.owner}', but no such field exists ` +
|
|
564
|
+
`among the model's fields — with lifecycle: true the compiler generates it, and an ability cannot scope by it.`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
285
568
|
// `owner`/`via` compare a row column to the caller's JWT claim; a no-session audience has
|
|
286
569
|
// none. Truthiness matches the compiler, which treats `owner: ''` as no row scope.
|
|
287
570
|
const rowScope = a.condition.owner ? 'owner' : a.condition.via ? 'via' : null;
|
|
@@ -365,6 +648,7 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
365
648
|
// literally, so a model keying the column `deleted_at` — the shape a snake_case brownfield
|
|
366
649
|
// schema pulls as — carried a soft-delete column past a guard that never looked at it.
|
|
367
650
|
const softDeleteField = 'deletedAt' in def.fields || 'deleted_at' in def.fields;
|
|
651
|
+
// A lifecycle model has declared what a delete does; its generated deletedAt is not an author field.
|
|
368
652
|
if (needsSoftDeleteDeclaration({ hasDeletedAtField: softDeleteField, hasPublicRead, grantsDelete }) && def.softDelete === undefined) {
|
|
369
653
|
// DURABILITY FIRST. The old message called this "a visibility decision", which is false
|
|
370
654
|
// for exactly the models that broke: with no public read, `softDelete: true` adds no
|
|
@@ -454,15 +738,19 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
454
738
|
|
|
455
739
|
return {
|
|
456
740
|
table,
|
|
457
|
-
fields
|
|
458
|
-
|
|
741
|
+
fields,
|
|
742
|
+
// Appended after every author check above: the internal set may name the generated columns.
|
|
743
|
+
abilities: lifecycle ? [...(def.abilities ?? []), ...lifecycleAbilities()] : def.abilities ?? [],
|
|
459
744
|
hooks: def.hooks,
|
|
460
745
|
relations: def.relations ?? {},
|
|
461
746
|
primaryKey,
|
|
462
747
|
constraints: def.constraints ?? [],
|
|
463
748
|
writtenBy: def.writtenBy ?? 'app',
|
|
464
749
|
privileges: def.privileges ?? {},
|
|
750
|
+
// Stays false on a lifecycle model: its reads are filtered by the RLS guard (L1.2a), its deletes arrive with the triggers.
|
|
465
751
|
softDelete: def.softDelete ?? false,
|
|
752
|
+
...(lifecycle ? { lifecycle: true as const } : {}),
|
|
753
|
+
...(deleteWhen !== undefined ? { deleteWhen } : {}),
|
|
466
754
|
schema: def.schema ?? 'public',
|
|
467
755
|
private: def.private ?? false,
|
|
468
756
|
rls: def.rls ?? true,
|
package/src/module.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* See docs/plans/v3-module-complete-generation.md.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import type
|
|
22
|
+
import { LIFECYCLE_OWNER_ROLE, type ModelDescriptor } from './model';
|
|
23
23
|
import type { DerivedDescriptor, SequenceDescriptor } from './derived';
|
|
24
24
|
import { rejectHoles, functionArgSignature } from './derived';
|
|
25
25
|
import { deriveFunctionSchemas } from './derive';
|
|
@@ -223,7 +223,12 @@ export function moduleDerived(modules: Module[]): DerivedDescriptor[] {
|
|
|
223
223
|
return modules.flatMap((m) => m.derived);
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
/**
|
|
226
|
+
/**
|
|
227
|
+
* The union of every module's governed roles — deduped and sorted. Widen-only by construction.
|
|
228
|
+
* A module holding a lifecycle model also governs {@link LIFECYCLE_OWNER_ROLE}, which the
|
|
229
|
+
* compiler grants on that model.
|
|
230
|
+
*/
|
|
227
231
|
export function moduleGovernedRoles(modules: Module[]): string[] {
|
|
228
|
-
|
|
232
|
+
const lifecycle = modules.some((m) => m.models.some((model) => model.lifecycle));
|
|
233
|
+
return [...new Set([...modules.flatMap((m) => m.governedRoles), ...(lifecycle ? [LIFECYCLE_OWNER_ROLE] : [])])].sort();
|
|
229
234
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The link-time half of the relation display options: the checks and the orderBy normalization
|
|
3
|
+
* that need the resolved TARGET model. A leaf: it imports only types, so linkModels can use it
|
|
4
|
+
* without an import cycle through relations.ts (which imports referenceThunk from link.ts).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ModelDescriptor } from './model';
|
|
8
|
+
import type { Relation, RelationOrder } from './relations';
|
|
9
|
+
|
|
10
|
+
/** True when linkModels must evaluate this relation's target to normalize its display options. */
|
|
11
|
+
export function hasDisplayOptions(rel: Relation): boolean {
|
|
12
|
+
return rel.kind === 'hasOne' || rel.orderBy !== undefined || rel.where !== undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const nameOf = (m: ModelDescriptor): string => (!m.schema || m.schema === 'public' ? m.table : `${m.schema}.${m.table}`);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The normalized orderBy of a hasMany or hasOne against its resolved TARGET, or undefined when
|
|
19
|
+
* there is none (a hasMany with no orderBy keeps today's order: none is added). Pure: linkModels
|
|
20
|
+
* writes the result, once every reference in the set has resolved.
|
|
21
|
+
*
|
|
22
|
+
* Every orderBy and where field must be a field of the target. The target's primary key is
|
|
23
|
+
* appended, ascending, for each key column the orderBy does not list, so the order is total. A
|
|
24
|
+
* hasOne with no orderBy is ordered by that key. Idempotent: a normalized orderBy lists the key.
|
|
25
|
+
*/
|
|
26
|
+
export function normalizeRelationDisplay(model: string, name: string, rel: Relation, target: ModelDescriptor): RelationOrder[] | undefined {
|
|
27
|
+
const at = `model '${model}': relation '${name}'`;
|
|
28
|
+
for (const key of Object.keys(rel.where ?? {})) {
|
|
29
|
+
if (!Object.prototype.hasOwnProperty.call(target.fields, key)) {
|
|
30
|
+
throw new Error(`${at}: where field '${key}' is not a field of model '${nameOf(target)}'`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (rel.orderBy === undefined && rel.kind !== 'hasOne') return undefined;
|
|
34
|
+
const declared = rel.orderBy ?? [];
|
|
35
|
+
for (const term of declared) {
|
|
36
|
+
if (!Object.prototype.hasOwnProperty.call(target.fields, term.field)) {
|
|
37
|
+
throw new Error(`${at}: orderBy field '${term.field}' is not a field of model '${nameOf(target)}'`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (target.primaryKey.length === 0) {
|
|
41
|
+
throw new Error(`${at}: model '${nameOf(target)}' has no primary key to break orderBy ties; declare one`);
|
|
42
|
+
}
|
|
43
|
+
const listed = new Set(declared.map((t) => t.field));
|
|
44
|
+
return [
|
|
45
|
+
...declared.map((t) => ({ field: t.field, order: t.order })),
|
|
46
|
+
...target.primaryKey.filter((k) => !listed.has(k)).map((k) => ({ field: k, order: 'asc' as const })),
|
|
47
|
+
];
|
|
48
|
+
}
|