@everystack/model 0.4.24 → 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,11 +4,47 @@ 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]
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
8
35
 
9
36
  ### Documentation
10
37
 
11
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
12
48
 
13
49
  ## [0.4.24] - 2026-10-23
14
50
 
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
@@ -248,13 +248,13 @@ The foreign key belongs to the child table, so the action lands there: in the pl
248
248
  - Any other value (for example `'softDelete'`) is refused by name.
249
249
  - `belongsTo` takes no `dependent`. A `hasMany` with no `dependent` is display only and compiles exactly as before.
250
250
 
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.
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()`.
252
252
 
253
253
  ### Lifecycle
254
254
 
255
- `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.
256
256
  For example, deleting a post hides its lifecycle comments in the same batch. Restoring the post restores that
257
- batch subtree. Purging first removes the expired comments, then the post.
257
+ batch subtree. Destroy first removes the expired comments, then the post.
258
258
 
259
259
  ```typescript
260
260
  const Post = defineModel('posts', {
@@ -276,21 +276,21 @@ fingerprint and generated schema. Do not declare these fields yourself, includin
276
276
  Lifecycle needs a single-column primary key and RLS. Omit `lifecycle` to disable it; `false` is refused.
277
277
  Do not combine it with `softDelete`. The boolean remains supported separately; its removal has not shipped.
278
278
 
279
- 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;
280
280
  a plain manage role can see deleted rows. The API hides `deletedBy`, `deletedBatch` and `deletedXid` from other roles.
281
281
  `deletedAt` stays visible. The generated guard refuses direct writes to the four columns by ordinary callers.
282
282
  Only `everystack_lifecycle`, a reserved NOLOGIN role, performs physical deletion. It receives SELECT,
283
283
  UPDATE of those four columns, and DELETE on lifecycle tables, but no INSERT there.
284
- 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.
285
285
 
286
286
  | `dependent` | Lifecycle parent's soft deletion | Physical removal |
287
287
  | --- | --- | --- |
288
- | `restrict` | Refuses while live lifecycle children remain | FK remains RESTRICT; purge waits for all remaining lifecycle children |
289
- | `cascade` | Stamps lifecycle children in the parent's batch | FK is RESTRICT; purge removes children first |
290
- | `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 |
291
291
 
292
292
  Cascade and restrict from a lifecycle parent require lifecycle children. Nullify requires a nullable FK.
293
- 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
294
294
  running app hooks. `dependent: 'destroy'` has not shipped.
295
295
  AFTER DELETE audit triggers are supported; they do not run on a soft deletion.
296
296
 
