@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/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 { FieldBuilder } from './field';
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 { Relation } from './relations';
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
- if (!(column in def.fields)) {
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: def.fields,
458
- abilities: def.abilities ?? [],
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 { ModelDescriptor } from './model';
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
- /** The union of every module's governed roles — deduped and sorted. Widen-only by construction. */
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
- return [...new Set(modules.flatMap((m) => m.governedRoles))].sort();
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
+ }