@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/CHANGELOG.md CHANGED
@@ -4,6 +4,71 @@ 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
+ ## [0.4.25] - 2026-10-30
8
+
9
+ ### BREAKING CHANGES
10
+
11
+ - destroy is one generated SQL function per lifecycle table; purgeExpired is removed
12
+ - only a lifecycle table may hold a foreign key into a lifecycle table
13
+
14
+ ### Added
15
+
16
+ - a destroy call removes only rows soft-deleted before its cutoff
17
+ - close destroy_log to the lifecycle role; the marker functions refuse inside a trigger
18
+ - a lifecycle model refuses TRIGGER and REFERENCES in privileges
19
+ - the apply preflight refuses a live catalog that breaks the soft delete
20
+ - db:reconcile runs the live catalog check on every write path
21
+
22
+ ### Fixed
23
+
24
+ - destroy hooks are refused on a table that is not a lifecycle table
25
+ - provisioning clears committed destroy_log rows and validates the transaction check
26
+ - a DELETE as the lifecycle role is soft outside a destroy call
27
+ - a destroy call whose marker was removed mid-call fails whole (55000)
28
+ - catalog checks pass the RDS shape and see owner, log and settings holes
29
+ - the destroy job logs no error text and fails a failed run
30
+ - LifecycleDestroy runs on CronV2, which the deploy identity may create
31
+
32
+ ### Changed
33
+
34
+ - one delete hook pair; beforeStamp and afterStamp are folded into beforeDelete and afterDelete
35
+
36
+ ### Documentation
37
+
38
+ - verify a built database with compileDeclaredState, not a per-model compileTableSchema loop
39
+ - one delete hook pair
40
+ - destroy, the everystack schema, and the upgrade from purgeExpired
41
+ - who runs destroyExpired, what batch caps, and the single destroy_log marker
42
+ - a destroy call removes only rows soft-deleted before its cutoff
43
+ - the out-of-date destroy_log refusal, a leftover destroying() function, and a root skipped for one run
44
+ - destroy_log is closed to the lifecycle role, and destroy cannot run inside a trigger
45
+ - the destroy result's reason, blocker and skippedBy
46
+ - only a lifecycle table holds a key into one; soft delete decides, destroy executes
47
+ - the destroy role option, the stock job and a first run by hand
48
+
49
+ ## [0.4.24] - 2026-10-23
50
+
51
+ ### BREAKING CHANGES
52
+
53
+ - lifecycle: { deleteWhen } refuses deleting a row its predicate does not allow
54
+ - refuse dependent + where on a lifecycle parent's hasMany
55
+ - can('read', { role, deleted: 'include' | 'only' }) on a lifecycle model
56
+ - one lifecycle hook API: (rows, ctx), veto(reason), beforeStamp
57
+ - refuse can('read', { deleted }) for anon, authenticated and public
58
+
59
+ ### Added
60
+
61
+ - index the FK column of every lifecycle dependent
62
+
63
+ ### Documentation
64
+
65
+ - what a lifecycle delete returns, plain children, vetoing a delete, the purge executor, the caching note
66
+ - lifecycle dependents' FK columns are indexed by the generator; what a pull without dependents plans
67
+ - measure what the lifecycle read guard costs a non-leakproof index search
68
+ - can('read', { role, deleted }) as the read hatch for a lifecycle index search
69
+ - Extending lifecycle, the phase table, the veto and failure rules
70
+ - the keyed lifecycle DELETE, before-hook pool hazard, batch as a hint, deleted-reader grants, FK index drops
71
+
7
72
  ## [0.4.23] - 2026-10-23
8
73
 
9
74
  ### Added
package/README.md CHANGED
@@ -163,10 +163,10 @@ such a model and it throws at import, naming which branch it tripped. Where noth
163
163
  publicly and nothing deletes, the flag provably changes neither the SQL nor the handler, so
164
164
  nothing is required.
165
165
 
166
- Pick `true` if the row is meant to survive its own deletion — anything with a cleanup or purge
166
+ Pick `true` if the row is meant to survive its own deletion — anything with a cleanup or destroy
167
167
  job, an audit trail, or rows other tables reference. Pick `false` if `deleted_at` here is
168
168
  audit-only and a `DELETE` really should remove the row. It is not cosmetic: a table whose S3
