@everystack/model 0.4.23 → 0.4.25

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/dependents.ts CHANGED
@@ -14,9 +14,10 @@
14
14
  *
15
15
  * Ratified rules (F7):
16
16
  * - 'restrict' → 'restrict', 'nullify' → 'set null', 'cascade' → 'cascade'.
17
- * - Lifecycle (P1.2): a lifecycle parent's 'cascade' needs a lifecycle child and compiles to
18
- * 'restrict' (the soft-delete cascade runs in triggers). Any other cascade into a lifecycle
19
- * table is refused, and so is a lifecycle parent's 'cascade' or 'restrict' onto a plain child.
17
+ * - Lifecycle (P1.2, ratified 2026-10-29): only a lifecycle table may hold a foreign key into a
18
+ * lifecycle table, and the parent declares every such key with a dependent. Soft deletion runs in
19
+ * triggers; physical removal follows the declared foreign-key action. A cascade into a lifecycle
20
+ * table from a plain parent is refused.
20
21
  * - Neither side wins. Both may declare; agreeing is fine, disagreeing is an error naming both.
21
22
  * - Matched on (target, column): a child's other FKs to the same parent are untouched.
22
23
  * - 'nullify' on a NOT NULL child column is an error (the delete would fail).
@@ -46,11 +47,22 @@ export function lifecycleCascadeHarm(where: string): string {
46
47
  return (
47
48
  `ON DELETE CASCADE into a lifecycle table is refused. The cascaded delete is turned into a soft delete, `
48
49
  + `so the parent row is removed and ${where} still points at it. `
49
- + `Use 'restrict' or 'nullify' (onDelete 'restrict' or 'set null') instead; `
50
+ + `Use 'restrict' or 'nullify' instead (from a plain parent, onDelete 'restrict' or 'set null'; from a lifecycle parent, its hasMany dependent); `
50
51
  + `to soft-delete the children with their parent, make the parent lifecycle: true and declare hasMany { dependent: 'cascade' } on it`
51
52
  );
52
53
  }
53
54
 
