@everystack/model 0.4.23 → 0.4.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,35 @@ All notable changes to `@everystack/model` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ### Documentation
10
+
11
+ - verify a built database with compileDeclaredState, not a per-model compileTableSchema loop
12
+
13
+ ## [0.4.24] - 2026-10-23
14
+
15
+ ### BREAKING CHANGES
16
+
17
+ - lifecycle: { deleteWhen } refuses deleting a row its predicate does not allow
18
+ - refuse dependent + where on a lifecycle parent's hasMany
19
+ - can('read', { role, deleted: 'include' | 'only' }) on a lifecycle model
20
+ - one lifecycle hook API: (rows, ctx), veto(reason), beforeStamp
21
+ - refuse can('read', { deleted }) for anon, authenticated and public
22
+
23
+ ### Added
24
+
25
+ - index the FK column of every lifecycle dependent
26
+
27
+ ### Documentation
28
+
29
+ - what a lifecycle delete returns, plain children, vetoing a delete, the purge executor, the caching note
30
+ - lifecycle dependents' FK columns are indexed by the generator; what a pull without dependents plans
31
+ - measure what the lifecycle read guard costs a non-leakproof index search
32
+ - can('read', { role, deleted }) as the read hatch for a lifecycle index search
33
+ - Extending lifecycle, the phase table, the veto and failure rules
34
+ - the keyed lifecycle DELETE, before-hook pool hazard, batch as a hint, deleted-reader grants, FK index drops
35
+
7
36
  ## [0.4.23] - 2026-10-23
8
37
 
9
38
  ### Added
package/README.md CHANGED
@@ -219,6 +219,9 @@ A string third argument is `references`, the key on the other side when it is no
219
219
  | `'nullify'` | `ON DELETE SET NULL` |
220
220
  | `'cascade'` | `ON DELETE CASCADE` |
221
221
 