@@ -309,17 +309,15 @@ const List = defineModel('lists', {
309
309
 
310
310
  A refused delete raises SQLSTATE `23514` with the constraint `<table>_lifecycle_delete_when` and a message that names
311
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
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
316
315
  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
316
+ For logic a predicate cannot express, a `beforeDelete` hook can `veto(reason)` a root row's soft delete through the API
318
317
  ([Extending lifecycle](../../docs/handler.md#extending-lifecycle)); the `BEFORE UPDATE` veto trigger in docs/upgrading.md
319
318
  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.
321
- `db:pull` refuses author BEFORE DELETE triggers on lifecycle tables because they can suppress purge DELETE.
322
- It also refuses TRUNCATE triggers because TRUNCATE bypasses lifecycle row removal.
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`.
323
321
 
324
322
  Call `<table>_restore(id)` to restore a batch subtree, or use the API's `POST /<table>/restore { id }`.
325
323
  Restore checks the caller's delete rule, not its read policy, because read policies hide deleted rows.
@@ -379,54 +377,154 @@ Where the searching role is shared with other reads, use a `SECURITY DEFINER` lo
379
377
  whose body adds `deleted_at IS NULL` itself, schema-qualifies every table, pins `search_path`, and is granted EXECUTE
380
378
  only to the roles that may search.
381
379
 
382
- ### Purge
383
-
384
- Import `purgeExpired` and `ENSURE_PURGE_LOG_SQL` from `@everystack/server/purge`.
385
- On the trusted owner connection, run the ensure statements before the first purge.
386
- Pass the ops Drizzle database, the full linked model set, and `{ after, batch }`.
387
- `after` is positive retention in milliseconds; `batch` bounds the rows physically removed across all tables.
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.
392
- This is trusted server code, not a client RPC capability.
393
-
394
- Purge checks the live FK graph against the declaration, then visits lifecycle children before parents.
395
- A cyclic lifecycle relation aborts the whole purge run before any row is removed.
396
- Each selected row gets its own READ COMMITTED transaction and lock. The DELETE runs as the reserved role.
397
- A remaining cascade/restrict lifecycle child blocks its parent; a nullify child clears its FK on the real delete.
398
- Unsupported live graph paths are reported without invoking hooks or deleting rows.
399
- Rows removed by `ON DELETE CASCADE` get no individual destroy hook or receipt.
400
-
401
- `budgetMs` defaults to 30000 and is checked between rows; a current transaction finishes.
402
- `retryAfterMs` defaults to one hour for skipped/failed keys; zero disables it.
403
- Skipped and failed rows do not consume `batch`. The result has per-table counts, skip reasons,
404
- `noHook` for removed tables without destroy hooks, and `incomplete` when the time budget ends the run.
405
- `afterDestroyErrors` counts after-hook failures; `afterDestroyFailures` records the first message per table/key.
406
- The optional `onError` sink receives each after-hook fault; a throwing sink does not stop the purge.
407
-
408
- `everystack.purge_log` stores the run id, table/key, batch, deletion and attempt times, actor, outcome,
409
- before-hook status and optional detail. A purged receipt commits with its DELETE.
410
- A failed attempt records failure after rolling back its savepoint, while retaining the row lock.
411
- The ledger grants INSERT only to the reserved role; guards refuse UPDATE, DELETE and TRUNCATE.
412
- It contains no row payload. Its owner can disable guards, so it is not evidence against that trusted owner.
413
- `db:doctor --purge-after <days>` counts overdue rows without hooks or writes; without that flag it skips the count.
414
- The example's account-erasure cron remains paused.
415
-
416
- `hooks.beforeDestroy(row, ctx)` and `hooks.afterDestroy(row, ctx)` describe physical removal.
417
- `beforeDestroy` runs only for rows whose DELETE succeeded at statement level, inside the transaction with the removed row's data.
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.
421
- Purge checks its reserved-role DELETE and deferred constraints before the hook, which runs under the original caller's role.
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).
426
- A driver without transactions refuses API hard DELETE on a table with a destroy hook: `db.transaction is not a function`.
427
- `afterDestroy` runs once per removed row after commit, best-effort; process death in between can prevent delivery.
428
- Irreversible external effects belong in `afterDestroy`. Make them idempotent; no transaction is passed to either hook.
429
- Existing `beforeDelete` and `afterDelete` remain API request hooks, including on lifecycle soft deletion.
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.
430
528
 
431
529
  ### Display options: `orderBy`, `limit`, `where`, and `hasOne`
432
530
 
@@ -476,7 +574,7 @@ const handler = createHandler(db, schema, deriveHandlerConfig(models));
476
574
 
477
575
  Do not hand-write the per-table keys beside it. Each one is a view of the models, and a
478
576
  hand-kept copy drifts silently: `softDelete.tables` losing two entries is what let a table start
479
- 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
480
578
  options that are not per-table — `auth`, `pgSettings`, `rpc`, limits — stay explicit.
481
579
 
482
580
  ## Programmatic API (`@everystack/cli/model`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/model",
3
- "version": "0.4.24",
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/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,7 +47,7 @@ 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
  }
@@ -135,28 +136,10 @@ export function relationDeleteActions(
135
136
  if (dependent === 'cascade' && child.lifecycle && !parent.lifecycle) {
136
137
  throw new Error(`${at}, but ${where} is in a lifecycle table: ${lifecycleCascadeHarm(where)}`);
137
138
  }
138
- if (dependent === 'cascade' && parent.lifecycle && !child.lifecycle) {
139
- throw new Error(
140
- `${at}, but ${where} is not in a lifecycle table. Deleting a lifecycle row is a soft delete, so the parent `
141
- + `row stays and its foreign key never fires: ${where} would stay live under a deleted parent. `
142
- + `Make '${nameOf(child)}' lifecycle: true (its rows are then soft-deleted with the parent), `
143
- + `or use dependent 'restrict' or 'nullify'`,
144
- );
145
- }
146
-
147
- if (dependent === 'restrict' && parent.lifecycle && !child.lifecycle) {
148
- throw new Error(
149
- `${at}, but ${where} is not in a lifecycle table. Deleting a lifecycle row is a soft delete, so the foreign key `
150
- + `never fires; the restrict check runs in a trigger as the lifecycle role, which holds no rights on a plain table. `
151
- + `Make '${nameOf(child)}' lifecycle: true, or use dependent 'nullify'`,
152
- );
153
- }
139
+ if (parent.lifecycle && !child.lifecycle) throw new Error(plainKeyRefusal(`${at}, but ${where}`, nameOf(child), nameOf(parent), '.references()'));
154
140
 
155
- // A lifecycle parent soft-deletes its lifecycle 'cascade' children through triggers
156
- // (lifecycle-derived.ts), never through the foreign key: its DELETE never removes the row,
157
- // and an RI cascade into a lifecycle table would leave the child pointing at a removed
158
- // parent once one is purged. So that foreign key is RESTRICT; the relation keeps 'cascade'.
159
- 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];
160
143
  const forChild = planned.get(child) ?? new Map();
161
144
  const earlier = forChild.get(rel.column);
162
145
  if (earlier && earlier.onDelete !== onDelete) {
@@ -175,21 +158,75 @@ export function relationDeleteActions(
175
158
  planned.set(child, forChild);
176
159
  }
177
160
  }
161
+ assertLifecycleKeysDeclared(models, targetOf);
162
+ lifecycleDestroyOrder(models, targetOf);
178
163
  const out = new Map<ModelDescriptor, ReadonlyMap<string, ReferentialAction>>();
179
164
  for (const [child, fields] of planned) out.set(child, new Map([...fields].map(([k, v]) => [k, v.onDelete])));
180
165
  return out;
181
166
  }
182
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
+
183
220
  /**
184
221
  * The child FK columns a lifecycle parent's dependents need indexed, per child model. A property
185
222
  * of the MODEL SET like {@link relationDeleteActions}: pure, nothing written, every reader that
186
223
  * compiles indexes from the set consults it.
187
224
  *
188
225
  * 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
226
+ * restrict check counts the child by the FK, and destroy's ON DELETE SET NULL ('nullify') runs
190
227
  * PostgreSQL's referential action by the FK, once per removed parent. Without an index each is a
191
228
  * 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).
229
+ * destroy over 600k SET NULL children 21.4 s without, 0.6 s with).
193
230
  *
194
231
  * Rules: only a LIFECYCLE parent's hasMany with a dependent; field keys, one column (a dependent
195
232
  * rides a field `.references()`, never a composite foreignKey); skipped when the child already has
@@ -234,3 +271,45 @@ export function lifecycleDependentIndexes(
234
271
  for (const list of out.values()) list.sort((a, b) => a.join('\u0000').localeCompare(b.join('\u0000')));
235
272
  return out;
236
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,7 +29,7 @@ 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, lifecycleDependentIndexes } from './dependents';
32
+ export { relationDeleteActions, lifecycleDependentIndexes, lifecycleDestroyOrder } from './dependents';
33
33
  export type { DependentActions, DependentIndexes } from './dependents';
34
34
 
35
35
  export { veto, isVeto } from './lifecycle-hooks';
@@ -54,3 +54,5 @@ export type {
54
54
 
55
55
  export { deriveHandlerConfig, deriveForceRlsCarveouts, deriveFunctionSchemas } from './derive';
56
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';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The lifecycle hook contract, shared by the model, the API handler and purge.
2
+ * The lifecycle hook contract, shared by the model, the API handler and destroy workers.
3
3
  * `@everystack/api` carries a structurally identical copy (it has no runtime dependencies);
4
4
  * a veto made by either package is recognized by both.
5
5
  */
@@ -26,11 +26,9 @@ export function isVeto(value: unknown): value is HookVeto {
26
26
 
27
27
  /** The one context every lifecycle hook receives. */
28
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. */
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. */
34
32
  batch: string | null;
35
33
  user: Record<string, unknown> | null;
36
34
  client: Record<string, unknown> | null;
@@ -46,8 +44,27 @@ export interface DestroyContext extends LifecycleContext {
46
44
  /** beforeDestroy may return {@link veto}; afterDestroy's return value is ignored. */
47
45
  export type DestroyHook = (row: Record<string, unknown>, ctx: DestroyContext) => void | HookVeto | Promise<void | HookVeto>;
48
46
 
49
- /** A before hook of the lifecycle: called once per root row; returns nothing or a veto. */
47
+ /** Before commit, once per matched root row; returns nothing or a veto. Cascaded children run no hook: guard them with deleteWhen. */
50
48
  export type BeforeLifecycleHook = (row: Record<string, unknown>, ctx: LifecycleContext) => void | HookVeto | Promise<void | HookVeto>;
51
49
 
52
- /** An after hook of the lifecycle: called after commit with the root rows of one batch. Best-effort. */
50
+ /** After commit, once per request with the root rows. Best-effort; cascaded children run no hook. */
53
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
+ }
package/src/model.ts CHANGED
@@ -23,12 +23,12 @@ import type { RawSql } from './raw-sql';
23
23
  import { validateTriggersFor, type TriggerSpec, type DependsOnRef } from './derived';
24
24
  import { TABLE_PRIVILEGES, type TablePrivilege } from './privileges';
25
25
 
26
- import type { AfterLifecycleHook, BeforeLifecycleHook, DestroyHook } from './lifecycle-hooks';
26
+ import { assertDeleteHookNames, assertDestroyHooks, type AfterLifecycleHook, type BeforeLifecycleHook, type DestroyHook } from './lifecycle-hooks';
27
27
  export type { AfterLifecycleHook, BeforeLifecycleHook, DestroyContext, DestroyHook, HookVeto, LifecycleContext } from './lifecycle-hooks';
28
28
 
29
29
  /**
30
30
  * Per-table mutation hooks. Imperative function bodies the framework cannot
31
- * absorb (purge an S3 object on delete, stamp a column on create) — held
31
+ * absorb (remove a stored object after destroy, set a column on create) — held
32
32
  * verbatim and passed through to the handler's `hooks`.
33
33
  */
34
34
  export interface ModelHooks {
@@ -36,20 +36,17 @@ export interface ModelHooks {
36
36
  afterCreate?: (...args: any[]) => any;
37
37
  beforeUpdate?: (...args: any[]) => any;
38
38
  afterUpdate?: (...args: any[]) => any;
39
- beforeDelete?: (...args: any[]) => any;
40
- afterDelete?: (...args: any[]) => any;
41
- /** Runs after the DELETE statement, before commit. A throw rolls back only the database
42
- * delete, never an external effect. No transaction is passed. Must be idempotent.
43
- * Treat the row as read-only: it is the same object later serialized into the response. */
39
+ /** Before commit, once per matched root. Cascaded children run no hook; use deleteWhen. */
40
+ beforeDelete?: BeforeLifecycleHook;
41
+ /** After commit, once per request with the root rows. Best-effort. */
42
+ afterDelete?: AfterLifecycleHook;
43
+ /** Before an SQL destroy attempt, per candidate root. A veto or throw holds that root.
44
+ * The row may survive the attempt; do nothing irreversible here. No transaction is passed.
45
+ * Hooks may repeat after a failed commit. Treat the database row as read-only. */
44
46
  beforeDestroy?: DestroyHook;
45
- /** Runs after commit, best-effort. Cannot undo the removal. Must be idempotent.
46
- * Treat the row as read-only: it is the same object later serialized into the response. */
47
+ /** After commit, only for removed roots; FK cascade children run no hook. Best-effort:
48
+ * a crash can lose the call. Cannot undo removal. Treat the database row as read-only. */
47
49
  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
50
  /** Lifecycle tables: after `POST {table}/restore` commits, with `[{ <key>: id }]`, the batch and the counts. Best-effort. */
54
51
  afterRestore?: AfterLifecycleHook;
55
52
  /** Reserved for a future release; createHandler refuses it. */
@@ -123,7 +120,7 @@ export interface ModelDefinition {
123
120
  *
124
121
  * The guard used to ask only about the public read, which is the visibility axis. That let a
125
122
  * model with an owner-scoped read and a `delete` ability default to `false` and begin
126
- * destroying rows — with an S3 purge job keyed on `deleted_at IS NOT NULL`, that orphaned
123
+ * destroying rows — with an S3 cleanup job keyed on `deleted_at IS NOT NULL`, that orphaned
127
124
  * every file it should have cleaned up. On those models the flag adds no policy at all, so
128
125
  * visibility was never the axis that mattered.
129
126
  *
@@ -142,10 +139,10 @@ export interface ModelDefinition {
142
139
  * `deletedBatch` uuid, `deletedXid` bigint. The author never declares them, never combines
143
140
  * lifecycle with `softDelete`, and declares a single-column primary key; each is refused here.
144
141
  *
145
- * `{ deleteWhen: sql\`<predicate>\` }` is lifecycle too, with a guard: a row may be stamped
142
+ * `{ deleteWhen: sql\`<predicate>\` }` is lifecycle too, with a guard: a row may be marked
146
143
  * deleted only when the predicate is TRUE for it (FALSE or NULL refuses, SQLSTATE 23514), whether
147
144
  * 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`.
145
+ * Destroy does not re-check this predicate after deletion. `{}` is `true`.
149
146
  */
150
147
  lifecycle?: true | LifecycleOptions;
151
148
  /**
@@ -320,7 +317,7 @@ function lifecycleFields(): Record<(typeof LIFECYCLE_COLUMNS)[number], FieldBuil
320
317
 
321
318
  /** `lifecycle: { … }` options. */
322
319
  export interface LifecycleOptions {
323
- /** A row may be stamped deleted only when this is TRUE for it; FALSE or NULL refuses. */
320
+ /** A row may be marked deleted only when this is TRUE for it; FALSE or NULL refuses. */
324
321
  deleteWhen?: RawSql;
325
322
  }
326
323
 
@@ -377,7 +374,7 @@ function assertLifecycle(table: string, def: ModelDefinition): void {
377
374
  }
378
375
  // A cascade into this table, declared on this table's own foreign key. A parent's
379
376
  // hasMany { dependent: 'cascade' } is checked in relationDeleteActions (allowed from a
380
- // lifecycle parent, where it compiles to RESTRICT and the triggers soft-delete the children).
377
+ // lifecycle parent: the triggers soft-delete the children, and destroy follows the real CASCADE).
381
378
  const tableName = def.schema && def.schema !== 'public' ? `${def.schema}.${table}` : table;
382
379
  for (const [name, f] of Object.entries(def.fields)) {
383
380
  if (f.spec.onDelete !== 'cascade') continue;
@@ -395,6 +392,10 @@ function assertLifecycle(table: string, def: ModelDefinition): void {
395
392
  `Mark exactly one field .primaryKey().`,
396
393
  );
397
394
  }
395
+ const [key, keyField] = Object.entries(def.fields).find(([, f]) => f.spec.isPrimaryKey)!;
396
+ if (keyField.spec.isArray) {
397
+ throw new Error(`model '${tableName}': lifecycle needs a primary key PostgreSQL can list in a key array; ${key} is an array type`);
398
+ }
398
399
  // The generated trigger and function names are `<table>_lifecycle_transition` and `_guard`.
399
400
  // PostgreSQL silently truncates an identifier past 63 bytes, so the declared name would not be the live one.
400
401
  // UTF-8 bytes, as PostgreSQL counts them. TextEncoder rather than Buffer: a model also loads outside Node.
@@ -412,7 +413,9 @@ function assertLifecycle(table: string, def: ModelDefinition): void {
412
413
  * Restore runs the table's delete rule inside a SECURITY DEFINER function as the lifecycle role.
413
414
  * Refuse a delete rule whose meaning would change there: a subquery (`via`, or SELECT in
414
415
  * where/sql) runs with that role's privileges, and current_user/session_user name that role or
415
- * the login, not the caller. TRUNCATE is refused because it skips the delete triggers.
416
+ * the login, not the caller. TRUNCATE is refused because it skips the delete triggers; TRIGGER
417
+ * because its holder can create triggers on the table; REFERENCES because its holder can
418
+ * add a foreign key that blocks or cascades a destroy.
416
419
  */
417
420
  function assertLifecycleRestoreSafe(table: string, def: ModelDefinition): void {
418
421
  if (!isLifecycle(def)) return;
@@ -464,9 +467,25 @@ function assertLifecycleRestoreSafe(table: string, def: ModelDefinition): void {
464
467
  `triggers, so it would erase rows past the soft delete and restore. Remove TRUNCATE from the entry.`,
465
468
  );
466
469
  }
470
+ const beyondDelete: Record<string, string> = {
471
+ TRIGGER: 'TRIGGER lets the holder create triggers on the table, so its own code would run inside every delete and destroy',
472
+ REFERENCES: 'REFERENCES lets the holder add a foreign key that blocks or cascades a destroy',
473
+ };
474
+ for (const [role, privs] of Object.entries(def.privileges ?? {})) {
475
+ if (!Array.isArray(privs)) continue;
476
+ for (const priv of ['TRIGGER', 'REFERENCES'] as const) {
477
+ if (!privs.includes(priv)) continue;
478
+ throw new Error(
479
+ `model '${table}': privileges['${role}'] grants ${priv} on a lifecycle: true model — ${beyondDelete[priv]}. ` +
480
+ `Remove ${priv} from the entry.`,
481
+ );
482
+ }
483
+ }
467
484
  }
468
485
 
469
486
  export function defineModel(table: string, def: ModelDefinition): ModelDescriptor {
487
+ assertDeleteHookNames(table, def.hooks);
488
+ assertDestroyHooks(table, def.hooks, isLifecycle(def));
470
489
  // Refuse first, then inject: the author-declared collisions are checked against what the author wrote.
471
490
  assertRoleNotReserved(table, def);
472
491
  assertLifecycle(table, def);
package/src/pg-type.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { FieldSpec } from './field';
2
+
1
3
  /**
2
4
  * pg-type — the ONE definition of a column type's spelling.
3
5
  *
@@ -95,3 +97,67 @@ export function pgTypeRefusal(typeName: string): string | null {
95
97
  }
96
98
  return null;
97
99
  }
100
+
101
+ /**
102
+ * Postgres type for a field, with the precision drizzle-kit would emit — in
103
+ * `format_type`'s vocabulary, because this feeds the IR that diffs against
104
+ * introspection. A serial field's *column type* is integer/bigint (`serial` is
105
+ * DDL shorthand, not a type — `format_type` never reports it); the sequence
106
+ * default is carried separately (see compileTableSchema), and the DDL renderers
107
+ * spell the shorthand (see SERIAL_DDL).
108
+ */
109
+ const PG_TYPE: Record<string, string> = {
110
+ text: 'text',
111
+ uuid: 'uuid',
112
+ integer: 'integer',
113
+ boolean: 'boolean',
114
+ json: 'json',
115
+ jsonb: 'jsonb',
116
+ bigint: 'bigint',
117
+ serial: 'integer',
118
+ bigserial: 'bigint',
119
+ real: 'real',
120
+ doublePrecision: 'double precision',
121
+ timestamptz: 'timestamp with time zone',
122
+ // `format_type` canonicalizes a bare `timestamp` column to this spelling, and
123
+ // introspection compares against it — so the compiler must emit the same, or an
124
+ // unchanged `timestamp` column round-trips to a spurious ALTER COLUMN TYPE.
125
+ timestamp: 'timestamp without time zone',
126
+ date: 'date',
127
+ };
128
+
129
+ export function pgType(spec: FieldSpec): string {
130
+ // An array is the base type with `[]` appended — `text` → `text[]`, exactly as
131
+ // `format_type` spells it.
132
+ return spec.isArray ? `${scalarPgType(spec)}[]` : scalarPgType(spec);
133
+ }
134
+
135
+ function scalarPgType(spec: FieldSpec): string {
136
+ // Parameterized types build their own canonical string — exactly as `format_type`
137
+ // spells it (no space after the comma; `varchar` is `character varying`; a
138
+ // precision-only numeric carries scale 0), so an unchanged column round-trips clean.
139
+ if (spec.type === 'numeric') {
140
+ if (spec.precision != null) return `numeric(${spec.precision},${spec.scale ?? 0})`;
141
+ return 'numeric';
142
+ }
143
+ if (spec.type === 'varchar') {
144
+ return spec.length != null ? `character varying(${spec.length})` : 'character varying';
145
+ }
146
+ if (spec.type === 'enum') {
147
+ // The column's type is the enum's own type name; `format_type` reports the same bare
148
+ // name, so it round-trips. The `CREATE TYPE` is emitted separately, before the table.
149
+ if (!spec.enumName) throw new Error('field bridge: an enum field needs a type name — field.enum(name, values)');
150
+ return spec.enumName;
151
+ }
152
+ if (spec.type === 'pgType') {
153
+ // A verbatim type: the declared string IS the canonical `format_type` spelling
154
+ // (the factory refused anything else), so it round-trips byte-for-byte.
155
+ if (!spec.pgTypeName) throw new Error('field bridge: a pgType field needs its type name — field.pgType(name)');
156
+ return spec.pgTypeName;
157
+ }
158
+ const t = PG_TYPE[spec.type];
159
+ if (!t) {
160
+ throw new Error(`field bridge: no DDL mapping for field type '${spec.type}' yet`);
161
+ }
162
+ return t;
163
+ }
package/src/wire.ts CHANGED
@@ -46,8 +46,10 @@
46
46
  *
47
47
  * v1 — the first version. Shapes as they stood when the guard was introduced.
48
48
  * v2 — ProvisionWirePayload gains `lifecycleRole` (provision the reserved lifecycle role).
49
+ * v3 — ReconcileWirePayload gains `applicationRoles` (the lifecycle catalog check's roles). A v2
50
+ * CLI's payload carries none, and a reconcile of lifecycle-owned objects refuses without them.
49
51
  */
50
- export const MODEL_WIRE_CONTRACT = 2;
52
+ export const MODEL_WIRE_CONTRACT = 3;
51
53
 
52
54
  /**
53
55
  * Every Model-derived wire payload carries this. The server refuses a payload whose `modelContract`
@@ -237,6 +239,12 @@ export interface ReconcileWirePayload extends ModelWireEnvelope {
237
239
  rebuild?: boolean;
238
240
  overwriteDrift?: boolean;
239
241
  only?: string[];
242
+ /**
243
+ * The application roles the Models' compiled contracts name (catalogInputsFromModels in the
244
+ * CLI): the lifecycle catalog check's one input the declared layer cannot supply. Absent while a
245
+ * declared object is lifecycle-owned = the reconcile refuses (fail closed).
246
+ */
247
+ applicationRoles?: string[];
240
248
  actor?: string | null;
241
249
  gitRef?: string | null;
242
250
  }