55
+ /**
56
+ * A lifecycle parent's hasMany with both a `dependent` and `where`: `where` is display only and never
57
+ * narrows what a delete does, so the pair reads as a filtered cascade or restrict that does not
58
+ * exist. Refused on a lifecycle parent; a plain parent keeps accepting it (declarations predate this).
59
+ */
60
+ export function dependentWhereRefusal(parent: string, relation: string, rel: { dependent?: unknown; where?: unknown }): string | undefined {
61
+ if (rel.dependent === undefined || rel.where === undefined) return undefined;
62
+ return `model '${parent}': relation '${relation}' declares dependent '${String(rel.dependent)}' with where: where is display only and never narrows a dependent. `
63
+ + 'Use an unfiltered dependent and a separate display relation, or drop where.';
64
+ }
65
+
54
66
  /**
55
67
  * Validate every dependent in the set and return the actions it implies. Pure: nothing is written.
56
68
  * `targetOf` resolves a thunk; linkModels passes one that knows the names it has resolved but not
@@ -72,6 +84,8 @@ export function relationDeleteActions(
72
84
  // defineModel refuses these shapes; a hand-edited descriptor still never compiles.
73
85
  throw new Error(`${at}, which is not a hasMany 'restrict', 'nullify' or 'cascade'`);
74
86
  }
87
+ const filtered = parent.lifecycle ? dependentWhereRefusal(nameOf(parent), name, rel) : undefined;
88
+ if (filtered) throw new Error(filtered);
75
89
  const child = targetOf(rel.target);
76
90
  const where = `${nameOf(child)}.${rel.column}`;
77
91
  const f = child.fields[rel.column];
@@ -122,28 +136,10 @@ export function relationDeleteActions(
122
136
  if (dependent === 'cascade' && child.lifecycle && !parent.lifecycle) {
123
137
  throw new Error(`${at}, but ${where} is in a lifecycle table: ${lifecycleCascadeHarm(where)}`);
124
138
  }
125
- if (dependent === 'cascade' && parent.lifecycle && !child.lifecycle) {
126
- throw new Error(
127
- `${at}, but ${where} is not in a lifecycle table. Deleting a lifecycle row is a soft delete, so the parent `
128
- + `row stays and its foreign key never fires: ${where} would stay live under a deleted parent. `
129
- + `Make '${nameOf(child)}' lifecycle: true (its rows are then soft-deleted with the parent), `
130
- + `or use dependent 'restrict' or 'nullify'`,
131
- );
132
- }
139
+ if (parent.lifecycle && !child.lifecycle) throw new Error(plainKeyRefusal(`${at}, but ${where}`, nameOf(child), nameOf(parent), '.references()'));
133
140
 
134
- if (dependent === 'restrict' && parent.lifecycle && !child.lifecycle) {
135
- throw new Error(
136
- `${at}, but ${where} is not in a lifecycle table. Deleting a lifecycle row is a soft delete, so the foreign key `
137
- + `never fires; the restrict check runs in a trigger as the lifecycle role, which holds no rights on a plain table. `
138
- + `Make '${nameOf(child)}' lifecycle: true, or use dependent 'nullify'`,
139
- );
140
- }
141
-
142
- // A lifecycle parent soft-deletes its lifecycle 'cascade' children through triggers
143
- // (lifecycle-derived.ts), never through the foreign key: its DELETE never removes the row,
144
- // and an RI cascade into a lifecycle table would leave the child pointing at a removed
145
- // parent once one is purged. So that foreign key is RESTRICT; the relation keeps 'cascade'.
146
- const onDelete: ReferentialAction = dependent === 'cascade' && parent.lifecycle ? 'restrict' : ACTION[dependent];
141
+ // Soft deletion uses triggers; destroy follows the declared foreign-key action.
142
+ const onDelete: ReferentialAction = ACTION[dependent];
147
143
  const forChild = planned.get(child) ?? new Map();
148
144
  const earlier = forChild.get(rel.column);
149
145
  if (earlier && earlier.onDelete !== onDelete) {
@@ -162,7 +158,158 @@ export function relationDeleteActions(
162
158
  planned.set(child, forChild);
163
159
  }
164
160
  }
161
+ assertLifecycleKeysDeclared(models, targetOf);
162
+ lifecycleDestroyOrder(models, targetOf);
165
163
  const out = new Map<ModelDescriptor, ReadonlyMap<string, ReferentialAction>>();
166
164
  for (const [child, fields] of planned) out.set(child, new Map([...fields].map(([k, v]) => [k, v.onDelete])));
167
165
  return out;
168
166
  }
167
+
168
+ /**
169
+ * Only a lifecycle table may hold a foreign key into a lifecycle table (ratified 2026-10-29). Soft
170
+ * delete never removes the parent row, so a plain child's key never fires, and the triggers that
171
+ * soft-delete, restrict or restore run as the lifecycle role, which holds no rights on a plain
172
+ * table. A plain table refers to a lifecycle row by value.
173
+ */
174
+ function plainKeyRefusal(site: string, child: string, parent: string, key: string): string {
175
+ return `${site} references lifecycle table '${parent}', but '${child}' is not a lifecycle table: only a lifecycle table may hold `
176
+ + `a foreign key into a lifecycle table. Make '${child}' lifecycle: true (and declare the dependent on '${parent}'), `
177
+ + `or refer to it by value (drop ${key})`;
178
+ }
179
+
180
+ const UNDECLARED = 'Soft delete never consults an undeclared key, so destroy would later defer or skip the parent row.';
181
+
182
+ /**
183
+ * Every foreign key into a lifecycle table comes from a lifecycle table, and the parent's hasMany
184
+ * declares it with a dependent (cascade, restrict or nullify): the one way to declare it. Soft
185
+ * delete decides every consequence; a key nobody declared is a consequence nobody decided.
186
+ */
187
+ function assertLifecycleKeysDeclared(models: readonly ModelDescriptor[], targetOf: (thunk: () => unknown) => ModelDescriptor): void {
188
+ // A set with no lifecycle model evaluates no reference thunk (linkModels never needs to).
189
+ if (!models.some((m) => m.lifecycle === true)) return;
190
+ for (const child of new Set(models)) {
191
+ for (const [key, f] of Object.entries(child.fields)) {
192
+ if (!f.spec.references) continue;
193
+ const parent = targetOf(f.spec.references);
194
+ if (parent.lifecycle !== true) continue;
195
+ const site = `model '${nameOf(child)}': field '${key}'`;
196
+ if (child.lifecycle !== true) throw new Error(plainKeyRefusal(site, nameOf(child), nameOf(parent), '.references()'));
197
+ const declared = (Object.values(parent.relations) as Relation[]).some((rel) => rel.kind === 'hasMany'
198
+ && rel.dependent !== undefined && rel.column === key && targetOf(rel.target) === child);
199
+ if (declared) continue;
200
+ throw new Error(
201
+ `${site} references lifecycle table '${nameOf(parent)}', and '${nameOf(parent)}' declares no dependent `
202
+ + `for ${nameOf(child)}.${key}. ${UNDECLARED} Declare what deleting a '${nameOf(parent)}' row does to it in '${nameOf(parent)}'s relations: `
203
+ + `hasMany('${nameOf(child)}', '${key}', { dependent: 'cascade' | 'restrict' | 'nullify' })`,
204
+ );
205
+ }
206
+ for (const c of child.constraints) {
207
+ if (c.kind !== 'foreignKey' || !c.references) continue;
208
+ const parent = targetOf(c.references as () => unknown);
209
+ if (parent.lifecycle !== true) continue;
210
+ const site = `model '${nameOf(child)}': foreignKey(${c.columns.join(', ')})`;
211
+ if (child.lifecycle !== true) throw new Error(plainKeyRefusal(site, nameOf(child), nameOf(parent), 'the foreignKey'));
212
+ throw new Error(
213
+ `${site} references lifecycle table '${nameOf(parent)}', and a table-level foreignKey cannot carry a dependent. `
214
+ + `${UNDECLARED} Reference '${nameOf(parent)}'s primary key with a field .references() and declare a hasMany dependent on '${nameOf(parent)}'`,
215
+ );
216
+ }
217
+ }
218
+ }
219
+
220
+ /**
221
+ * The child FK columns a lifecycle parent's dependents need indexed, per child model. A property
222
+ * of the MODEL SET like {@link relationDeleteActions}: pure, nothing written, every reader that
223
+ * compiles indexes from the set consults it.
224
+ *
225
+ * Why: the cascade trigger on a middle table runs one UPDATE on the child per stamped row, the
226
+ * restrict check counts the child by the FK, and destroy's ON DELETE SET NULL ('nullify') runs
227
+ * PostgreSQL's referential action by the FK, once per removed parent. Without an index each is a
228
+ * full scan of the child (measured: a 100k-child delete 15.5 s without, 1.4 s with; a 1,000-parent
229
+ * destroy over 600k SET NULL children 21.4 s without, 0.6 s with).
230
+ *
231
+ * Rules: only a LIFECYCLE parent's hasMany with a dependent; field keys, one column (a dependent
232
+ * rides a field `.references()`, never a composite foreignKey); skipped when the child already has
233
+ * a btree index, unique or primary key whose LEADING column is that one (a partial, hash or
234
+ * expression index does not serve every row); one entry per column however many edges share it;
235
+ * column lists sorted for a stable result.
236
+ */
237
+ export type DependentIndexes = ReadonlyMap<ModelDescriptor, ReadonlyArray<readonly string[]>>;
238
+
239
+ const leads = (columns: readonly string[], fk: readonly string[]): boolean =>
240
+ fk.length > 0 && fk.every((c, i) => columns[i] === c);
241
+
242
+ function servedBy(child: ModelDescriptor, fk: readonly string[]): boolean {
243
+ if (leads(child.primaryKey, fk)) return true;
244
+ if (fk.length === 1 && child.fields[fk[0]]?.spec.isUnique) return true;
245
+ return child.constraints.some((c) => {
246
+ if (c.kind === 'unique') return leads(c.columns, fk);
247
+ if (c.kind !== 'index') return false;
248
+ if (c.predicate !== undefined || (c.method !== undefined && c.method !== 'btree')) return false;
249
+ if (fk.some((_, i) => c.rawPositions?.[i])) return false;
250
+ return leads(c.columns, fk);
251
+ });
252
+ }
253
+
254
+ export function lifecycleDependentIndexes(
255
+ models: readonly ModelDescriptor[],
256
+ targetOf: (thunk: () => unknown) => ModelDescriptor = (thunk) => thunk() as ModelDescriptor,
257
+ ): DependentIndexes {
258
+ const out = new Map<ModelDescriptor, string[][]>();
259
+ for (const parent of new Set(models)) {
260
+ if (parent.lifecycle !== true) continue;
261
+ for (const rel of Object.values(parent.relations) as Relation[]) {
262
+ if (rel.kind !== 'hasMany' || rel.dependent === undefined) continue;
263
+ const child = targetOf(rel.target);
264
+ const fk = [rel.column];
265
+ if (servedBy(child, fk)) continue;
266
+ const list = out.get(child) ?? [];
267
+ if (!list.some((l) => l.join('\u0000') === fk.join('\u0000'))) list.push(fk);
268
+ out.set(child, list);
269
+ }
270
+ }
271
+ for (const list of out.values()) list.sort((a, b) => a.join('\u0000').localeCompare(b.join('\u0000')));
272
+ return out;
273
+ }
274
+
275
+ /**
276
+ * Lifecycle table names in children-first destroy order over restrict/cascade edges. Ties keep
277
+ * model-set order; public names are bare and other schemas are qualified. A self-reference is
278
+ * allowed: a restrict tree converges one level per run. Multi-table cycles cannot be ordered.
279
+ * `targetOf` lets linkModels check the graph before binding any name references.
280
+ */
281
+ export function lifecycleDestroyOrder(
282
+ models: readonly ModelDescriptor[],
283
+ targetOf: (thunk: () => unknown) => ModelDescriptor = (thunk) => thunk() as ModelDescriptor,
284
+ ): string[] {
285
+ const tables = [...new Set(models)].filter(m => m.lifecycle === true);
286
+ const children = new Map(tables.map(m => [m, new Set<ModelDescriptor>()]));
287
+ for (const parent of tables) {
288
+ for (const rel of Object.values(parent.relations) as Relation[]) {
289
+ if (rel.kind !== 'hasMany' || (rel.dependent !== 'restrict' && rel.dependent !== 'cascade')) continue;
290
+ const child = targetOf(rel.target);
291
+ if (child !== parent && children.has(child)) children.get(parent)!.add(child);
292
+ }
293
+ }
294
+ const visited = new Set<ModelDescriptor>();
295
+ const path: ModelDescriptor[] = [];
296
+ const visit = (table: ModelDescriptor): void => {
297
+ const at = path.indexOf(table);
298
+ if (at !== -1) throw new Error(`unsupported destroy order: ${[...path.slice(at), table].map(nameOf).join(' -> ')}`);
299
+ if (visited.has(table)) return;
300
+ path.push(table);
301
+ for (const child of children.get(table)!) visit(child);
302
+ path.pop();
303
+ visited.add(table);
304
+ };
305
+ for (const table of tables) visit(table);
306
+ const remaining = new Set(tables);
307
+ const ordered: string[] = [];
308
+ while (remaining.size) {
309
+ // Pick the earliest ready table again after each removal, so ties keep their input order.
310
+ const next = tables.find(m => remaining.has(m) && [...children.get(m)!].every(child => !remaining.has(child)))!;
311
+ ordered.push(nameOf(next));
312
+ remaining.delete(next);
313
+ }
314
+ return ordered;
315
+ }
package/src/derive.ts CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  import type { ModelDescriptor } from './model';
16
16
  import type { ModelHooks } from './model';
