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