169
- cleanup job finds work by `deleted_at IS NOT NULL` orphans every file it should have purged if
169
+ cleanup job finds work by `deleted_at IS NOT NULL` orphans every file it should have removed if
170
170
  the row is hard-deleted instead.
171
171
 
172
172
  A column named `deleted_at` is not a statement of intent — it is carried for audit, for
@@ -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,13 +248,13 @@ 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 parent rendered `lifecycle: true` is the exception: `db:pull` renders the `dependent` of each key from a lifecycle child on the parent, from the key's `ON DELETE`, and leaves a plain child's key as it is, with a comment: only a lifecycle table may hold a key into a lifecycle table, so that model refuses to compile until you make it `lifecycle: true` or drop the `.references()`.
249
252
 
250
253
  ### Lifecycle
251
254
 
252
- `lifecycle: true` declares deletion in two steps: hide the row now, remove it later with an explicit purge.
255
+ `lifecycle: true` declares deletion in two steps: hide the row now, remove it later with explicit destruction.
253
256
  For example, deleting a post hides its lifecycle comments in the same batch. Restoring the post restores that
254
- batch subtree. Purging first removes the expired comments, then the post.
257
+ batch subtree. Destroy first removes the expired comments, then the post.
255
258
 
256
259
  ```typescript
257
260
  const Post = defineModel('posts', {
@@ -273,25 +276,48 @@ fingerprint and generated schema. Do not declare these fields yourself, includin
273
276
  Lifecycle needs a single-column primary key and RLS. Omit `lifecycle` to disable it; `false` is refused.
274
277
  Do not combine it with `softDelete`. The boolean remains supported separately; its removal has not shipped.
275
278
 
276
- A DELETE stamps these columns and reports zero removed rows. Read roles see only live rows;
279
+ A DELETE sets these columns and reports zero removed rows. Read roles see only live rows;
277
280
  a plain manage role can see deleted rows. The API hides `deletedBy`, `deletedBatch` and `deletedXid` from other roles.
278
281
  `deletedAt` stays visible. The generated guard refuses direct writes to the four columns by ordinary callers.
279
282
  Only `everystack_lifecycle`, a reserved NOLOGIN role, performs physical deletion. It receives SELECT,
280
283
  UPDATE of those four columns, and DELETE on lifecycle tables, but no INSERT there.
281
- Authors cannot name this role in abilities or privileges. TRUNCATE privileges on lifecycle models are refused.
284
+ Authors cannot name this role in abilities or privileges. TRUNCATE, TRIGGER and REFERENCES privileges on lifecycle models are refused.
282
285
 
283
286
  | `dependent` | Lifecycle parent's soft deletion | Physical removal |
284
287
  | --- | --- | --- |
285
- | `restrict` | Refuses while live lifecycle children remain | FK remains RESTRICT; purge waits for all remaining lifecycle children |
286
- | `cascade` | Stamps lifecycle children in the parent's batch | FK is RESTRICT; purge removes children first |
287
- | `nullify` | Leaves the child and its link unchanged | SET NULL clears the link at purge |
288
+ | `restrict` | Refuses while live lifecycle children remain | FK is RESTRICT; any remaining child defers the root |
289
+ | `cascade` | Soft-deletes lifecycle children in the parent's batch | FK is CASCADE; physical removal follows it |
290
+ | `nullify` | Leaves the child and its link unchanged | SET NULL clears the link at destroy |
288
291
 
289
292
  Cascade and restrict from a lifecycle parent require lifecycle children. Nullify requires a nullable FK.
290
- An actual `ON DELETE CASCADE` into a lifecycle table is refused. Plain-table cascades still remove rows without
293
+ An explicit field `onDelete: 'cascade'` into a lifecycle table is refused; use the lifecycle parent's declared dependent instead. 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.
293
- `db:pull` refuses author BEFORE DELETE triggers on lifecycle tables because they can suppress purge DELETE.
294
- It also refuses TRUNCATE triggers because TRUNCATE bypasses lifecycle row removal.
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 soft delete, direct or cascaded, so one guarded child refuses its parent's whole delete and nothing is
313
+ soft-deleted, as `restrict` does. A DELETE of several rows is refused as a whole when any one is guarded. `lifecycle: {}`
314
+ is `lifecycle: true`. Destroy does not re-evaluate the predicate after soft deletion. The predicate runs as the
315
+ reserved lifecycle role with a pinned `search_path`, so qualify any function of your own it calls.
316
+ For logic a predicate cannot express, a `beforeDelete` hook can `veto(reason)` a root row's soft delete through the API
317
+ ([Extending lifecycle](../../docs/handler.md#extending-lifecycle)); the `BEFORE UPDATE` veto trigger in docs/upgrading.md
318
+ remains the last resort, the one that also sees direct SQL and cascades.
319
+ There is no option to require an actor on a soft delete: use a veto trigger, or require authentication on DELETE.
320
+ `db:pull` renders live objects without a separate lifecycle verifier; review differences with `db:plan`.
295
321
 
296
322
  Call `<table>_restore(id)` to restore a batch subtree, or use the API's `POST /<table>/restore { id }`.
297
323
  Restore checks the caller's delete rule, not its read policy, because read policies hide deleted rows.
@@ -305,51 +331,204 @@ The API process is fully trusted. SQL on its connection can rewrite the request
305
331
  `deleted_by` is attribution, not proof. Lifecycle graph operations require READ COMMITTED;
306
332
  an earlier snapshot can miss a related row committed since it began.
307
333
 
308
- ### Purge
309
-
310
- Import `purgeExpired` and `ENSURE_PURGE_LOG_SQL` from `@everystack/server/purge`.
311
- On the trusted owner connection, run the ensure statements before the first purge.
312
- Pass the ops Drizzle database, the full linked model set, and `{ after, batch }`.
313
- `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`.
315
- This is trusted server code, not a client RPC capability.
316
-
317
- Purge checks the live FK graph against the declaration, then visits lifecycle children before parents.
318
- A cyclic lifecycle relation aborts the whole purge run before any row is removed.
319
- Each selected row gets its own READ COMMITTED transaction and lock. The DELETE runs as the reserved role.
320
- A remaining cascade/restrict lifecycle child blocks its parent; a nullify child clears its FK on the real delete.
321
- Unsupported live graph paths are reported without invoking hooks or deleting rows.
322
- Rows removed by `ON DELETE CASCADE` get no individual destroy hook or receipt.
323
-
324
- `budgetMs` defaults to 30000 and is checked between rows; a current transaction finishes.
325
- `retryAfterMs` defaults to one hour for skipped/failed keys; zero disables it.
326
- Skipped and failed rows do not consume `batch`. The result has per-table counts, skip reasons,
327
- `noHook` for removed tables without destroy hooks, and `incomplete` when the time budget ends the run.
328
- `afterDestroyErrors` counts after-hook failures; `afterDestroyFailures` records the first message per table/key.
329
- The optional `onError` sink receives each after-hook fault; a throwing sink does not stop the purge.
330
-
331
- `everystack.purge_log` stores the run id, table/key, batch, deletion and attempt times, actor, outcome,
332
- before-hook status and optional detail. A purged receipt commits with its DELETE.
333
- A failed attempt records failure after rolling back its savepoint, while retaining the row lock.
334
- The ledger grants INSERT only to the reserved role; guards refuse UPDATE, DELETE and TRUNCATE.
335
- It contains no row payload. Its owner can disable guards, so it is not evidence against that trusted owner.
336
- `db:doctor --purge-after <days>` counts overdue rows without hooks or writes; without that flag it skips the count.
337
- The example's account-erasure cron remains paused.
338
-
339
- `hooks.beforeDestroy(row, ctx)` and `hooks.afterDestroy(row, ctx)` describe physical removal.
340
- `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.
342
- 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.
345
- A driver without transactions refuses API hard DELETE on a table with a destroy hook: `db.transaction is not a function`.
346
- `afterDestroy` runs once per removed row after commit, best-effort; process death in between can prevent delivery.
347
- Irreversible external effects belong in `afterDestroy`. Make them idempotent; no transaction is passed to either hook.
348
- Existing `beforeDelete` and `afterDelete` remain API request hooks, including on lifecycle soft deletion.
334
+ ### Lifecycle and indexes
335
+
336
+ Every SELECT policy on a lifecycle table carries the guard `(deleted_at IS NULL)`, except the lifecycle role's own and a
337
+ `manage` ALL policy. PostgreSQL evaluates a policy qual before any of the query's own conditions whose operator function
338
+ is not `leakproof`, so such a condition cannot be an index condition below it. Measured on PostgreSQL 16, 100k and 500k
339
+ rows, one read per operator (`scripts/bench/lifecycle-read-guard.py`), generic and custom plans alike:
340
+
341
+ | operator (function) | leakproof | read whose policy was `true` | with the guard |
342
+ | --- | --- | --- | --- |
343
+ | `slug = $1` (`texteq`), `id = ANY($1)` (`int4eq`) | yes | index condition | index condition, unchanged |
344
+ | `tags @> $1` (`arraycontains`) | no | GIN, 1.7 ms at 500k | sequential scan, 41.8 ms |
345
+ | `meta @> $1` (`jsonb_contains`) | no | GIN, 1.0 ms at 500k | sequential scan, 44.6 ms |
346
+ | `title ILIKE $1` (`texticlike`, trigram) | no | GIN, 1.0 ms at 500k | sequential scan, 92.8 ms |
347
+ | `body_tsv @@ $1` (`ts_match_vq`) | no | GIN, 1.0 ms at 500k | sequential scan, 36.4 ms |
348
+
349
+ 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).
350
+ Only a read whose policy was `true` before lifecycle regresses (a public read, or a role read with no condition).
351
+ A read that already had its own condition (an owner or `sql` read) used a sequential scan for these operators
352
+ before lifecycle too, so the guard changes nothing there. A partial index `WHERE deleted_at IS NULL` did not help.
353
+ A role whose policy has no guard keeps the index.
354
+
355
+ For a hot search over a lifecycle table with one of those operators, give the search a role whose read has no guard
356
+ and filter deleted rows yourself:
357
+
358
+ ```typescript
359
+ abilities: [
360
+ can('read'), // guarded: live rows only
361
+ can('read', { role: 'lookup_role', deleted: 'include' }), // no guard: live and deleted rows, index usable
362
+ can('read', { role: 'trash_role', deleted: 'only' }), // (deleted_at IS NOT NULL): a trash view
363
+ ]
364
+ ```
365
+
366
+ `deleted` works only on `can('read', { role })` of a lifecycle model and changes only that role's read policy; its
367
+ `sql`, `where` and `columns` still apply. `'include'` means the role's queries must add `deleted_at IS NULL`
368
+ themselves when they want live rows (a leakproof `IS NULL` costs nothing). A role cannot have both a guarded read and
369
+ a `deleted` read: they would compile to the same policy, and `defineModel`'s compile refuses that. A column read may
370
+ list `deletedAt` (every reader sees it); `deletedBy`, `deletedBatch` and `deletedXid` stay unlistable.
371
+ `deleted` is for a named internal role: `defineModel` refuses it for `anon`, `authenticated` and `public`.
372
+ A `deleted` read with no `columns` is granted SELECT on every column except `deleted_by`, `deleted_batch` and
373
+ `deleted_xid`, not the whole table, so the role cannot read who deleted a row, even with plain SQL. A role that must see
374
+ who deleted uses `manage`; a `manage` or a `privileges` SELECT for the same role grants the whole table.
375
+ `db:plan` says `⚠ ACCESS — role <r> can read deleted rows of <table>` when it adds or changes such a policy.
376
+ Where the searching role is shared with other reads, use a `SECURITY DEFINER` lookup function owned by such a role,
377
+ whose body adds `deleted_at IS NULL` itself, schema-qualifies every table, pins `search_path`, and is granted EXECUTE
378
+ only to the roles that may search.
379
+
380
+ ### Destroy expired rows
381
+
382
+ The operational tables destroy relies on are listed in [the everystack schema](../../docs/everystack-schema.md).
383
+
384
+ Provision with `db:provision`, or build a fresh database with `db:build`. Both create
385
+ `everystack.destroy_log` before the generated lifecycle functions are installed.
386
+ For each lifecycle table, `<schema>.<table>_lifecycle_destroy(p_keys DEFAULT NULL,
387
+ p_before DEFAULT now(), p_max_rows DEFAULT 1000)` returns one row per root it acted on:
388
+ `key`, and `outcome` (`'removed'` or `'skipped'`).
389
+ Only a role whose `manage` covers every row receives EXECUTE. An owner-scoped `manage`, or a
390
+ role-less one on an owner-scoped model, covers only the caller's rows, so it receives none.
391
+ A role-less `can('manage')` on a model with no owner scope covers every row for `authenticated`,
392
+ and `can('manage', { role: 'anon' })` does the same for `anon`. Those roles then receive EXECUTE
393
+ too, so any caller with that role can destroy soft-deleted rows before your retention window ends.
394
+ To keep destroy with your operators, declare `manage` for a named role such as `admin`. This key-list contract currently requires scalar
395
+ primary keys; declaration refuses array-valued keys. Application roles must not own lifecycle
396
+ tables, be superusers, or inherit or assume `everystack_lifecycle` or a `manage` role.
397
+ A table owner can disable triggers.
398
+
399
+ The function selects already deleted rows strictly older than `p_before`, ordered by deletion
400
+ time and key, with `FOR UPDATE SKIP LOCKED`. NULL keys select by date alone. Foreign keys
401
+ apply the declared CASCADE, SET NULL, or RESTRICT action. A root with any remaining restrict
402
+ child, live or deleted, is deferred until that child is gone. Run tables in
403
+ `lifecycleDestroyOrder(models)` (children first over restrict and cascade edges), with each call in
404
+ its own transaction. Self-restrict trees converge one level per call; multi-table cycles are
405
+ refused at link and compile time. A live blocker leaves the parent overdue for `db:doctor`.
406
+ Only a lifecycle table may hold a foreign key into a lifecycle table, and the parent declares each one
407
+ with a hasMany `dependent`; a plain table refers to a lifecycle row by value. Any other key into a
408
+ lifecycle table is refused at `linkModels` and by `db:apply`. A key outside the declarations
409
+ (an out-of-band key, which no normal write makes) makes the set delete raise. The function then retries each root alone, returns each one
410
+ that still raises as `'skipped'`, and removes the rest. Only `foreign_key_violation` is skipped;
411
+ a lock timeout or any other error rolls the call back. The retry uses one subtransaction per root.
412
+ Fewer than `p_max_rows` rows may be returned (rows locked by a concurrent transaction are skipped).
413
+ `p_max_rows` must be a non-NULL positive integer; there is no upper cap.
414
+ The function uses a function-level `lock_timeout = '5s'`, restored on return. The caller must set
415
+ `statement_timeout` before invoking: setting it inside cannot bound an already running statement.
416
+ The wrapper must pass the SAME `p_before` cutoff it used to select candidates. A row restored and
417
+ re-deleted after that cutoff has a newer `deleted_at` and is skipped, which neutralises stale hook
418
+ approval without a token. Eligibility is checked inside the function; `deleteWhen`
419
+ is not checked again during destroy.
420
+
421
+ During the call, a lifecycle `DELETE` removes a row only if it is already soft-deleted and either
422
+ older than the call's cutoff or a cascade child whose declared cascade parent the call removed:
423
+ eligibility follows the root, so a child soft-deleted on its own a moment after its root goes with
424
+ it. Anything else (a live row, or a deleted row that is neither expired nor cascading, such as one a
425
+ consumer trigger tries to erase early) raises `23503`, and the root is reported as `'skipped'` with
426
+ nothing removed. Every delete after
427
+ the call returns is soft again, the lifecycle role's included.
428
+
429
+ `everystack.destroy_log` holds one row per destroy call in progress: the transaction ID and the
430
+ call's cutoff (`before`, the `p_before` it was given). The call writes it before it deletes and removes it before it returns, so the table is empty at rest
431
+ and never grows. No role but its owner holds a privilege on it. The call writes and removes its row
432
+ through `everystack.destroy_log_mark` and `everystack.destroy_log_unmark`, which only the lifecycle
433
+ role may run, and which refuse to run inside a trigger (`55000`). So a consumer trigger cannot
434
+ remove the call's row or write a new one with another cutoff, and **destroy cannot run inside a
435
+ trigger**: a `<table>_lifecycle_destroy` call from a trigger (a nested call during a destroy
436
+ included) fails with `55000`. Call it as a top-level statement, as `destroyExpired` does. A later
437
+ call in the same transaction runs on its own row. If the call's row is gone before the call ends,
438
+ the call fails whole with `55000` and removes nothing. A consumer trigger on a cascaded child runs
439
+ as that child table's owner (PostgreSQL runs `ON DELETE CASCADE` as the referencing table's owner),
440
+ not as the lifecycle role: treat it as owner code. The table keeps
441
+ no audit: `destroyExpired` returns what it removed per table, for the caller to record if it wants.
442
+
443
+ Use `destroyExpired` on a top-level database handle to run optional JavaScript hooks around
444
+ the generated SQL calls. It uses `lifecycleDestroyOrder(models)`; it computes no graph. The role it
445
+ runs as needs EXECUTE on the destroy functions and must read every expired row: a role whose `manage`
446
+ covers every row, such as `admin`. Pass `role: 'admin'` on the app's least-privilege login
447
+ (`authenticator`, NOINHERIT): every transaction then starts with `SET LOCAL ROLE admin`, as the API
448
+ does. Never run it as the database master or as `everystack_lifecycle`, which it refuses. It reads candidates a page of `batch` at a time
449
+ and names each page's keys in its call, so a call's work stays bounded however many roots are pinned.
450
+
451
+ ```typescript
452
+ import { destroyExpired } from '@everystack/server/destroy';
453
+
454
+ const result = await destroyExpired(db, models, {
455
+ after: 30 * 86400000, // Positive retention window in milliseconds, supplied by the caller.
456
+ batch: 1000, // Roots removed per table per run, at most; also the page size.
457
+ statementTimeoutMs: 60000,
458
+ role: 'admin', // SET LOCAL ROLE in every transaction; omit to run as the handle's own role.
459
+ onError: ({ table, phase, error }) => report({ table, phase, error }),
460
+ });
461
+ ```
462
+
463
+ There is no retention window on the Model. `after` is required; `batch` defaults to 1000 and
464
+ `statementTimeoutMs` to 60000. Optional `now: Date` fixes the run's clock. The wrapper chooses
465
+ one cutoff for the whole run, sets `SET LOCAL statement_timeout` before every destroy call,
466
+ and opens a separate top-level transaction for each call. A table with nothing expired gets no call. Do not pass a transaction handle.
467
+ It does not retry or schedule work.
468
+
469
+ | Hook on the Model | When | Effect |
470
+ | --- | --- | --- |
471
+ | `beforeDestroy(row, ctx)` | Before the SQL attempt, per candidate root | `veto(reason)` or a throw holds that root |
472
+ | `afterDestroy(row, ctx)` | After commit, only for root keys returned by SQL | Best-effort; errors do not change the result |
473
+
474
+ Hooks receive database rows with SQL column names. `ctx` is frozen:
475
+ `{ phase: 'destroy', batch: row.deleted_batch ?? null, user: null, client: null }`.
476
+ A failed commit can cause hooks to repeat on a later run; hooks must tolerate repetition.
477
+ Before-hook errors increment `held` and reach `onError`. An SQL or transaction failure sets
478
+ `failed` for that table and the run continues. After-hook errors reach `onError` without
479
+ changing the committed result. A throwing `onError` does not stop the run.
480
+
481
+ The result is `{ perTable, order }`: each `perTable['<schema>.<table>']` has
482
+ `{ removed, held, skipped, failed? }`. `held` counts vetoed or hook-skipped roots. `skipped` counts
483
+ roots a foreign key still references; `db:doctor` keeps counting them overdue. Neither counts
484
+ roots deferred by restrict children or skipped because they are locked. `order` is the list
485
+ from `lifecycleDestroyOrder` (public table names are bare; other schemas are qualified).
486
+
487
+ beforeDestroy(row, ctx) runs before an attempt; return veto(reason) to withhold the row. The row may still survive the attempt, so do nothing irreversible here. afterDestroy fires at most once per removed root, after commit; a crash between commit and the hook loses the call, so durable work belongs in a consumer AFTER DELETE trigger writing to an outbox. Hooks run for roots only: a child removed by its parent's cascade fires no afterDestroy; a sweep or a child trigger covers it. While a destroy call executes, a delete of a lifecycle row in that call, including one a consumer trigger issues, removes the row only if it is already soft-deleted and older than the call's cutoff; anything else raises, and the root is reported as skipped with nothing removed. Outside a destroy, every delete is soft. A root with a remaining restrict child is deferred until the child is gone. destroyExpired's perTable[t].skippedBy counts skipped roots by reason ('referenced', 'ineligible_live') and blocker (a table or constraint name, never a key); normal writes never cause a skip, so one means an out-of-band key or trigger. Fewer than batch rows may be returned. Application roles must not own lifecycle tables, be superusers, or inherit or assume everystack_lifecycle or a manage role (a table owner can disable triggers). The caller sets statement_timeout; the function sets only lock_timeout. Pass the same cutoff you selected with.
488
+
489
+ `db:doctor --destroy-after <days>` counts overdue rows without hooks or writes.
490
+ `overdueLifecycleRowsReadOnly(db, models, { after, role })` gives the same counts in one read-only
491
+ transaction under `role`.
492
+
493
+ #### The scheduled destroy job
494
+
495
+ `createDestroyJob` from `@everystack/server/destroy-job` is the stock scheduled handler. It does
496
+ nothing until you set `after`: with no retention window it logs one line and never connects. Give
497
+ it a handle on the least-privilege login; it switches to `role` (default `admin`) in each
498
+ transaction. It logs one JSON line with per-table counts and the `describeSkipped` lines, never a
499
+ row key or an error message. A run where any table failed then throws, naming only the failed
500
+ tables, so the invocation fails and error alarms fire; the cause reaches `onError`.
501
+
502
+ ```typescript
503
+ import { createDb } from '@everystack/server/db';
504
+ import { createDestroyJob } from '@everystack/server/destroy-job';
505
+
506
+ export const handler = createDestroyJob({
507
+ models,
508
+ after: 30 * 86400000,
509
+ db: () => createDb(schema).db,
510
+ });
511
+ ```
512
+
513
+ `createDb` connects as the authenticator only when the function links the `DATABASE_URL` secret and not the `Database` component; with no `DATABASE_URL` linked it falls back to the master.
514
+
515
+ The event `{ "dryRun": true }` counts the overdue rows under the role and changes nothing. The
516
+ example app runs the job as the `LifecycleDestroy` CronV2, linked to `DATABASE_URL` only. For a first
517
+ run, invoke it by hand with your AWS profile; you hold no database credential and no lifecycle role.
518
+ With several stages in one account the lookup lists each; set `fn` to the one for your stage.
519
+
520
+ ```bash
521
+ fn=$(aws lambda list-functions --query "Functions[?contains(FunctionName, 'LifecycleDestroy')].FunctionName" --output text)
522
+ aws lambda invoke --function-name "$fn" --cli-binary-format raw-in-base64-out --payload '{"dryRun":true}' dry.json && cat dry.json
523
+ aws lambda invoke --function-name "$fn" run.json && cat run.json
524
+ ```
525
+
526
+ Read the dry run's counts before the real run. The real run removes at most `batch` roots per table.
527
+ The example's account-erasure cron remains paused; it needs a separate adoption change.
349
528
 
