@everystack/model 0.4.22 → 0.4.23

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/derived.ts CHANGED
@@ -88,7 +88,7 @@ function validateRelationFields(kind: string, name: string, fields: Record<strin
88
88
  const s = builder.spec;
89
89
  if (s.isPrimaryKey) throw new Error(`${at}: .primaryKey() is table structure — a relation column carries type, nullability, and array only.`);
90
90
  if (s.isUnique) throw new Error(`${at}: .unique() is a table constraint — enforce it on the base table (or a matview index).`);
91
- if (s.references) throw new Error(`${at}: .references() is a table FK — declare it on the base table; the body carries the join.`);
91
+ if (s.references || s.referencesExternal) throw new Error(`${at}: .references() is a table FK — declare it on the base table; the body carries the join.`);
92
92
  if (s.defaultKind) throw new Error(`${at}: defaults are meaningless on a relation column — the body computes every value.`);
93
93
  if (s.isDeprecated) throw new Error(`${at}: .deprecated() is the table contract phase — remove the column from the body instead.`);
94
94
  if (s.isPrivate) throw new Error(`${at}: .private() hides a table column from the API — a relation exposes only what its body selects; drop the column from the body.`);
package/src/drizzle.ts CHANGED
@@ -170,6 +170,10 @@ export function toDrizzleTable(model: ModelDescriptor): PgTableWithColumns<any>
170
170
 
171
171
  if (spec.references) {
172
172
  const target = spec.references as () => ModelDescriptor;
173
+ // The CHILD's declared actions only. A parent's hasMany `dependent` is a property of the
174
+ // model set (relationDeleteActions) and this table is built per model, so it is not here.
175
+ // The runtime table never emits DDL (the CLI does, from the set), so queries are unaffected.
176
+ // This is the one place it differs from the generated schema.generated.ts, which carries it.
173
177
  const refOpts: { onDelete?: FieldSpec['onDelete']; onUpdate?: FieldSpec['onUpdate'] } = {};
174
178
  if (spec.onDelete) refOpts.onDelete = spec.onDelete;
175
179
  if (spec.onUpdate) refOpts.onUpdate = spec.onUpdate;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * An EXTERNAL foreign-key target: a table no model declares, named by its PostgreSQL identity.
3
+ *
4
+ * `field.uuid().references({ schema: 'auth', table: 'users', column: 'id' })` is how a model says
5
+ * "this column points into another system's table" (an auth provider's `auth.users`, a table a
6
+ * different service owns). Ratified rules:
7
+ *
8
+ * - schema, table and column are all REQUIRED and are three separate identifiers. Nothing is
9
+ * parsed for dots: an external FK crosses the governed boundary, so it says exactly where.
10
+ * - A string target stays a MODEL name. Only this object form names a table.
11
+ * - An object naming a table some model in the set declares is refused: use the model.
12
+ * - It is FK state only. It is never a relation (no belongsTo/hasMany, no embedding).
13
+ * - It compiles, diffs and fingerprints exactly like a model FK. Nothing downstream of the
14
+ * compiler can tell the two apart, so a live FK nothing declares is still visible drift.
15
+ *
16
+ * The names are PostgreSQL names, taken verbatim (no snake_casing: there is no model field to
17
+ * derive them from). They reach DDL quoted, so a name is refused when quoting cannot carry it.
18
+ */
19
+
20
+ import type { ModelDescriptor } from './model';
21
+
22
+ /** A table no model declares: the composite `foreignKey(cols, { schema, table }, refColumns)` target. */
23
+ export interface ExternalTableRef {
24
+ schema: string;
25
+ table: string;
26
+ }
27
+
28
+ /** A column of a table no model declares: the `.references({ schema, table, column })` target. */
29
+ export interface ExternalColumnRef extends ExternalTableRef {
30
+ column: string;
31
+ }
32
+
33
+ /** NAMEDATALEN - 1 on a stock PostgreSQL build; a longer name is truncated with only a NOTICE. */
34
+ const MAX_IDENTIFIER_BYTES = 63;
35
+
36
+ /**
37
+ * Why a name cannot be an external identifier, or null when it can. The DDL quotes every part
38
+ * (`"auth"."users"`) without doubling quotes, and the FK IR carries `schema.table` as one dotted
39
+ * string, so a quote, a dot or a control character cannot be carried safely.
40
+ */
41
+ export function externalIdentifierRefusal(name: unknown): string | null {
42
+ if (typeof name !== 'string') return `is ${name === null ? 'null' : typeof name}, not a string`;
43
+ if (name.length === 0) return 'is empty';
44
+ if (name.includes('"')) return 'holds a double quote';
45
+ // A line terminator or other control character: a name holding one could end a comment in
46
+ // generated source or forge a line in plan output and logs. No real table needs one.
47
+ if (/[\u0000-\u001f\u007f\u2028\u2029]/.test(name)) return 'holds a control character or line terminator';
48
+ if (name.includes('.')) return "holds a '.' (a name is one identifier; the schema is its own field)";
49
+ const bytes = new TextEncoder().encode(name).length;
50
+ if (bytes > MAX_IDENTIFIER_BYTES) return `is ${bytes} bytes (PostgreSQL identifiers are at most ${MAX_IDENTIFIER_BYTES})`;
51
+ return null;
52
+ }
53
+
54
+ /** `auth.users` — how every error and plan line names an external target. */
55
+ export function externalTargetName(ref: ExternalTableRef): string {
56
+ return `${String(ref.schema)}.${String(ref.table)}`;
57
+ }
58
+
59
+ function readRef<K extends string>(target: object, keys: readonly K[], call: string): Record<K, string> {
60
+ const shown = externalTargetName(target as ExternalTableRef);
61
+ for (const key of Object.keys(target)) {
62
+ if (!(keys as readonly string[]).includes(key)) {
63
+ throw new Error(`${call} target ${shown}: unknown key '${key}' (expected ${keys.join(', ')})`);
64
+ }
65
+ }
66
+ const out = {} as Record<K, string>;
67
+ for (const key of keys) {
68
+ const value = (target as Record<string, unknown>)[key];
69
+ if (value === undefined) {
70
+ throw new Error(`${call} target ${shown}: ${key} is required (an external target names its ${keys.join(', ')}; there is no default)`);
71
+ }
72
+ const refusal = externalIdentifierRefusal(value);
73
+ if (refusal) throw new Error(`${call} target ${shown}: ${key} ${JSON.stringify(value)} ${refusal}`);
74
+ out[key] = value as string;
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /** Validate and copy a `.references({ schema, table, column })` target. */
80
+ export function externalColumnRef(target: object): ExternalColumnRef {
81
+ return Object.freeze(readRef(target, ['schema', 'table', 'column'] as const, 'references({ schema, table, column })'));
82
+ }
83
+
84
+ /** Validate and copy a composite `foreignKey(cols, { schema, table }, refColumns)` target and its columns. */
85
+ export function externalTableRef(target: object, refColumns: readonly string[], call: string): ExternalTableRef {
86
+ const ref = readRef(target, ['schema', 'table'] as const, call);
87
+ for (const c of refColumns) {
88
+ const refusal = externalIdentifierRefusal(c);
89
+ if (refusal) throw new Error(`${call} target ${externalTargetName(ref)}: reference column ${JSON.stringify(c)} ${refusal}`);
90
+ }
91
+ return Object.freeze(ref);
92
+ }
93
+
94
+ /**
95
+ * Refuse an external target that a model in this set declares: the author must use the model.
96
+ * Compared on RESOLVED identities (the model's schema, default `public`, and its table) across
97
+ * the whole set. Called by linkModels and by every compile entry point that holds the set.
98
+ */
99
+ export function assertExternalTargets(models: readonly ModelDescriptor[]): void {
100
+ const declared = new Map<string, ModelDescriptor>();
101
+ for (const m of models) declared.set(`${m.schema ?? 'public'}\u0000${m.table}`, m);
102
+ const owner = (m: ModelDescriptor): string => `${m.schema ?? 'public'}.${m.table}`;
103
+ const check = (m: ModelDescriptor, ref: ExternalTableRef, what: string): void => {
104
+ const hit = declared.get(`${ref.schema}\u0000${ref.table}`);
105
+ if (!hit) return;
106
+ throw new Error(
107
+ `model '${owner(m)}': ${what} references ${externalTargetName(ref)}, which model '${owner(hit)}' declares: `
108
+ + `use the model (.references(() => Model) or a model-name string), not the external { schema, table } form`,
109
+ );
110
+ };
111
+ for (const m of models) {
112
+ for (const [key, f] of Object.entries(m.fields)) {
113
+ if (f.spec.referencesExternal) check(m, f.spec.referencesExternal, `field '${key}'`);
114
+ }
115
+ for (const c of m.constraints) {
116
+ if (c.kind === 'foreignKey' && c.external) check(m, c.external, `foreignKey(${c.columns.join(', ')})`);
117
+ }
118
+ }
119
+ }
package/src/field.ts CHANGED
@@ -15,6 +15,7 @@ import type { RawSql } from './raw-sql';
15
15
  import { pgTypeRefusal } from './pg-type';
16
16
  import { assertConstraintName, assertFkValidate } from './constraints';
17
17
  import { referenceThunk } from './link';
18
+ import { externalColumnRef, type ExternalColumnRef } from './external';
18
19
 
19
20
  export type FieldType =
20
21
  | 'text'
@@ -80,6 +81,13 @@ export interface FieldSpec {
80
81
  isDeprecated: boolean;
81
82
  /** `.references(() => Other)` — FK target, a model thunk. Used by relation/ownership work. */
82
83
  references?: () => unknown;
84
+ /**
85
+ * `.references({ schema, table, column })` — an FK to a table NO model declares (another
86
+ * system's `auth.users`). Set instead of {@link references}, never with it. FK state only: it is
87
+ * never a relation, so nothing that follows a model thunk ever sees it. It compiles, diffs and
88
+ * fingerprints exactly like a model FK. See external.ts.
89
+ */
90
+ referencesExternal?: ExternalColumnRef;
83
91
  /** `ON DELETE` action for the FK. Omitted (or `'no action'`) is the Postgres default. */
84
92
  onDelete?: ReferentialAction;
85
93
  /** `ON UPDATE` action for the FK. Omitted (or `'no action'`) is the Postgres default. */
@@ -231,8 +239,12 @@ export class FieldBuilder {
231
239
  }
232
240
 
233
241
  /**
234
- * Declare a foreign key. The target is a table name or model thunk. Names are resolved
235
- * by linkModels when the complete set is available. Optional
242
+ * Declare a foreign key. The target is a model thunk or a model NAME (a string is always a
243
+ * model name, resolved by linkModels when the complete set is available; one that names no
244
+ * model is an error, never a table). A table no model declares is named as an object:
245
+ * `.references({ schema: 'auth', table: 'users', column: 'id' })`, all three required, each a
246
+ * PostgreSQL name taken verbatim. That form is FK state only, never a relation, and is refused
247
+ * when a model in the set declares the table (use the model). Optional
236
248
  * `onDelete`/`onUpdate` referential actions are column-scoped, so they live here, not on
237
249
  * the table — `field.uuid().references(() => User, { onDelete: 'cascade' })`.
238
250
  *
@@ -242,8 +254,19 @@ export class FieldBuilder {
242
254
  * {@link FieldSpec.referencesValidate} for why a fifteen-year-old schema needs to be able
243
255
  * to say it.
244
256
  */
245
- references(target: (() => unknown) | string, opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction; name?: string; validate?: boolean }): this {
246
- this.spec.references = referenceThunk(target);
257
+ references(target: (() => unknown) | string | ExternalColumnRef, opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction; name?: string; validate?: boolean }): this {
258
+ // A second call REPLACES the target, as every other repeated modifier on this builder
259
+ // overwrites: each branch clears the other slot, so a field never holds both (the compiler
260
+ // would read one and relations or `via` the other).
261
+ if (typeof target === 'function' || typeof target === 'string') {
262
+ this.spec.references = referenceThunk(target);
263
+ delete this.spec.referencesExternal;
264
+ } else if (target !== null && typeof target === 'object') {
265
+ this.spec.referencesExternal = externalColumnRef(target);
266
+ delete this.spec.references;
267
+ } else {
268
+ throw new Error(`references(…): the target is ${target === null ? 'null' : typeof target}; pass a model thunk, a model name, or { schema, table, column }`);
269
+ }
247
270
  if (opts?.onDelete) this.spec.onDelete = opts.onDelete;
248
271
  if (opts?.onUpdate) this.spec.onUpdate = opts.onUpdate;
249
272
  if (opts?.name !== undefined) this.spec.constraintName = assertConstraintName(opts.name, 'foreign key');
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * handler config. See docs/plans/v3-model-architecture.md.
4
4
  *
5
5
  * Ships the declaration surface (`defineModel`, `field`, `can`, `belongsTo`/
6
- * `hasMany`); `@everystack/cli` compiles models to migrations, RLS + grants, and
6
+ * `hasMany`/`hasOne`); `@everystack/cli` compiles models to migrations, RLS + grants, and
7
7
  * handler config (`deriveHandlerConfig`).
8
8
  */
9
9
 
@@ -14,6 +14,8 @@ export { canonicalColumnType, fieldFactoryCall, pgTypeRefusal } from './pg-type'
14
14
 
15
15
  export { parseQualified } from './qualified';
16
16
  export { linkModels, ModelLinkError } from './link';
17
+ export { assertExternalTargets, externalTargetName, externalIdentifierRefusal } from './external';
18
+ export type { ExternalTableRef, ExternalColumnRef } from './external';
17
19
 
18
20
  export { unique, check, index, foreignKey, IndexBuilder, positionalCheckName } from './constraints';
19
21
  export type { TableConstraint } from './constraints';
@@ -25,11 +27,13 @@ export { can, isColumnAbility, isPublicReadAbility, isDeleteAbility, needsSoftDe
25
27
  export type { SoftDeleteFacts } from './can';
26
28
  export type { Ability, AbilityCondition, Action } from './can';
27
29
 
28
- export { belongsTo, hasMany } from './relations';
29
- export type { Relation } from './relations';
30
+ export { belongsTo, hasMany, hasOne } from './relations';
31
+ export type { Relation, BelongsToOptions, HasManyOptions, HasOneOptions, HasManyDependent, RelationOrder, RelationWhere } from './relations';
32
+ export { relationDeleteActions } from './dependents';
33
+ export type { DependentActions } from './dependents';
30
34
 
31
- export { defineModel, defineMaterializedTable, EXTRA_PRIVILEGES, DML_PRIVILEGES, TABLE_PRIVILEGES } from './model';
32
- export type { ModelDescriptor, ModelDefinition, ModelHooks, WrittenBy, ExtraPrivilege, TablePrivilege, DmlPrivilege, MaterializedTableMarker, MaterializedTableDefinition } from './model';
35
+ export { defineModel, defineMaterializedTable, LIFECYCLE_COLUMNS, LIFECYCLE_OWNER_ROLE, EXTRA_PRIVILEGES, DML_PRIVILEGES, TABLE_PRIVILEGES } from './model';
36
+ export type { ModelDescriptor, ModelDefinition, ModelHooks, DestroyContext, DestroyHook, WrittenBy, ExtraPrivilege, TablePrivilege, DmlPrivilege, MaterializedTableMarker, MaterializedTableDefinition } from './model';
33
37
 
34
38
  export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql, moduleSequences, moduleDerived, moduleGovernedRoles } from './module';
35
39
  export type { Module, ModuleDefinition } from './module';
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
@@ -11,12 +11,13 @@
11
11
  * compiler (`compileTableContract`, what `db:generate` emits as RLS + grants).
12
12
  */
13
13
 
14
- import type { FieldBuilder } from './field';
14
+ import { field, type FieldBuilder } from './field';
15
15
  import { referenceLocation } from './link';
16
16
  import type { Ability } from './can';
17
- import { isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete, sessionlessAudience } from './can';
18
- import type { Relation } from './relations';
17
+ import { can, isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete, sessionlessAudience } from './can';
18
+ import { assertRelationShape, type Relation } from './relations';
19
19
  import { assertCheckNames, type TableConstraint } from './constraints';
20
+ import { lifecycleCascadeHarm } from './dependents';
20
21
  import type { RawSql } from './raw-sql';
21
22
  import { validateTriggersFor, type TriggerSpec, type DependsOnRef } from './derived';
22
23
  import { TABLE_PRIVILEGES, type TablePrivilege } from './privileges';
@@ -26,6 +27,15 @@ import { TABLE_PRIVILEGES, type TablePrivilege } from './privileges';
26
27
  * absorb (purge an S3 object on delete, stamp a column on create) — held
27
28
  * verbatim and passed through to the handler's `hooks`.
28
29
  */
30
+ /** Context for a real removal. Hooks must be idempotent: retries can repeat external effects. */
31
+ export interface DestroyContext {
32
+ source: 'purge' | 'api';
33
+ user: Record<string, unknown> | null;
34
+ client: Record<string, unknown> | null;
35
+ }
36
+
37
+ export type DestroyHook = (row: Record<string, unknown>, ctx: DestroyContext) => void | Promise<void>;
38
+
29
39
  export interface ModelHooks {
30
40
  beforeCreate?: (...args: any[]) => any;
31
41
  afterCreate?: (...args: any[]) => any;
@@ -33,6 +43,13 @@ export interface ModelHooks {
33
43
  afterUpdate?: (...args: any[]) => any;
34
44
  beforeDelete?: (...args: any[]) => any;
35
45
  afterDelete?: (...args: any[]) => any;
46
+ /** Runs after the DELETE statement, before commit. A throw rolls back only the database
47
+ * delete, never an external effect. No transaction is passed. Must be idempotent.
48
+ * Treat the row as read-only: it is the same object later serialized into the response. */
49
+ beforeDestroy?: DestroyHook;
50
+ /** Runs after commit, best-effort. Cannot undo the removal. Must be idempotent.
51
+ * Treat the row as read-only: it is the same object later serialized into the response. */
52
+ afterDestroy?: DestroyHook;
36
53
  }
37
54
 
38
55
  /**
@@ -115,6 +132,14 @@ export interface ModelDefinition {
115
132
  * was always explicit; this matches it. Convention may propose it, never enforce it.
116
133
  */
117
134
  softDelete?: boolean;
135
+ /**
136
+ * `lifecycle: true` — a soft delete with a delay. defineModel generates four nullable columns
137
+ * ({@link LIFECYCLE_COLUMNS}): `deletedAt` timestamptz, `deletedBy` text (no foreign key),
138
+ * `deletedBatch` uuid, `deletedXid` bigint. The author never declares them, never combines
139
+ * lifecycle with `softDelete`, and declares a single-column primary key; each is refused here.
140
+ * `true` is the only value today.
141
+ */
142
+ lifecycle?: true;
118
143
  /**
119
144
  * The Postgres schema the table lives in. Default `'public'`. Package-owned
120
145
  * tables declare their own (`'dist'`, `'ops'`, `'auth'`) so one db:generate run
@@ -195,6 +220,8 @@ export interface ModelDescriptor {
195
220
  privileges: Record<string, TablePrivilege[]>;
196
221
  /** `true` excludes `deleted_at IS NULL` rows from public reads and the data API. Declared, never inferred. */
197
222
  softDelete: boolean;
223
+ /** Present only when declared: `fields` carries the generated {@link LIFECYCLE_COLUMNS}. */
224
+ lifecycle?: true;
198
225
  /** The resolved Postgres schema (default `'public'`). Package tables live in their own. */
199
226
  schema: string;
200
227
  /** `true` removes the table from the generic data API (not in `exposedTables`). Default `false`. */
@@ -227,11 +254,194 @@ export interface MaterializedTableMarker {
227
254
  comment?: string;
228
255
  }
229
256
 
257
+ /** The field keys `lifecycle: true` generates, in the order defineModel appends them. */
258
+ export const LIFECYCLE_COLUMNS = ['deletedAt', 'deletedBy', 'deletedBatch', 'deletedXid'] as const;
259
+
260
+ /** Both spellings of each generated key: an author declaring either collides with the generated column. */
261
+ const LIFECYCLE_RESERVED = [...LIFECYCLE_COLUMNS, 'deleted_at', 'deleted_by', 'deleted_batch', 'deleted_xid'];
262
+
263
+ /**
264
+ * The role a lifecycle model grants to its delete machinery. defineModel appends its abilities
265
+ * to every `lifecycle: true` model, so no author ability may name it, on any model.
266
+ */
267
+ export const LIFECYCLE_OWNER_ROLE = 'everystack_lifecycle';
268
+
269
+ /**
270
+ * The lifecycle role's abilities: read every row (deleted ones included), update only the four
271
+ * generated columns, delete. No insert, no manage.
272
+ */
273
+ function lifecycleAbilities(): Ability[] {
274
+ const role = LIFECYCLE_OWNER_ROLE;
275
+ return [
276
+ can('read', { role }),
277
+ can('update', { role, columns: [...LIFECYCLE_COLUMNS] }),
278
+ can('delete', { role }),
279
+ ];
280
+ }
281
+
282
+ function assertRoleNotReserved(table: string, def: ModelDefinition): void {
283
+ // A privileges entry is refused too, even an empty list: TABLE_PRIVILEGES includes TRUNCATE
284
+ // and TRIGGER, which would let the role bypass RLS or attach triggers beside the guards.
285
+ if (def.privileges && Object.prototype.hasOwnProperty.call(def.privileges, LIFECYCLE_OWNER_ROLE)) {
286
+ throw new Error(
287
+ `model '${table}': privileges['${LIFECYCLE_OWNER_ROLE}'] — the role '${LIFECYCLE_OWNER_ROLE}' is reserved for lifecycle; ` +
288
+ `the compiler grants it automatically on a lifecycle: true model. Remove the entry.`,
289
+ );
290
+ }
291
+ for (const a of def.abilities ?? []) {
292
+ if (a.condition.role !== LIFECYCLE_OWNER_ROLE) continue;
293
+ throw new Error(
294
+ `model '${table}': can('${a.action}', { role: '${LIFECYCLE_OWNER_ROLE}' }) — the role '${LIFECYCLE_OWNER_ROLE}' ` +
295
+ `is reserved for lifecycle; the compiler grants it automatically on a lifecycle: true model. Remove the ability.`,
296
+ );
297
+ }
298
+ }
299
+
300
+ /** Fresh builders on every call: a descriptor owns its fields, never shares them with another model. */
301
+ function lifecycleFields(): Record<(typeof LIFECYCLE_COLUMNS)[number], FieldBuilder> {
302
+ return {
303
+ deletedAt: field.timestamptz().nullable(),
304
+ deletedBy: field.text().nullable(),
305
+ deletedBatch: field.uuid().nullable(),
306
+ deletedXid: field.bigint().nullable(),
307
+ };
308
+ }
309
+
310
+ function assertLifecycle(table: string, def: ModelDefinition): void {
311
+ const value: unknown = def.lifecycle;
312
+ if (value === undefined) return;
313
+ if (value === false) {
314
+ throw new Error(`model '${table}': lifecycle: false is not supported yet. Omit the key for a model without lifecycle.`);
315
+ }
316
+ if (value !== true) {
317
+ throw new Error(`model '${table}': lifecycle: ${JSON.stringify(value)} — the only value is lifecycle: true.`);
318
+ }
319
+ if (def.softDelete !== undefined) {
320
+ throw new Error(
321
+ `model '${table}': lifecycle replaces softDelete — delete softDelete: ${String(def.softDelete)}. ` +
322
+ `lifecycle: true already declares what a delete does.`,
323
+ );
324
+ }
325
+ for (const name of LIFECYCLE_RESERVED) {
326
+ if (name in def.fields) {
327
+ throw new Error(
328
+ `model '${table}': field '${name}' — with lifecycle: true the compiler generates this column; delete the declaration.`,
329
+ );
330
+ }
331
+ }
332
+ // The lifecycle role's policies and the deleted_at read guard are RLS policies; under disabled
333
+ // row security they filter nothing while reading as though they do.
334
+ if (def.rls === false) {
335
+ throw new Error(
336
+ `model '${table}': lifecycle: true with rls: false — lifecycle hides deleted rows with RLS policies, ` +
337
+ `and rls: false declares there are none, so the guard would be dead. Either drop rls: false (lifecycle needs RLS) ` +
338
+ `or drop lifecycle: true.`,
339
+ );
340
+ }
341
+ // A cascade into this table, declared on this table's own foreign key. A parent's
342
+ // hasMany { dependent: 'cascade' } is checked in relationDeleteActions (allowed from a
343
+ // lifecycle parent, where it compiles to RESTRICT and the triggers soft-delete the children).
344
+ const tableName = def.schema && def.schema !== 'public' ? `${def.schema}.${table}` : table;
345
+ for (const [name, f] of Object.entries(def.fields)) {
346
+ if (f.spec.onDelete !== 'cascade') continue;
347
+ throw new Error(`model '${tableName}': field '${name}' declares onDelete 'cascade'. ${lifecycleCascadeHarm(`${tableName}.${name}`)}.`);
348
+ }
349
+ for (const c of def.constraints ?? []) {
350
+ if (c.kind !== 'foreignKey' || c.onDelete !== 'cascade') continue;
351
+ const where = `${tableName}.${c.columns.join(', ')}`;
352
+ throw new Error(`model '${tableName}': foreignKey(${c.columns.join(', ')}) declares onDelete 'cascade'. ${lifecycleCascadeHarm(where)}.`);
353
+ }
354
+ const pk = Object.entries(def.fields).filter(([, f]) => f.spec.isPrimaryKey).length;
355
+ if (pk !== 1) {
356
+ throw new Error(
357
+ `model '${table}': lifecycle needs a single-column primary key — this model declares ${pk === 0 ? 'none' : `${pk} (composite)`}. ` +
358
+ `Mark exactly one field .primaryKey().`,
359
+ );
360
+ }
361
+ // The generated trigger and function names are `<table>_lifecycle_transition` and `_guard`.
362
+ // PostgreSQL silently truncates an identifier past 63 bytes, so the declared name would not be the live one.
363
+ // UTF-8 bytes, as PostgreSQL counts them. TextEncoder rather than Buffer: a model also loads outside Node.
364
+ const longest = `${table}_lifecycle_transition`;
365
+ const bytes = new TextEncoder().encode(longest).length;
366
+ if (bytes > 63) {
367
+ throw new Error(
368
+ `model '${table}': lifecycle generates '${longest}' (${bytes} bytes), past PostgreSQL's 63-byte identifier limit. ` +
369
+ `A lifecycle table name may be at most 42 bytes; shorten the table name.`,
370
+ );
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Restore runs the table's delete rule inside a SECURITY DEFINER function as the lifecycle role.
376
+ * Refuse a delete rule whose meaning would change there: a subquery (`via`, or SELECT in
377
+ * where/sql) runs with that role's privileges, and current_user/session_user name that role or
378
+ * the login, not the caller. TRUNCATE is refused because it skips the delete triggers.
379
+ */
380
+ function assertLifecycleRestoreSafe(table: string, def: ModelDefinition): void {
381
+ if (def.lifecycle !== true) return;
382
+ const why = 'restore runs the delete rule inside a SECURITY DEFINER function as the lifecycle owner';
383
+ const abilities = def.abilities ?? [];
384
+ // A role-less (or authenticated) delete on a model scoped only through `via` compiles to the
385
+ // via subquery too, though the ability itself names no via.
386
+ const viaScopedModel = !abilities.some((a) => a.condition.owner) && abilities.some((a) => a.condition.via);
387
+ for (const a of abilities) {
388
+ if (a.action !== 'delete' && a.action !== 'manage') continue;
389
+ // PUBLIC would get EXECUTE on restore, and a branch with no role behind it: anon restores.
390
+ if (a.condition.role && a.condition.role.toLowerCase() === 'public') {
391
+ throw new Error(
392
+ `model '${table}': can('${a.action}', { role: '${a.condition.role}' }) on a lifecycle: true model — ${why}, and ` +
393
+ `restore is granted to whoever may delete, so every role, anon included, could restore rows. ` +
394
+ `Use a named role (authenticated, or one of your own) for the delete rule.`,
395
+ );
396
+ }
397
+ const authClass = !a.condition.role || a.condition.role === 'authenticated';
398
+ if (a.condition.via || (viaScopedModel && authClass)) {
399
+ const spelled = a.condition.via ? `can('${a.action}', { via: '${a.condition.via}' })` : `can('${a.action}') on a model scoped by via`;
400
+ throw new Error(
401
+ `model '${table}': ${spelled} on a lifecycle: true model — ${why}, where a via ` +
402
+ `subquery would read the parent with that role's privileges. Use owner, or a role with a plain where, for the delete rule.`,
403
+ );
404
+ }
405
+ for (const key of ['where', 'sql'] as const) {
406
+ const text = (a.condition[key] as { sql?: unknown } | undefined)?.sql;
407
+ if (typeof text !== 'string') continue;
408
+ const offender = /\bselect\b/i.test(text)
409
+ ? 'a subquery (SELECT)'
410
+ : /\bcurrent_user\b/i.test(text)
411
+ ? 'current_user'
412
+ : /\bsession_user\b/i.test(text)
413
+ ? 'session_user'
414
+ : null;
415
+ if (!offender) continue;
416
+ throw new Error(
417
+ `model '${table}': can('${a.action}', { ${key} }) uses ${offender} on a lifecycle: true model — ${why}, ` +
418
+ `where a subquery runs with that role's privileges and current_user/session_user do not name the caller. ` +
419
+ `Replace it with a predicate on the row's own columns or the request claims.`,
420
+ );
421
+ }
422
+ }
423
+ for (const [role, privs] of Object.entries(def.privileges ?? {})) {
424
+ if (!Array.isArray(privs) || !privs.includes('TRUNCATE')) continue;
425
+ throw new Error(
426
+ `model '${table}': privileges['${role}'] grants TRUNCATE on a lifecycle: true model — TRUNCATE skips the delete ` +
427
+ `triggers, so it would erase rows past the soft delete and restore. Remove TRUNCATE from the entry.`,
428
+ );
429
+ }
430
+ }
431
+
230
432
  export function defineModel(table: string, def: ModelDefinition): ModelDescriptor {
433
+ // Refuse first, then inject: the author-declared collisions are checked against what the author wrote.
434
+ assertRoleNotReserved(table, def);
435
+ assertLifecycle(table, def);
436
+ assertLifecycleRestoreSafe(table, def);
437
+ const lifecycle = def.lifecycle === true;
438
+ const fields: Record<string, FieldBuilder> = lifecycle ? { ...def.fields, ...lifecycleFields() } : def.fields;
439
+
231
440
  for (const [name, field] of Object.entries(def.fields)) {
232
441
  referenceLocation(field.spec.references, `${table}.${name}`);
233
442
  }
234
443
  for (const [name, relation] of Object.entries(def.relations ?? {})) {
444
+ assertRelationShape(def.schema && def.schema !== 'public' ? `${def.schema}.${table}` : table, name, relation);
235
445
  referenceLocation(relation.target, `${table}.${name}`);
236
446
  }
237
447
  const primaryKey = Object.entries(def.fields)
@@ -282,6 +492,15 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
282
492
  }
283
493
  }
284
494
 
495
+ // A generated lifecycle column is not an author field, so no author ability may scope by it.
496
+ // (A column ability's owner is already refused above against the author's fields.)
497
+ if (lifecycle && a.condition.owner && LIFECYCLE_RESERVED.includes(a.condition.owner)) {
498
+ throw new Error(
499
+ `model '${table}': can('${a.action}', { owner: '${a.condition.owner}' }) names '${a.condition.owner}', but no such field exists ` +
500
+ `among the model's fields — with lifecycle: true the compiler generates it, and an ability cannot scope by it.`,
501
+ );
502
+ }
503
+
285
504
  // `owner`/`via` compare a row column to the caller's JWT claim; a no-session audience has
286
505
  // none. Truthiness matches the compiler, which treats `owner: ''` as no row scope.
287
506
  const rowScope = a.condition.owner ? 'owner' : a.condition.via ? 'via' : null;
@@ -365,6 +584,7 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
365
584
  // literally, so a model keying the column `deleted_at` — the shape a snake_case brownfield
366
585
  // schema pulls as — carried a soft-delete column past a guard that never looked at it.
367
586
  const softDeleteField = 'deletedAt' in def.fields || 'deleted_at' in def.fields;
587
+ // A lifecycle model has declared what a delete does; its generated deletedAt is not an author field.
368
588
  if (needsSoftDeleteDeclaration({ hasDeletedAtField: softDeleteField, hasPublicRead, grantsDelete }) && def.softDelete === undefined) {
369
589
  // DURABILITY FIRST. The old message called this "a visibility decision", which is false
370
590
  // for exactly the models that broke: with no public read, `softDelete: true` adds no
@@ -454,15 +674,18 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
454
674
 
455
675
  return {
456
676
  table,
457
- fields: def.fields,
458
- abilities: def.abilities ?? [],
677
+ fields,
678
+ // Appended after every author check above: the internal set may name the generated columns.
679
+ abilities: lifecycle ? [...(def.abilities ?? []), ...lifecycleAbilities()] : def.abilities ?? [],
459
680
  hooks: def.hooks,
460
681
  relations: def.relations ?? {},
461
682
  primaryKey,
462
683
  constraints: def.constraints ?? [],
463
684
  writtenBy: def.writtenBy ?? 'app',
464
685
  privileges: def.privileges ?? {},
686
+ // Stays false on a lifecycle model: its reads are filtered by the RLS guard (L1.2a), its deletes arrive with the triggers.
465
687
  softDelete: def.softDelete ?? false,
688
+ ...(lifecycle ? { lifecycle: true as const } : {}),
466
689
  schema: def.schema ?? 'public',
467
690
  private: def.private ?? false,
468
691
  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
  }