@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 +37 -1
- package/README.md +166 -68
- package/package.json +1 -1
- package/src/dependents.ts +106 -27
- package/src/derive.ts +3 -0
- package/src/derived.ts +5 -0
- package/src/destroy-log.ts +150 -0
- package/src/index.ts +4 -2
- package/src/lifecycle-hooks.ts +25 -8
- package/src/model.ts +39 -20
- package/src/pg-type.ts +66 -0
- package/src/wire.ts +9 -1
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
|
-
## [
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
289
|
-
| `cascade` |
|
|
290
|
-
| `nullify` | Leaves the child and its link unchanged | SET NULL clears the link at
|
|
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
|
|
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
|
|
313
|
-
|
|
314
|
-
is `lifecycle: true`.
|
|
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 `
|
|
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
|
|
321
|
-
`db:pull`
|
|
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
|
-
###
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
`
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
`
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
`
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
|
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
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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')
|
|
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 (
|
|
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
|
-
//
|
|
156
|
-
|
|
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
|
|
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
|
-
*
|
|
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';
|
package/src/lifecycle-hooks.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The lifecycle hook contract, shared by the model, the API handler and
|
|
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
|
-
/** `
|
|
30
|
-
phase: '
|
|
31
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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 (
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
/**
|
|
46
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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 =
|
|
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
|
}
|