222
+ On a lifecycle parent the generator also indexes the child's foreign-key column for `'cascade'`, `'restrict'` and `'nullify'`; an index you declare with that column leading is used instead.
223
+ Dropping a generated index is destructive (`--allow-drops` on `db:sync`, `--confirm` on `db:apply`, refused on `db:apply --stage`): it happens when you remove the dependent, make the parent non-lifecycle, or add your own index that leads with the foreign-key column after the generated one was applied.
224
+
222
225
  ```typescript
223
226
  const Post = defineModel('posts', {
224
227
  fields: { id: field.uuid().primaryKey() },
@@ -245,7 +248,7 @@ The foreign key belongs to the child table, so the action lands there: in the pl
245
248
  - Any other value (for example `'softDelete'`) is refused by name.
246
249
  - `belongsTo` takes no `dependent`. A `hasMany` with no `dependent` is display only and compiles exactly as before.
247
250
 
248
- `db:pull` renders the action on the child (`.references(() => Post, { onDelete: 'set null' })`) and no `dependent` on the parent; that agrees with a parent that declares one, so pull then plan is empty.
251
+ `db:pull` renders the action on the child (`.references(() => Post, { onDelete: 'set null' })`) and no `dependent` on the parent; that agrees with a parent that declares one, so pull then plan is empty. A lifecycle parent is the exception: without its `dependent` declarations the generated foreign-key indexes are no longer declared, so `db:plan` refuses their drops as destructive until you declare the `dependent` again.
249
252
 
250
253
  ### Lifecycle
251
254
 
@@ -290,6 +293,31 @@ Cascade and restrict from a lifecycle parent require lifecycle children. Nullify
290
293
  An actual `ON DELETE CASCADE` into a lifecycle table is refused. Plain-table cascades still remove rows without
291
294
  running app hooks. `dependent: 'destroy'` has not shipped.
292
295
  AFTER DELETE audit triggers are supported; they do not run on a soft deletion.
296
+
297
+ #### Refusing some deletes: `deleteWhen`
298
+
299
+ `lifecycle: { deleteWhen: sql\`<predicate>\` }` is lifecycle with a guard: a row may be deleted only when the predicate
300
+ is TRUE for it. FALSE refuses, and so does NULL. The predicate uses bare column names, like a policy predicate:
301
+
302
+ ```typescript
303
+ const List = defineModel('lists', {
304
+ lifecycle: { deleteWhen: sql`NOT (kind = 'system' AND private)` },
305
+ fields: { id: field.integer().primaryKey(), kind: field.text().notNull(), private: field.boolean().notNull() },
306
+ abilities: [can('manage', { role: 'admin' })],
307
+ });
308
+ ```
309
+
310
+ A refused delete raises SQLSTATE `23514` with the constraint `<table>_lifecycle_delete_when` and a message that names
311
+ the table, never a row value; the API answers `422` with `"Deleting this row is not allowed by its model's deleteWhen."`.
312
+ The guard checks every stamp, direct or cascaded, so one guarded child refuses its parent's whole delete and nothing is
313
+ stamped, as `restrict` does. A DELETE of several rows is refused as a whole when any one is guarded. `lifecycle: {}`
314
+ is `lifecycle: true`. Purge removes a row only while the predicate still holds: a row whose columns changed after its
315
+ stamp stays, with a `skipped` receipt (`protected by deleteWhen`) and no retry cooldown. The predicate runs as the
316
+ reserved lifecycle role with a pinned `search_path`, so qualify any function of your own it calls.
317
+ For logic a predicate cannot express, a `beforeStamp` hook can `veto(reason)` a root row's stamp through the API
318
+ ([Extending lifecycle](../../docs/handler.md#extending-lifecycle)); the `BEFORE UPDATE` veto trigger in docs/upgrading.md
319
+ remains the last resort, the one that also sees direct SQL and cascades.
320
+ There is no option to require an actor on a stamp: use a veto trigger, or require authentication on DELETE.
293
321
  `db:pull` refuses author BEFORE DELETE triggers on lifecycle tables because they can suppress purge DELETE.
294
322
  It also refuses TRUNCATE triggers because TRUNCATE bypasses lifecycle row removal.
295
323
 
@@ -305,13 +333,62 @@ The API process is fully trusted. SQL on its connection can rewrite the request
305
333
  `deleted_by` is attribution, not proof. Lifecycle graph operations require READ COMMITTED;
306
334
  an earlier snapshot can miss a related row committed since it began.
307
335
 
336
+ ### Lifecycle and indexes
337
+
338
+ Every SELECT policy on a lifecycle table carries the guard `(deleted_at IS NULL)`, except the lifecycle role's own and a
339
+ `manage` ALL policy. PostgreSQL evaluates a policy qual before any of the query's own conditions whose operator function
340
+ is not `leakproof`, so such a condition cannot be an index condition below it. Measured on PostgreSQL 16, 100k and 500k
341
+ rows, one read per operator (`scripts/bench/lifecycle-read-guard.py`), generic and custom plans alike:
342
+
343
+ | operator (function) | leakproof | read whose policy was `true` | with the guard |
344
+ | --- | --- | --- | --- |
345
+ | `slug = $1` (`texteq`), `id = ANY($1)` (`int4eq`) | yes | index condition | index condition, unchanged |
346
+ | `tags @> $1` (`arraycontains`) | no | GIN, 1.7 ms at 500k | sequential scan, 41.8 ms |
347
+ | `meta @> $1` (`jsonb_contains`) | no | GIN, 1.0 ms at 500k | sequential scan, 44.6 ms |
348
+ | `title ILIKE $1` (`texticlike`, trigram) | no | GIN, 1.0 ms at 500k | sequential scan, 92.8 ms |
349
+ | `body_tsv @@ $1` (`ts_match_vq`) | no | GIN, 1.0 ms at 500k | sequential scan, 36.4 ms |
350
+
351
+ At 100k the same four went from 100 to 300 buffers to about 2,700 (0.15 to 0.7 ms to 12 to 48 ms).
352
+ Only a read whose policy was `true` before lifecycle regresses (a public read, or a role read with no condition).
353
+ A read that already had its own condition (an owner or `sql` read) used a sequential scan for these operators
354
+ before lifecycle too, so the guard changes nothing there. A partial index `WHERE deleted_at IS NULL` did not help.
355
+ A role whose policy has no guard keeps the index.
356
+
357
+ For a hot search over a lifecycle table with one of those operators, give the search a role whose read has no guard
358
+ and filter deleted rows yourself:
359
+
360
+ ```typescript
361
+ abilities: [
362
+ can('read'), // guarded: live rows only
363
+ can('read', { role: 'lookup_role', deleted: 'include' }), // no guard: live and deleted rows, index usable
364
+ can('read', { role: 'trash_role', deleted: 'only' }), // (deleted_at IS NOT NULL): a trash view
365
+ ]
366
+ ```
367
+
368
+ `deleted` works only on `can('read', { role })` of a lifecycle model and changes only that role's read policy; its
369
+ `sql`, `where` and `columns` still apply. `'include'` means the role's queries must add `deleted_at IS NULL`
370
+ themselves when they want live rows (a leakproof `IS NULL` costs nothing). A role cannot have both a guarded read and
371
+ a `deleted` read: they would compile to the same policy, and `defineModel`'s compile refuses that. A column read may
372
+ list `deletedAt` (every reader sees it); `deletedBy`, `deletedBatch` and `deletedXid` stay unlistable.
373
+ `deleted` is for a named internal role: `defineModel` refuses it for `anon`, `authenticated` and `public`.
374
+ A `deleted` read with no `columns` is granted SELECT on every column except `deleted_by`, `deleted_batch` and
375
+ `deleted_xid`, not the whole table, so the role cannot read who deleted a row, even with plain SQL. A role that must see
376
+ who deleted uses `manage`; a `manage` or a `privileges` SELECT for the same role grants the whole table.
377
+ `db:plan` says `⚠ ACCESS — role <r> can read deleted rows of <table>` when it adds or changes such a policy.
378
+ Where the searching role is shared with other reads, use a `SECURITY DEFINER` lookup function owned by such a role,
379
+ whose body adds `deleted_at IS NULL` itself, schema-qualifies every table, pins `search_path`, and is granted EXECUTE
380
+ only to the roles that may search.
381
+
308
382
  ### Purge
309
383
 
310
384
  Import `purgeExpired` and `ENSURE_PURGE_LOG_SQL` from `@everystack/server/purge`.
311
385
  On the trusted owner connection, run the ensure statements before the first purge.
312
386
  Pass the ops Drizzle database, the full linked model set, and `{ after, batch }`.
313
387
  `after` is positive retention in milliseconds; `batch` bounds the rows physically removed across all tables.
314
- The executor must read those tables and the ledger, and be allowed to SET ROLE to `everystack_lifecycle`.
388
+ The executor must read those tables and the ledger, and be allowed to SET ROLE to `everystack_lifecycle`
389
+ (membership in that role; no verb grants it yet, so `GRANT everystack_lifecycle TO <ops role>` by hand).
390
+ The DELETE and the ledger INSERT run as `everystack_lifecycle` with that role's own grants. A lockdown that
391
+ revokes DELETE from other roles must leave this role's DELETE on the lifecycle tables alone.
315
392
  This is trusted server code, not a client RPC capability.
316
393
 
317
394
  Purge checks the live FK graph against the declaration, then visits lifecycle children before parents.
@@ -338,10 +415,14 @@ The example's account-erasure cron remains paused.
338
415
 
339
416
  `hooks.beforeDestroy(row, ctx)` and `hooks.afterDestroy(row, ctx)` describe physical removal.
340
417
  `beforeDestroy` runs only for rows whose DELETE succeeded at statement level, inside the transaction with the removed row's data.
341
- A throw cancels that removal. A successful beforeDestroy can repeat if later work rolls back the removal or commit fails.
418
+ A throw cancels that removal and is a failure (a `failed` receipt, retry cooldown). `return veto(reason)` cancels it as a
419
+ decision: a `skipped` receipt with detail `held: <reason>` and no cooldown, so the next run asks again.
420
+ A successful beforeDestroy can repeat if later work rolls back the removal or commit fails.
342
421
  Purge checks its reserved-role DELETE and deferred constraints before the hook, which runs under the original caller's role.
343
- Purge passes `{ source: 'purge', user: null, client: null }`; hooks that authorize on `ctx.user` must handle that null user.
344
- The API passes `{ source: 'api', user, client }` after a hard DELETE returns the authorized rows, before commit.
422
+ Purge passes `{ phase: 'destroy', source: 'purge', batch: <deleted_batch>, user: null, client: null }`; hooks that authorize on `ctx.user` must handle that null user.
423
+ The API passes `{ phase: 'destroy', source: 'api', batch: null, user, client }` after a hard DELETE returns the authorized rows, before commit.
424
+ `veto`, `isVeto`, `HookVeto` and `LifecycleContext` are exported here and from `@everystack/server/purge`. All lifecycle
425
+ hooks (`beforeStamp`, `afterStamp`, `afterRestore`, the destroy pair) are in [Extending lifecycle](../../docs/handler.md#extending-lifecycle).
345
426
  A driver without transactions refuses API hard DELETE on a table with a destroy hook: `db.transaction is not a function`.
346
427
  `afterDestroy` runs once per removed row after commit, best-effort; process death in between can prevent delivery.
347
428
  Irreversible external effects belong in `afterDestroy`. Make them idempotent; no transaction is passed to either hook.
@@ -349,7 +430,7 @@ Existing `beforeDelete` and `afterDelete` remain API request hooks, including on
349
430
 
350
431
  ### Display options: `orderBy`, `limit`, `where`, and `hasOne`
351
432
 
352
- These say which related rows a reader sees, and in what order. They are **display only**: they emit no SQL, do not move the fingerprint, and never narrow a `dependent`. A `hasMany` with `dependent: 'nullify'` and `where: { approved: true }` still sets `post_id` to NULL on every comment, approved or not.
433
+ These say which related rows a reader sees, and in what order. They are **display only**: they emit no SQL, do not move the fingerprint, and never narrow a `dependent`. A `hasMany` with `dependent: 'nullify'` and `where: { approved: true }` still sets `post_id` to NULL on every comment, approved or not. On a lifecycle parent that combination is refused, because it reads as a filtered cascade that does not exist: declare the `dependent` on an unfiltered relation and keep `where` on a separate display relation.
353
434
 
354
435
  ```typescript
355
436
  const Post = defineModel('posts', {
@@ -405,12 +486,13 @@ The same compiler the CLI uses is importable for your own CI and tests — point
405
486
  ```typescript
406
487
  import {
407
488
  compileMigration, generateMigrationSql, introspectSchema, diffSchema, // data layer
489
+ compileDeclaredState, // the declared state of the set
408
490
  compileTableContract, introspectContract, diffContracts, // authz layer
409
491
  buildOwnerProbeSql, evaluateOwnerProbe, // owner-isolation red-team
410
492
  } from '@everystack/cli/model';
411
493
  ```
412
494
 
413
- - **Data** — `introspectSchema(run)` reads the whole DB; `diffSchema(desired, current)` / `generateMigrationSql(models, current)` produce the structural delta. A green `db:generate` is this returning empty.
495
+ - **Data** — `introspectSchema(run)` reads the whole DB; `diffSchema(desired, current)` / `generateMigrationSql(models, current)` produce the structural delta. A green `db:generate` is this returning empty. For `desired`, use `compileDeclaredState(models, { schema }).snapshot`, not a `compileTableSchema` call per Model: the set derives parts no one Model carries (the hasMany `ON DELETE`, the generated lifecycle FK indexes), and a per-Model loop reads those live objects as drops.
414
496
  - **Authz** — `compileTableContract(model)` is the declared RLS/grants; `introspectContract(run)` is what's live; `diffContracts(declared, live)` is the drift audit (`db:authz:diff`).
415
497
  - **Red-team** — `buildOwnerProbeSql` / `evaluateOwnerProbe` drive two JWT identities per owner-scoped table to catch IDOR (`db:authz:owner`).
416
498
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/model",
3
- "version": "0.4.23",
3
+ "version": "0.4.24",
4
4
  "description": "Declare a table's schema, validation, and authorization once — compiled to migrations, RLS, and handler config",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
package/src/can.ts CHANGED
@@ -81,6 +81,14 @@ export interface AbilityCondition {
81
81
  where?: unknown;
82
82
  /** Escape hatch: a verbatim RLS predicate, preserved and diffed by normalized text. */
83
83
  sql?: unknown;
84
+ /**
85
+ * Lifecycle models only, on `can('read', { role })`: which rows this role's SELECT policy covers
86
+ * by deletion state. `'include'` drops the `(deleted_at IS NULL)` guard (live and deleted rows);
87
+ * `'only'` replaces it with `(deleted_at IS NOT NULL)` (deleted rows only). Omitted, the guard
88
+ * stays. Every other restriction of the ability (`sql`, `where`, `owner`, `columns`) still applies.
89
+ * The same words as the request-time `?deleted=include|only`.
90
+ */
91
+ deleted?: 'include' | 'only';
84
92
  /**
85
93
  * The WRITE predicate, when it differs from the read predicate.
86
94
  *
package/src/dependents.ts CHANGED
@@ -51,6 +51,17 @@ export function lifecycleCascadeHarm(where: string): string {
51
51
  );
52
52
  }
53
53
 
54
+ /**
55
+ * A lifecycle parent's hasMany with both a `dependent` and `where`: `where` is display only and never
56
+ * narrows what a delete does, so the pair reads as a filtered cascade or restrict that does not
57
+ * exist. Refused on a lifecycle parent; a plain parent keeps accepting it (declarations predate this).
58
+ */
59
+ export function dependentWhereRefusal(parent: string, relation: string, rel: { dependent?: unknown; where?: unknown }): string | undefined {
60
+ if (rel.dependent === undefined || rel.where === undefined) return undefined;
61
+ return `model '${parent}': relation '${relation}' declares dependent '${String(rel.dependent)}' with where: where is display only and never narrows a dependent. `
62
+ + 'Use an unfiltered dependent and a separate display relation, or drop where.';
63
+ }
64
+
54
65
  /**
55
66
  * Validate every dependent in the set and return the actions it implies. Pure: nothing is written.
56
67
  * `targetOf` resolves a thunk; linkModels passes one that knows the names it has resolved but not
@@ -72,6 +83,8 @@ export function relationDeleteActions(
72
83
  // defineModel refuses these shapes; a hand-edited descriptor still never compiles.
73
84
  throw new Error(`${at}, which is not a hasMany 'restrict', 'nullify' or 'cascade'`);
74
85
  }
86
+ const filtered = parent.lifecycle ? dependentWhereRefusal(nameOf(parent), name, rel) : undefined;
87
+ if (filtered) throw new Error(filtered);
75
88
  const child = targetOf(rel.target);
76
89
  const where = `${nameOf(child)}.${rel.column}`;
77
90
  const f = child.fields[rel.column];
@@ -166,3 +179,58 @@ export function relationDeleteActions(
166
179
  for (const [child, fields] of planned) out.set(child, new Map([...fields].map(([k, v]) => [k, v.onDelete])));
167
180
  return out;
168
181
  }
182
+
183
+ /**
184
+ * The child FK columns a lifecycle parent's dependents need indexed, per child model. A property
185
+ * of the MODEL SET like {@link relationDeleteActions}: pure, nothing written, every reader that
186
+ * compiles indexes from the set consults it.
187
+ *
188
+ * Why: the cascade trigger on a middle table runs one UPDATE on the child per stamped row, the
189
+ * restrict check counts the child by the FK, and purge's ON DELETE SET NULL ('nullify') runs
190
+ * PostgreSQL's referential action by the FK, once per removed parent. Without an index each is a
191
+ * full scan of the child (measured: a 100k-child delete 15.5 s without, 1.4 s with; a 1,000-parent
192
+ * purge over 600k SET NULL children 21.4 s without, 0.6 s with).
193
+ *
194
+ * Rules: only a LIFECYCLE parent's hasMany with a dependent; field keys, one column (a dependent
195
+ * rides a field `.references()`, never a composite foreignKey); skipped when the child already has
196
+ * a btree index, unique or primary key whose LEADING column is that one (a partial, hash or
197
+ * expression index does not serve every row); one entry per column however many edges share it;
198
+ * column lists sorted for a stable result.
199
+ */
200
+ export type DependentIndexes = ReadonlyMap<ModelDescriptor, ReadonlyArray<readonly string[]>>;
201
+
202
+ const leads = (columns: readonly string[], fk: readonly string[]): boolean =>
203
+ fk.length > 0 && fk.every((c, i) => columns[i] === c);
204
+
205
+ function servedBy(child: ModelDescriptor, fk: readonly string[]): boolean {
206
+ if (leads(child.primaryKey, fk)) return true;
207
+ if (fk.length === 1 && child.fields[fk[0]]?.spec.isUnique) return true;
208
+ return child.constraints.some((c) => {
209
+ if (c.kind === 'unique') return leads(c.columns, fk);
210
+ if (c.kind !== 'index') return false;
211
+ if (c.predicate !== undefined || (c.method !== undefined && c.method !== 'btree')) return false;
212
+ if (fk.some((_, i) => c.rawPositions?.[i])) return false;
213
+ return leads(c.columns, fk);
214
+ });
215
+ }
216
+
217
+ export function lifecycleDependentIndexes(
218
+ models: readonly ModelDescriptor[],
219
+ targetOf: (thunk: () => unknown) => ModelDescriptor = (thunk) => thunk() as ModelDescriptor,
220
+ ): DependentIndexes {
221
+ const out = new Map<ModelDescriptor, string[][]>();
222
+ for (const parent of new Set(models)) {
223
+ if (parent.lifecycle !== true) continue;
224
+ for (const rel of Object.values(parent.relations) as Relation[]) {
225
+ if (rel.kind !== 'hasMany' || rel.dependent === undefined) continue;
226
+ const child = targetOf(rel.target);
227
+ const fk = [rel.column];
228
+ if (servedBy(child, fk)) continue;
229
+ const list = out.get(child) ?? [];
230
+ if (!list.some((l) => l.join('\u0000') === fk.join('\u0000'))) list.push(fk);
231
+ out.set(child, list);
232
+ }
233
+ }
234
+ for (const list of out.values()) list.sort((a, b) => a.join('\u0000').localeCompare(b.join('\u0000')));
235
+ return out;
236
+ }
package/src/index.ts CHANGED
@@ -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 } 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';
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The lifecycle hook contract, shared by the model, the API handler and purge.
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
+ /** `stamp`: the row was marked deleted. `restore`: a batch came back. `destroy`: physical removal. */
30
+ phase: 'stamp' | 'restore' | 'destroy';
31
+ /** `api`: a request through createHandler. `purge`: purgeExpired. */
32
+ source: 'api' | 'purge';
33
+ /** The lifecycle batch, or null when there is none to report (before a stamp, a non-lifecycle table, a pre-batch row). Never invented. */
34
+ batch: string | null;
35
+ user: Record<string, unknown> | null;
36
+ client: Record<string, unknown> | null;
37
+ /** Restore only: what the restore function reported. */
38
+ counts?: { restored: number; skipped: number };
39
+ }
40
+
41
+ /** Context for a real removal. Hooks must be idempotent: retries can repeat external effects. */
42
+ export interface DestroyContext extends LifecycleContext {
43
+ phase: 'destroy';
44
+ }
45
+
46
+ /** beforeDestroy may return {@link veto}; afterDestroy's return value is ignored. */
47
+ export type DestroyHook = (row: Record<string, unknown>, ctx: DestroyContext) => void | HookVeto | Promise<void | HookVeto>;
48
+
49
+ /** A before hook of the lifecycle: called once per root row; returns nothing or a veto. */
50
+ export type BeforeLifecycleHook = (row: Record<string, unknown>, ctx: LifecycleContext) => void | HookVeto | Promise<void | HookVeto>;
51
+
52
+ /** An after hook of the lifecycle: called after commit with the root rows of one batch. Best-effort. */
53
+ export type AfterLifecycleHook = (rows: Record<string, unknown>[], ctx: LifecycleContext) => void | Promise<void>;
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.
@@ -22,20 +23,14 @@ import type { RawSql } from './raw-sql';
22
23
  import { validateTriggersFor, type TriggerSpec, type DependsOnRef } from './derived';
23
24
  import { TABLE_PRIVILEGES, type TablePrivilege } from './privileges';
24
25
 
26
+ import type { AfterLifecycleHook, BeforeLifecycleHook, DestroyHook } from './lifecycle-hooks';
27
+ export type { AfterLifecycleHook, BeforeLifecycleHook, DestroyContext, DestroyHook, HookVeto, LifecycleContext } from './lifecycle-hooks';
28
+
25
29
  /**
26
30
  * Per-table mutation hooks. Imperative function bodies the framework cannot
27
31
  * absorb (purge an S3 object on delete, stamp a column on create) — held
28
32
  * verbatim and passed through to the handler's `hooks`.
29
33
  */
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
-
39
34
  export interface ModelHooks {
40
35
  beforeCreate?: (...args: any[]) => any;
41
36
  afterCreate?: (...args: any[]) => any;
@@ -50,6 +45,15 @@ export interface ModelHooks {
50
45
  /** Runs after commit, best-effort. Cannot undo the removal. Must be idempotent.
51
46
  * Treat the row as read-only: it is the same object later serialized into the response. */
52
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;
53
57
  }
54
58
 
55
59
  /**
@@ -137,9 +141,13 @@ export interface ModelDefinition {
137
141
  * ({@link LIFECYCLE_COLUMNS}): `deletedAt` timestamptz, `deletedBy` text (no foreign key),
138
142
  * `deletedBatch` uuid, `deletedXid` bigint. The author never declares them, never combines
139
143
  * lifecycle with `softDelete`, and declares a single-column primary key; each is refused here.
140
- * `true` is the only value today.
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`.
141
149
  */
142
- lifecycle?: true;
150
+ lifecycle?: true | LifecycleOptions;
143
151
  /**
144
152
  * The Postgres schema the table lives in. Default `'public'`. Package-owned
145
153
  * tables declare their own (`'dist'`, `'ops'`, `'auth'`) so one db:generate run
@@ -220,8 +228,11 @@ export interface ModelDescriptor {
220
228
  privileges: Record<string, TablePrivilege[]>;
221
229
  /** `true` excludes `deleted_at IS NULL` rows from public reads and the data API. Declared, never inferred. */
222
230
  softDelete: boolean;
223
- /** Present only when declared: `fields` carries the generated {@link LIFECYCLE_COLUMNS}. */
231
+ /** Present only when declared: `fields` carries the generated {@link LIFECYCLE_COLUMNS}. Always
232
+ * `true` on the descriptor, whichever form the definition used. */
224
233
  lifecycle?: true;
234
+ /** `lifecycle: { deleteWhen }`'s predicate text. Absent when there is no guard. */
235
+ deleteWhen?: string;
225
236
  /** The resolved Postgres schema (default `'public'`). Package tables live in their own. */
226
237
  schema: string;
227
238
  /** `true` removes the table from the generic data API (not in `exposedTables`). Default `false`. */
@@ -307,14 +318,40 @@ function lifecycleFields(): Record<(typeof LIFECYCLE_COLUMNS)[number], FieldBuil
307
318
  };
308
319
  }
309
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
+
310
338
  function assertLifecycle(table: string, def: ModelDefinition): void {
311
339
  const value: unknown = def.lifecycle;
312
340
  if (value === undefined) return;
313
341
  if (value === false) {
314
342
  throw new Error(`model '${table}': lifecycle: false is not supported yet. Omit the key for a model without lifecycle.`);
315
343
  }
316
- if (value !== true) {
317
- throw new Error(`model '${table}': lifecycle: ${JSON.stringify(value)} — the only value is lifecycle: true.`);
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\`…\` }.`);
318
355
  }
319
356
  if (def.softDelete !== undefined) {
320
357
  throw new Error(
@@ -378,7 +415,7 @@ function assertLifecycle(table: string, def: ModelDefinition): void {
378
415
  * the login, not the caller. TRUNCATE is refused because it skips the delete triggers.
379
416
  */
380
417
  function assertLifecycleRestoreSafe(table: string, def: ModelDefinition): void {
381
- if (def.lifecycle !== true) return;
418
+ if (!isLifecycle(def)) return;
382
419
  const why = 'restore runs the delete rule inside a SECURITY DEFINER function as the lifecycle owner';
383
420
  const abilities = def.abilities ?? [];
384
421
  // A role-less (or authenticated) delete on a model scoped only through `via` compiles to the
@@ -434,7 +471,8 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
434
471
  assertRoleNotReserved(table, def);
435
472
  assertLifecycle(table, def);
436
473
  assertLifecycleRestoreSafe(table, def);
437
- const lifecycle = def.lifecycle === true;
474
+ const lifecycle = isLifecycle(def);
475
+ const deleteWhen = deleteWhenOf(def);
438
476
  const fields: Record<string, FieldBuilder> = lifecycle ? { ...def.fields, ...lifecycleFields() } : def.fields;
439
477
 
440
478
  for (const [name, field] of Object.entries(def.fields)) {
@@ -443,6 +481,8 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
443
481
  for (const [name, relation] of Object.entries(def.relations ?? {})) {
444
482
  assertRelationShape(def.schema && def.schema !== 'public' ? `${def.schema}.${table}` : table, name, relation);
445
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);
446
486
  }
447
487
  const primaryKey = Object.entries(def.fields)
448
488
  .filter(([, f]) => f.spec.isPrimaryKey)
@@ -453,6 +493,28 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
453
493
  throw new Error(`model '${table}': can('execute') is a function ability — declare it on the defineFunction it gates.`);
454
494
  }
455
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
+
456
518
  // A column list is authorization, not a selection hint. If it reaches a table-grant path
457
519
  // unnoticed, Postgres receives a whole-table privilege. Admit only the shapes the column
458
520
  // compiler understands, and reject an empty list because it is indistinguishable from no
@@ -484,7 +546,9 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
484
546
  );
485
547
  }
486
548
  for (const column of a.condition.columns) {
487
- 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')) {
488
552
  throw new Error(
489
553
  `model '${table}': columns names '${column}', but no such field exists. Column abilities use model field keys.`,
490
554
  );
@@ -686,6 +750,7 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
686
750
  // Stays false on a lifecycle model: its reads are filtered by the RLS guard (L1.2a), its deletes arrive with the triggers.
687
751
  softDelete: def.softDelete ?? false,
688
752
  ...(lifecycle ? { lifecycle: true as const } : {}),
753
+ ...(deleteWhen !== undefined ? { deleteWhen } : {}),
689
754
  schema: def.schema ?? 'public',
690
755
  private: def.private ?? false,
691
756
  rls: def.rls ?? true,