17
+ import { assertDeleteHookNames, assertDestroyHooks } from './lifecycle-hooks';
17
18
  import type { AbilityCondition } from './can';
18
19
  import { linkModels } from './link';
19
20
 
@@ -68,6 +69,8 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
68
69
  const hooks: Record<string, ModelHooks> = {};
69
70
 
70
71
  for (const model of models) {
72
+ assertDeleteHookNames(model.table, model.hooks);
73
+ assertDestroyHooks(model.table, model.hooks, model.lifecycle === true);
71
74
  // A private model is generated (schema + authz) but is not a generic-API
72
75
  // resource — it contributes nothing to the handler config (no exposedTables
73
76
  // entry, no hidden/protected/ownership/relation/hook derivation).
package/src/derived.ts CHANGED
@@ -391,6 +391,8 @@ export interface FunctionDefinition {
391
391
  * exactly as it always has. Strictness by explicitness, never by provenance.
392
392
  */
393
393
  searchPath?: string[] | 'unpinned';
394
+ /** Function-scoped PostgreSQL lock_timeout; restored on return. The caller sets statement_timeout. */
395
+ lockTimeout?: string;
394
396
  /** Default `'volatile'` (PostgreSQL's own). `immutable` enables expression indexes. */
395
397
  volatility?: 'immutable' | 'stable' | 'volatile';
396
398
  dependsOn?: DependsOnRef[];
@@ -440,6 +442,8 @@ export interface FunctionDescriptor {
440
442
  owner?: string;
441
443
  /** See {@link FunctionDefinition.searchPath}. `'unpinned'` = no `SET search_path`, declared. */
442
444
  searchPath?: string[] | 'unpinned';
445
+ /** Function-scoped PostgreSQL lock_timeout; restored on return. The caller sets statement_timeout. */
446
+ lockTimeout?: string;
443
447
  volatility: 'immutable' | 'stable' | 'volatile';
444
448
  dependsOn: DependsOnRef[];
445
449
  abilities: Ability[];
@@ -538,6 +542,7 @@ export function defineFunction(name: string, def: FunctionDefinition): FunctionD
538
542
  security,
539
543
  ...(def.owner ? { owner: def.owner } : {}),
540
544
  ...(def.searchPath ? { searchPath: def.searchPath } : {}),
545
+ ...(def.lockTimeout !== undefined ? { lockTimeout: def.lockTimeout } : {}),
541
546
  volatility: def.volatility ?? 'volatile',
542
547
  dependsOn: def.dependsOn ?? [],
543
548
  abilities: def.abilities ?? [],
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Shared provisioning SQL for everystack.destroy_log.
3
+ *
4
+ * The table holds one marker row per destroy call in progress, keyed by the transaction id, with the
5
+ * call's cutoff (p_before). The generated <t>_lifecycle_destroy writes it before it deletes and
6
+ * removes it before it returns, so the table is empty at rest. While the marker exists,
7
+ * <t>_lifecycle_destroy_before() returns the cutoff, and a lifecycle DELETE in that transaction
8
+ * really removes a row only if it is already soft-deleted before the cutoff; any other row raises.
9
+ * Without it every DELETE is soft, the lifecycle role's included: the marker is the only way to a
10
+ * real delete.
11
+ *
12
+ * No role but the table owner holds a privilege on the table, the lifecycle role included. Consumer
13
+ * triggers run as the lifecycle role inside a destroy call; a role that could write the table could
14
+ * delete the call's marker and insert another (the CHECK allows the current transaction), or widen
15
+ * its cutoff. The lifecycle role reaches the marker only through three SECURITY DEFINER functions the
16
+ * table owner owns: destroy_log_mark and destroy_log_unmark refuse to run inside a trigger
17
+ * (pg_trigger_depth() > 0), so only a top-level destroy call can write it; destroy_cutoff only reads.
18
+ * So <t>_lifecycle_destroy cannot run inside a trigger. Only the lifecycle role may EXECUTE them.
19
+ */
20
+ /** The marker functions' refusal inside a trigger, as a SQL literal: a destroy call is a top-level statement. */
21
+ const IN_TRIGGER = "'destroy cannot run inside a trigger: call <table>_lifecycle_destroy as a top-level statement, as destroyExpired does'";
22
+
23
+ /**
24
+ * The marker functions' bodies, keyed by signature: exactly the text between the $fn$ quotes, so
25
+ * equal to pg_proc.prosrc of the function provisioning installs. The apply preflight compares them.
26
+ */
27
+ type MarkerSignature = 'everystack.destroy_log_mark(timestamptz)' | 'everystack.destroy_log_unmark()' | 'everystack.destroy_cutoff()';
28
+ /** The marker functions' language (pg_language.lanname), keyed by signature. */
29
+ export const DESTROY_LOG_MARKER_LANGUAGES: Readonly<Record<MarkerSignature, 'plpgsql' | 'sql'>> = {
30
+ 'everystack.destroy_log_mark(timestamptz)': 'plpgsql',
31
+ 'everystack.destroy_log_unmark()': 'plpgsql',
32
+ 'everystack.destroy_cutoff()': 'sql',
33
+ };
34
+ /** The marker functions' pinned search_path, as written in SET and as pg_proc.proconfig holds it after `search_path=`. */
35
+ export const DESTROY_LOG_MARKER_SEARCH_PATH = 'pg_catalog, pg_temp';
36
+ export const DESTROY_LOG_MARKER_BODIES: Readonly<Record<MarkerSignature, string>> = {
37
+ 'everystack.destroy_log_mark(timestamptz)': `
38
+ BEGIN
39
+ IF pg_catalog.pg_trigger_depth() > 0 THEN
40
+ RAISE EXCEPTION USING ERRCODE = '55000', MESSAGE = ${IN_TRIGGER};
41
+ END IF;
42
+ INSERT INTO everystack.destroy_log (xid, before) VALUES (pg_catalog.pg_current_xact_id(), p_before);
43
+ END
44
+ `,
45
+ 'everystack.destroy_log_unmark()': `
46
+ BEGIN
47
+ IF pg_catalog.pg_trigger_depth() > 0 THEN
48
+ RAISE EXCEPTION USING ERRCODE = '55000', MESSAGE = ${IN_TRIGGER};
49
+ END IF;
50
+ DELETE FROM everystack.destroy_log WHERE xid = pg_catalog.pg_current_xact_id();
51
+ RETURN FOUND;
52
+ END
53
+ `,
54
+ 'everystack.destroy_cutoff()': `
55
+ SELECT before FROM everystack.destroy_log WHERE xid = pg_catalog.pg_current_xact_id()
56
+ `,
57
+ };
58
+
59
+ export const ENSURE_DESTROY_LOG_SQL: string[] = [
60
+ 'CREATE SCHEMA IF NOT EXISTS everystack',
61
+ // Two older shapes are replaced. An unreleased build kept an append-only audit here (an `event`
62
+ // column, started/finished rows); an earlier build keyed the marker on xid alone (no `before`).
63
+ // The table is empty at rest, so a committed row in either is stale: dropping it loses nothing.
64
+ `DO $old$
65
+ BEGIN
66
+ IF EXISTS (SELECT 1 FROM pg_catalog.pg_attribute
67
+ WHERE attrelid = pg_catalog.to_regclass('everystack.destroy_log') AND attname = 'event' AND NOT attisdropped) THEN
68
+ DROP TABLE everystack.destroy_log;
69
+ DROP FUNCTION IF EXISTS everystack.destroy_log_refuse_mutation();
70
+ ELSIF pg_catalog.to_regclass('everystack.destroy_log') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_attribute
71
+ WHERE attrelid = pg_catalog.to_regclass('everystack.destroy_log') AND attname = 'before' AND NOT attisdropped) THEN
72
+ DROP TABLE everystack.destroy_log;
73
+ END IF;
74
+ END
75
+ $old$`,
76
+ // A marker can only name the transaction that writes it, so nothing running as the lifecycle role
77
+ // (a consumer trigger inside a destroy) can leave one that turns a later transaction's deletes hard.
78
+ // `before` is the call's cutoff: only a row soft-deleted before it may be removed for real.
79
+ `CREATE TABLE IF NOT EXISTS everystack.destroy_log (
80
+ xid xid8 PRIMARY KEY,
81
+ before timestamptz NOT NULL,
82
+ CONSTRAINT destroy_log_current_xact CHECK (xid = pg_catalog.pg_current_xact_id())
83
+ )`,
84
+ `DO $check$
85
+ BEGIN
86
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_constraint
87
+ WHERE conrelid = 'everystack.destroy_log'::regclass AND conname = 'destroy_log_current_xact') THEN
88
+ ALTER TABLE everystack.destroy_log
89
+ ADD CONSTRAINT destroy_log_current_xact CHECK (xid = pg_catalog.pg_current_xact_id()) NOT VALID;
90
+ END IF;
91
+ END
92
+ $check$`,
93
+ // Empty at rest: a committed row is stale or forged. A call in flight holds an uncommitted row this
94
+ // DELETE cannot see, so it is left alone. Then the check holds for every row and can be validated.
95
+ 'DELETE FROM everystack.destroy_log',
96
+ 'ALTER TABLE everystack.destroy_log VALIDATE CONSTRAINT destroy_log_current_xact',
97
+ 'REVOKE ALL PRIVILEGES ON TABLE everystack.destroy_log FROM PUBLIC',
98
+ `DO $acl$
99
+ DECLARE r record;
100
+ BEGIN
101
+ FOR r IN
102
+ SELECT DISTINCT roles.rolname
103
+ FROM (
104
+ SELECT acl.grantee
105
+ FROM pg_catalog.pg_class c CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) acl
106
+ WHERE c.oid = 'everystack.destroy_log'::regclass
107
+ UNION
108
+ SELECT acl.grantee
109
+ FROM pg_catalog.pg_attribute a CROSS JOIN LATERAL pg_catalog.aclexplode(a.attacl) acl
110
+ WHERE a.attrelid = 'everystack.destroy_log'::regclass AND a.attnum > 0 AND NOT a.attisdropped
111
+ ) grants JOIN pg_catalog.pg_roles roles ON roles.oid = grants.grantee
112
+ WHERE grants.grantee <> 0 AND roles.oid <> (
113
+ SELECT c.relowner FROM pg_catalog.pg_class c WHERE c.oid = 'everystack.destroy_log'::regclass
114
+ )
115
+ LOOP
116
+ EXECUTE pg_catalog.format('REVOKE ALL PRIVILEGES ON TABLE everystack.destroy_log FROM %I', r.rolname);
117
+ EXECUTE pg_catalog.format('REVOKE ALL PRIVILEGES (xid, before) ON TABLE everystack.destroy_log FROM %I', r.rolname);
118
+ END LOOP;
119
+ END
120
+ $acl$`,
121
+ 'REVOKE ALL PRIVILEGES (xid, before) ON TABLE everystack.destroy_log FROM PUBLIC',
122
+ 'GRANT USAGE ON SCHEMA everystack TO everystack_lifecycle',
123
+ // The only writers. pg_trigger_depth() counts every trigger level, the ON DELETE CASCADE key's
124
+ // included, so a consumer trigger anywhere inside a destroy call is refused.
125
+ `CREATE OR REPLACE FUNCTION everystack.destroy_log_mark(p_before timestamptz) RETURNS void
126
+ LANGUAGE ${DESTROY_LOG_MARKER_LANGUAGES['everystack.destroy_log_mark(timestamptz)']} SECURITY DEFINER SET search_path = ${DESTROY_LOG_MARKER_SEARCH_PATH} AS $fn$${DESTROY_LOG_MARKER_BODIES['everystack.destroy_log_mark(timestamptz)']}$fn$`,
127
+ `CREATE OR REPLACE FUNCTION everystack.destroy_log_unmark() RETURNS boolean
128
+ LANGUAGE ${DESTROY_LOG_MARKER_LANGUAGES['everystack.destroy_log_unmark()']} SECURITY DEFINER SET search_path = ${DESTROY_LOG_MARKER_SEARCH_PATH} AS $fn$${DESTROY_LOG_MARKER_BODIES['everystack.destroy_log_unmark()']}$fn$`,
129
+ // The cutoff of this transaction's destroy call, NULL without one. Read at any trigger depth.
130
+ `CREATE OR REPLACE FUNCTION everystack.destroy_cutoff() RETURNS timestamptz
131
+ LANGUAGE ${DESTROY_LOG_MARKER_LANGUAGES['everystack.destroy_cutoff()']} STABLE SECURITY DEFINER SET search_path = ${DESTROY_LOG_MARKER_SEARCH_PATH} AS $fn$${DESTROY_LOG_MARKER_BODIES['everystack.destroy_cutoff()']}$fn$`,
132
+ // EXECUTE for the lifecycle role alone, without grant option: every other grant is revoked.
133
+ `DO $exec$
134
+ DECLARE f regprocedure; r record;
135
+ BEGIN
136
+ FOREACH f IN ARRAY ARRAY['everystack.destroy_log_mark(timestamptz)', 'everystack.destroy_log_unmark()', 'everystack.destroy_cutoff()']::regprocedure[] LOOP
137
+ EXECUTE pg_catalog.format('REVOKE ALL PRIVILEGES ON FUNCTION %s FROM PUBLIC', f);
138
+ FOR r IN
139
+ SELECT DISTINCT roles.rolname
140
+ FROM pg_catalog.pg_proc p CROSS JOIN LATERAL pg_catalog.aclexplode(p.proacl) acl
141
+ JOIN pg_catalog.pg_roles roles ON roles.oid = acl.grantee
142
+ WHERE p.oid = f AND acl.grantee <> p.proowner
143
+ LOOP
144
+ EXECUTE pg_catalog.format('REVOKE ALL PRIVILEGES ON FUNCTION %s FROM %I', f, r.rolname);
145
+ END LOOP;
146
+ END LOOP;
147
+ END
148
+ $exec$`,
149
+ 'GRANT EXECUTE ON FUNCTION everystack.destroy_log_mark(timestamptz), everystack.destroy_log_unmark(), everystack.destroy_cutoff() TO everystack_lifecycle',
150
+ ];
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  export { field, FieldBuilder } from './field';
11
11
  export type { FieldSpec, FieldType, DefaultKind, ReferentialAction } from './field';
12
12
 
13
- export { canonicalColumnType, fieldFactoryCall, pgTypeRefusal } from './pg-type';
13
+ export { canonicalColumnType, fieldFactoryCall, pgTypeRefusal, pgType } from './pg-type';
14
14
 
15
15
  export { parseQualified } from './qualified';
16
16
  export { linkModels, ModelLinkError } from './link';
@@ -29,11 +29,12 @@ export type { Ability, AbilityCondition, Action } from './can';
29
29
 
30
30
  export { belongsTo, hasMany, hasOne } from './relations';
31
31
  export type { Relation, BelongsToOptions, HasManyOptions, HasOneOptions, HasManyDependent, RelationOrder, RelationWhere } from './relations';
32
- export { relationDeleteActions } from './dependents';
33
- export type { DependentActions } from './dependents';
32
+ export { relationDeleteActions, lifecycleDependentIndexes, lifecycleDestroyOrder } from './dependents';
33
+ export type { DependentActions, DependentIndexes } from './dependents';
34
34
 
35
+ export { veto, isVeto } from './lifecycle-hooks';
35
36
  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';
37
+ export type { ModelDescriptor, ModelDefinition, ModelHooks, LifecycleOptions, DestroyContext, DestroyHook, LifecycleContext, HookVeto, BeforeLifecycleHook, AfterLifecycleHook, WrittenBy, ExtraPrivilege, TablePrivilege, DmlPrivilege, MaterializedTableMarker, MaterializedTableDefinition } from './model';
37
38
 
38
39
  export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql, moduleSequences, moduleDerived, moduleGovernedRoles } from './module';
39
40
  export type { Module, ModuleDefinition } from './module';
@@ -53,3 +54,5 @@ export type {
53
54
 
54
55
  export { deriveHandlerConfig, deriveForceRlsCarveouts, deriveFunctionSchemas } from './derive';
55
56
  export type { DerivedHandlerConfig, RelationConfig } from './derive';
57
+
58
+ export { ENSURE_DESTROY_LOG_SQL, DESTROY_LOG_MARKER_BODIES, DESTROY_LOG_MARKER_LANGUAGES, DESTROY_LOG_MARKER_SEARCH_PATH } from './destroy-log';
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The lifecycle hook contract, shared by the model, the API handler and destroy workers.
3
+ * `@everystack/api` carries a structurally identical copy (it has no runtime dependencies);
4
+ * a veto made by either package is recognized by both.
5
+ */
6
+
7
+ const VETO_KIND = 'everystack.hookVeto';
8
+
9
+ /** What a before hook returns to refuse its operation. Make it with {@link veto}. */
10
+ export interface HookVeto {
11
+ readonly kind: typeof VETO_KIND;
12
+ readonly reason: string;
13
+ }
14
+
15
+ /** Refuse the operation a before hook guards. A throw is a failure; a veto is a decision. */
16
+ export function veto(reason: string): HookVeto {
17
+ if (typeof reason !== 'string' || reason.trim() === '') throw new TypeError('veto(reason) needs a non-empty reason string');
18
+ return Object.freeze({ kind: VETO_KIND, reason });
19
+ }
20
+
21
+ /** True only for a value {@link veto} made (or its twin in `@everystack/api`). */
22
+ export function isVeto(value: unknown): value is HookVeto {
23
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
24
+ && (value as { kind?: unknown }).kind === VETO_KIND && typeof (value as { reason?: unknown }).reason === 'string';
25
+ }
26
+
27
+ /** The one context every lifecycle hook receives. */
28
+ export interface LifecycleContext {
29
+ /** `delete`: a request-level delete. `restore`: a batch came back. `destroy`: physical removal. */
30
+ phase: 'delete' | 'restore' | 'destroy';
31
+ /** Request-level deletes use null. Restore and destroy can carry the row's existing batch. */
32
+ batch: string | null;
33
+ user: Record<string, unknown> | null;
34
+ client: Record<string, unknown> | null;
35
+ /** Restore only: what the restore function reported. */
36
+ counts?: { restored: number; skipped: number };
37
+ }
38
+
39
+ /** Context for a real removal. Hooks must be idempotent: retries can repeat external effects. */
40
+ export interface DestroyContext extends LifecycleContext {
41
+ phase: 'destroy';
42
+ }
43
+
44
+ /** beforeDestroy may return {@link veto}; afterDestroy's return value is ignored. */
45
+ export type DestroyHook = (row: Record<string, unknown>, ctx: DestroyContext) => void | HookVeto | Promise<void | HookVeto>;
46
+
47
+ /** Before commit, once per matched root row; returns nothing or a veto. Cascaded children run no hook: guard them with deleteWhen. */
48
+ export type BeforeLifecycleHook = (row: Record<string, unknown>, ctx: LifecycleContext) => void | HookVeto | Promise<void | HookVeto>;
49
+
50
+ /** After commit, once per request with the root rows. Best-effort; cascaded children run no hook. */
51
+ export type AfterLifecycleHook = (rows: Record<string, unknown>[], ctx: LifecycleContext) => void | Promise<void>;
52
+
53
+ /** Refuse removed hook names before a declaration can silently lose its guard. */
54
+ export function assertDeleteHookNames(table: string, hooks: object | undefined): void {
55
+ for (const [oldName, replacement] of [['beforeStamp', 'beforeDelete'], ['afterStamp', 'afterDelete']]) {
56
+ if ((hooks as Record<string, unknown> | undefined)?.[oldName] !== undefined) {
57
+ throw new Error(`[everystack/model] hooks.${table}.${oldName} was renamed to ${replacement}; update the hook name`);
58
+ }
59
+ }
60
+ }
61
+
62
+ /** Refuse destroy hooks that would never run on a table without lifecycle. */
63
+ export function assertDestroyHooks(table: string, hooks: object | undefined, lifecycle: boolean): void {
64
+ if (lifecycle) return;
65
+ for (const name of ['beforeDestroy', 'afterDestroy']) {
66
+ if ((hooks as Record<string, unknown> | undefined)?.[name] !== undefined) {
67
+ throw new Error(`[everystack/model] hooks.${table}.${name} runs only when a lifecycle table is destroyed, and ${table} is not a lifecycle table. Use beforeDelete or afterDelete, which run at the DELETE`);
68
+ }
69
+ }
70
+ }