350
529
  ### Display options: `orderBy`, `limit`, `where`, and `hasOne`
351
530
 
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.
531
+ 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
532
 
354
533
  ```typescript
355
534
  const Post = defineModel('posts', {
@@ -395,7 +574,7 @@ const handler = createHandler(db, schema, deriveHandlerConfig(models));
395
574
 
396
575
  Do not hand-write the per-table keys beside it. Each one is a view of the models, and a
397
576
  hand-kept copy drifts silently: `softDelete.tables` losing two entries is what let a table start
398
- hard-deleting rows whose S3 objects a purge job keyed on `deleted_at IS NOT NULL`. Handler-level
577
+ hard-deleting rows whose S3 objects a destroy job keyed on `deleted_at IS NOT NULL`. Handler-level
399
578
  options that are not per-table — `auth`, `pgSettings`, `rpc`, limits — stay explicit.
400
579
 
401
580
  ## Programmatic API (`@everystack/cli/model`)
@@ -405,12 +584,13 @@ The same compiler the CLI uses is importable for your own CI and tests — point
405
584
  ```typescript
406
585
  import {
407
586
  compileMigration, generateMigrationSql, introspectSchema, diffSchema, // data layer
587
+ compileDeclaredState, // the declared state of the set
408
588
  compileTableContract, introspectContract, diffContracts, // authz layer
409
589
  buildOwnerProbeSql, evaluateOwnerProbe, // owner-isolation red-team
410
590
  } from '@everystack/cli/model';
411
591
  ```
412
592
 
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.
593
+ - **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
594
  - **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
595
  - **Red-team** — `buildOwnerProbeSql` / `evaluateOwnerProbe` drive two JWT identities per owner-scoped table to catch IDOR (`db:authz:owner`).
416
596
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/model",
3
- "version": "0.4.23",
3
+ "version": "0.4.25",
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
  *