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