@everystack/model 0.4.22 → 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 +67 -0
- package/README.md +288 -3
- package/package.json +1 -1
- package/src/can.ts +8 -0
- package/src/constraints.ts +21 -3
- package/src/dependents.ts +236 -0
- package/src/derive.ts +46 -11
- package/src/derived.ts +1 -1
- package/src/drizzle.ts +4 -0
- package/src/external.ts +119 -0
- package/src/field.ts +27 -4
- package/src/index.ts +10 -5
- package/src/lifecycle-hooks.ts +53 -0
- package/src/link.ts +40 -0
- package/src/model.ts +294 -6
- package/src/module.ts +8 -3
- package/src/relation-display.ts +48 -0
- package/src/relations.ts +207 -5
- package/src/wire.ts +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,73 @@ 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
|
+
|
|
36
|
+
## [0.4.23] - 2026-10-23
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- external FK target, .references({ schema, table, column })
|
|
41
|
+
- F7a relation options; hasMany dependent sets the child FK ON DELETE
|
|
42
|
+
- relation display options and hasOne (F7b)
|
|
43
|
+
- per-parent embed order, limit and where on both read paths (F10)
|
|
44
|
+
- hasOne one-row embed on both read paths (P2)
|
|
45
|
+
- hasMany dependent 'cascade' is ON DELETE CASCADE on the child's foreign key (D1)
|
|
46
|
+
- lifecycle: true declares four generated columns (L1.1)
|
|
47
|
+
- lifecycle gets a reserved owner role and hides deleted rows from read roles (L1.2a)
|
|
48
|
+
- lifecycle triggers turn DELETE into a soft delete (L1.2b)
|
|
49
|
+
- lifecycle restore, for whoever may delete the row (L1.3)
|
|
50
|
+
- a lifecycle parent's delete soft-deletes its cascade children in the same batch (L2 + L6, P1.2)
|
|
51
|
+
- the handler deletes, hides and restores lifecycle rows through the database (H1, P1.4)
|
|
52
|
+
- db:provision creates the reserved lifecycle role (P2.7)
|
|
53
|
+
- lifecycle columns are hidden from callers that cannot manage the table
|
|
54
|
+
- beforeDestroy and afterDestroy hooks at a real removal
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- F3e review minors: catalog FK target, one target slot, derived refusal
|
|
59
|
+
- F7a review: derive dependent ON DELETE per set, never write a field spec
|
|
60
|
+
- F7b review: mark display relations in drizzle, withhold only where/limit
|
|
61
|
+
- a beforeDestroy HandlerError renders with its own status
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
|
|
65
|
+
- break the link.ts <-> relations.ts import cycle (F7b)
|
|
66
|
+
|
|
67
|
+
### Documentation
|
|
68
|
+
|
|
69
|
+
- name the one runtime-vs-generated Drizzle difference a parent dependent creates
|
|
70
|
+
- handler relation orderBy/limit/where; the API handler applies the display options (F11)
|
|
71
|
+
- lifecycle, dependents, hooks at the real removal, purge
|
|
72
|
+
- define destroy timing and verification limits
|
|
73
|
+
|
|
7
74
|
## [0.4.22] - 2026-10-16
|
|
8
75
|
|
|
9
76
|
### Added
|
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@ export const models = [Account, Note];
|
|
|
72
72
|
| `field.jsonb()` | `jsonb` |
|
|
73
73
|
| `field.enum('name', [...])` | a `CREATE TYPE … AS ENUM` (name is explicit, so `db:pull` round-trips it) |
|
|
74
74
|
|
|
75
|
-
Modifiers: `.primaryKey()`, `.notNull()` / `.nullable()`, `.unique()`, `.default(value)` / `.defaultNow()` / `.defaultRandom()`, `.references(() => Other, { onDelete, onUpdate })
|
|
75
|
+
Modifiers: `.primaryKey()`, `.notNull()` / `.nullable()`, `.unique()`, `.default(value)` / `.defaultNow()` / `.defaultRandom()`, `.references(() => Other, { onDelete, onUpdate })` (or `.references({ schema, table, column })` for a table no model declares, below), `.private()` (never serialized), `.readonly()` (never accepted on write), `.validate(zodSchema)`, `.renamedFrom('oldField')` (turns a drop+add into a data-preserving `RENAME COLUMN`), and `.deprecated()` (the contract phase of contraction: the column stays in the database and stays readable — old clients keep working — while new writes are rejected and generated types strike it through; deleting the field later is the actual drop, destructive and gated).
|
|
76
76
|
|
|
77
77
|
Renaming a whole table works the same way, at the model level: `defineModel('articles', { renamedFrom: 'posts', ... })` compiles to one `ALTER TABLE … RENAME TO …` (data preserved, policies ride along) instead of CREATE plus a held DROP. Both markers are one-shot — remove them once every environment is past the state; a satisfied rename is an inert notice.
|
|
78
78
|
|
|
@@ -87,6 +87,24 @@ The table-level surface, composed in `constraints: [...]`, for what a column-sco
|
|
|
87
87
|
- `check(sql\`a >= b\`)` — a multi-column CHECK.
|
|
88
88
|
- `foreignKey(['a', 'b'], () => Other, ['x', 'y'], { onDelete })` — a composite foreign key.
|
|
89
89
|
|
|
90
|
+
### A foreign key to a table no model declares
|
|
91
|
+
|
|
92
|
+
Another system can own the table a column points at: an auth provider's `auth.users`, a table another service writes. Name it as an object:
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
ownerId: field.uuid().references({ schema: 'auth', table: 'users', column: 'id' }, { onDelete: 'cascade' }),
|
|
96
|
+
// composite: the reference columns are the third argument
|
|
97
|
+
constraints: [foreignKey(['orgId', 'teamId'], { schema: 'auth', table: 'teams' }, ['org_id', 'team_id'])],
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- `schema`, `table` and `column` are all required. There is no `public` default, and nothing is split on a dot: each is one PostgreSQL name, used as written (no snake_casing). A name holding a quote, a dot or a control character, or longer than 63 bytes, is refused.
|
|
101
|
+
- A string target is always a model name. `.references('users')` with no `users` model is an error, never a table.
|
|
102
|
+
- If a model in the set declares that schema and table, the object form is refused: use the model.
|
|
103
|
+
- It is a foreign key only, never a relation. It adds no `belongsTo`/`hasMany`, no embedding and no handler relation.
|
|
104
|
+
- It plans, applies and fingerprints exactly like a model foreign key. Delete it from the model and the next plan drops it; drop it from the database and the next plan adds it back.
|
|
105
|
+
- The deploying role needs `REFERENCES` on the target table. Without it the apply fails with PostgreSQL's `permission denied for table …`; granting it is the target owner's decision.
|
|
106
|
+
- A plan line names the target as `schema.table`: `+ add constraint things_owner_id_users_id_fk on things referencing auth.users`.
|
|
107
|
+
|
|
90
108
|
Each can carry the name the database calls it: `index('slug').name('posts_slug_lookup')`, `foreignKey([...], () => Other, [...], { name: 'posts_owner_fk' })`, and `check(sql\`ends_at >= starts_at\`, { name: 'events_window_check' })`. Undeclared, the compiler generates the name. A check is then named by its position among the table's unnamed checks (`events_check_0`, `events_check_1`), so inserting or reordering one renames the others; an explicit name is used verbatim and is stable under reordering. A name must be a bare lowercase identifier of at most 63 bytes, and two checks in one table cannot share a name or take one an unnamed check gets by position. Declaring a name on an existing constraint plans a `RENAME CONSTRAINT` (or `ALTER INDEX … RENAME`), never a drop and re-add. `db:apply` applies a rename like any other change, also when it is the only change in the plan. `db:pull` declares a live name only when it is not the one the compiler would generate.
|
|
91
109
|
|
|
92
110
|
## Authorization
|
|
@@ -159,17 +177,283 @@ flag explicitly for the same reason.
|
|
|
159
177
|
## Relations
|
|
160
178
|
|
|
161
179
|
```typescript
|
|
162
|
-
import { belongsTo, hasMany } from '@everystack/model';
|
|
180
|
+
import { belongsTo, hasMany, hasOne } from '@everystack/model';
|
|
163
181
|
|
|
164
182
|
defineModel('notes', {
|
|
165
183
|
fields: { /* … */ },
|
|
166
184
|
relations: {
|
|
167
185
|
account: belongsTo(() => Account, 'accountId'),
|
|
168
186
|
comments: hasMany(() => Comment, 'noteId'),
|
|
187
|
+
latestComment: hasOne(() => Comment, 'noteId', { orderBy: [{ field: 'createdAt', order: 'desc' }] }),
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The target is a model thunk or a model name (`hasMany('comments', 'noteId')`), resolved by `linkModels`. The third argument is optional:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
belongsTo(target, column, opts?: string | { references?: string })
|
|
196
|
+
hasMany(target, column, opts?: string | {
|
|
197
|
+
references?: string;
|
|
198
|
+
dependent?: 'restrict' | 'nullify' | 'cascade';
|
|
199
|
+
orderBy?: Array<{ field: string; order: 'asc' | 'desc' }>;
|
|
200
|
+
limit?: number;
|
|
201
|
+
where?: Record<string, string | number | boolean | null>;
|
|
202
|
+
})
|
|
203
|
+
hasOne(target, column, opts?: {
|
|
204
|
+
references?: string;
|
|
205
|
+
orderBy?: Array<{ field: string; order: 'asc' | 'desc' }>;
|
|
206
|
+
where?: Record<string, string | number | boolean | null>;
|
|
207
|
+
})
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
A string third argument is `references`, the key on the other side when it is not the primary key. Existing calls mean what they meant. An unknown option is refused by name.
|
|
211
|
+
|
|
212
|
+
### What a delete does: `dependent`
|
|
213
|
+
|
|
214
|
+
`dependent` on a `hasMany` says what deleting this row does to the target rows. For plain tables, it compiles to the `ON DELETE` of the target's foreign key on `column`. Lifecycle tables use the rules below:
|
|
215
|
+
|
|
216
|
+
| `dependent` | the child's foreign key |
|
|
217
|
+
| ------------ | --------------------------------- |
|
|
218
|
+
| `'restrict'` | `ON DELETE RESTRICT` |
|
|
219
|
+
| `'nullify'` | `ON DELETE SET NULL` |
|
|
220
|
+
| `'cascade'` | `ON DELETE CASCADE` |
|
|
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
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
const Post = defineModel('posts', {
|
|
227
|
+
fields: { id: field.uuid().primaryKey() },
|
|
228
|
+
relations: { comments: hasMany('comments', 'postId', { dependent: 'nullify' }) },
|
|
229
|
+
});
|
|
230
|
+
const Comment = defineModel('comments', {
|
|
231
|
+
fields: { id: field.uuid().primaryKey(), postId: field.uuid().references('posts') },
|
|
232
|
+
});
|
|
233
|
+
// db:plan: comments_post_id_posts_id_fk is re-created ON DELETE SET NULL.
|
|
234
|
+
// Deleting a post sets its comments' post_id to NULL.
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
The foreign key belongs to the child table, so the action lands there: in the plan, the from-scratch build and the fingerprint, as an ordinary foreign-key change you review. The rules:
|
|
238
|
+
|
|
239
|
+
- **The child must declare the foreign key.** `.references()` on the target field, pointing at this model. A `dependent` never creates one; without it the compile fails with `declare .references() on comments.postId`.
|
|
240
|
+
- **Neither side wins.** The child may also declare `onDelete`. If both say the same thing (`'restrict'` and `'restrict'`, `'nullify'` and `'set null'`), that is fine and emitted once. If they disagree, the compile fails naming both places. A child's explicit `onDelete: 'no action'` disagrees with `dependent: 'restrict'`: `NO ACTION` can be deferred to the end of the transaction, `RESTRICT` cannot.
|
|
241
|
+
- **`'restrict'` is a real change from the default.** A child FK with no `onDelete` is PostgreSQL's default, `NO ACTION`. Adding `dependent: 'restrict'` over it moves it to `RESTRICT`, and `db:plan` shows that as the FK being re-created.
|
|
242
|
+
- The action is a property of the model set, never written into a field. A field builder shared by two child models gets the action only on the child the relation names. The runtime `toDrizzleTable(model)` is built per model, so it carries only the child's own `onDelete`; the DDL, the plan, the fingerprint and the generated `schema.generated.ts` carry the derived one.
|
|
243
|
+
- **`'nullify'` needs a nullable column.** On a `NOT NULL` field it is a compile error.
|
|
244
|
+
- It applies to the one foreign key on `column`. Another foreign key from the same child to the same parent is untouched.
|
|
245
|
+
- The relation must join on the primary key (the key a field `.references()` points at). A field covered only by a table-level `foreignKey(...)` declares `onDelete` there.
|
|
246
|
+
- **`'cascade'` deletes the children in the database** (Rails `delete_all`). It runs no hook of the child and no code of yours. A child that must run code when it goes is not a cascade; that is `'destroy'`, which is not available yet. `'cascade'` works over a `NOT NULL` column, and it follows the child's own foreign keys in turn: deleting a thread deletes its replies and their reactions.
|
|
247
|
+
- **A cascade is not checked against the child's own abilities.** PostgreSQL runs the referential action as the owner of the child table, so a role that may delete the parent row removes its children even when it holds no `DELETE` grant on the child and the child's row-level policy would refuse it. Grant `delete` on the parent only to roles that may remove everything below it. `'nullify'` has the same property for `UPDATE`.
|
|
248
|
+
- Any other value (for example `'softDelete'`) is refused by name.
|
|
249
|
+
- `belongsTo` takes no `dependent`. A `hasMany` with no `dependent` is display only and compiles exactly as before.
|
|
250
|
+
|
|
251
|
+
`db:pull` renders the action on the child (`.references(() => Post, { onDelete: 'set null' })`) and no `dependent` on the parent; that agrees with a parent that declares one, so pull then plan is empty. A lifecycle parent is the exception: without its `dependent` declarations the generated foreign-key indexes are no longer declared, so `db:plan` refuses their drops as destructive until you declare the `dependent` again.
|
|
252
|
+
|
|
253
|
+
### Lifecycle
|
|
254
|
+
|
|
255
|
+
`lifecycle: true` declares deletion in two steps: hide the row now, remove it later with an explicit purge.
|
|
256
|
+
For example, deleting a post hides its lifecycle comments in the same batch. Restoring the post restores that
|
|
257
|
+
batch subtree. Purging first removes the expired comments, then the post.
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
const Post = defineModel('posts', {
|
|
261
|
+
lifecycle: true,
|
|
262
|
+
fields: { id: field.uuid().primaryKey() },
|
|
263
|
+
relations: { comments: hasMany('comments', 'postId', { dependent: 'cascade' }) },
|
|
264
|
+
abilities: [can('manage', { role: 'admin' })],
|
|
265
|
+
});
|
|
266
|
+
const Comment = defineModel('comments', {
|
|
267
|
+
lifecycle: true,
|
|
268
|
+
fields: { id: field.uuid().primaryKey(), postId: field.uuid().references('posts') },
|
|
269
|
+
abilities: [can('manage', { role: 'admin' })],
|
|
270
|
+
});
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The compiler adds nullable `deletedAt` (timestamptz), `deletedBy` (text, no foreign key),
|
|
274
|
+
`deletedBatch` (uuid) and `deletedXid` (bigint). `LIFECYCLE_COLUMNS` names them. They enter the schema plan,
|
|
275
|
+
fingerprint and generated schema. Do not declare these fields yourself, including their snake_case spellings.
|
|
276
|
+
Lifecycle needs a single-column primary key and RLS. Omit `lifecycle` to disable it; `false` is refused.
|
|
277
|
+
Do not combine it with `softDelete`. The boolean remains supported separately; its removal has not shipped.
|
|
278
|
+
|
|
279
|
+
A DELETE stamps these columns and reports zero removed rows. Read roles see only live rows;
|
|
280
|
+
a plain manage role can see deleted rows. The API hides `deletedBy`, `deletedBatch` and `deletedXid` from other roles.
|
|
281
|
+
`deletedAt` stays visible. The generated guard refuses direct writes to the four columns by ordinary callers.
|
|
282
|
+
Only `everystack_lifecycle`, a reserved NOLOGIN role, performs physical deletion. It receives SELECT,
|
|
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.
|
|
285
|
+
|
|
286
|
+
| `dependent` | Lifecycle parent's soft deletion | Physical removal |
|
|
287
|
+
| --- | --- | --- |
|
|
288
|
+
| `restrict` | Refuses while live lifecycle children remain | FK remains RESTRICT; purge waits for all remaining lifecycle children |
|
|
289
|
+
| `cascade` | Stamps lifecycle children in the parent's batch | FK is RESTRICT; purge removes children first |
|
|
290
|
+
| `nullify` | Leaves the child and its link unchanged | SET NULL clears the link at purge |
|
|
291
|
+
|
|
292
|
+
Cascade and restrict from a lifecycle parent require lifecycle children. Nullify requires a nullable FK.
|
|
293
|
+
An actual `ON DELETE CASCADE` into a lifecycle table is refused. Plain-table cascades still remove rows without
|
|
294
|
+
running app hooks. `dependent: 'destroy'` has not shipped.
|
|
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.
|
|
321
|
+
`db:pull` refuses author BEFORE DELETE triggers on lifecycle tables because they can suppress purge DELETE.
|
|
322
|
+
It also refuses TRUNCATE triggers because TRUNCATE bypasses lifecycle row removal.
|
|
323
|
+
|
|
324
|
+
Call `<table>_restore(id)` to restore a batch subtree, or use the API's `POST /<table>/restore { id }`.
|
|
325
|
+
Restore checks the caller's delete rule, not its read policy, because read policies hide deleted rows.
|
|
326
|
+
Missing, live and unauthorized roots all raise the same `42501` permission error.
|
|
327
|
+
A lifecycle delete/manage rule cannot use `via`, a subquery, `current_user`, `session_user` or role `public`.
|
|
328
|
+
Keep predicates to column comparisons and request claims; a schema-qualified helper inside restore runs as the reserved role.
|
|
329
|
+
Restore uses set-based updates, with per-row collision fallback only when uniqueness is declared.
|
|
330
|
+
See [restore limits](../../docs/handler.md#lifecycle-tables) for undeclared unique errors and concurrent restores.
|
|
331
|
+
|
|
332
|
+
The API process is fully trusted. SQL on its connection can rewrite the request claims value.
|
|
333
|
+
`deleted_by` is attribution, not proof. Lifecycle graph operations require READ COMMITTED;
|
|
334
|
+
an earlier snapshot can miss a related row committed since it began.
|
|
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
|
+
|
|
382
|
+
### Purge
|
|
383
|
+
|
|
384
|
+
Import `purgeExpired` and `ENSURE_PURGE_LOG_SQL` from `@everystack/server/purge`.
|
|
385
|
+
On the trusted owner connection, run the ensure statements before the first purge.
|
|
386
|
+
Pass the ops Drizzle database, the full linked model set, and `{ after, batch }`.
|
|
387
|
+
`after` is positive retention in milliseconds; `batch` bounds the rows physically removed across all tables.
|
|
388
|
+
The executor must read those tables and the ledger, and be allowed to SET ROLE to `everystack_lifecycle`
|
|
389
|
+
(membership in that role; no verb grants it yet, so `GRANT everystack_lifecycle TO <ops role>` by hand).
|
|
390
|
+
The DELETE and the ledger INSERT run as `everystack_lifecycle` with that role's own grants. A lockdown that
|
|
391
|
+
revokes DELETE from other roles must leave this role's DELETE on the lifecycle tables alone.
|
|
392
|
+
This is trusted server code, not a client RPC capability.
|
|
393
|
+
|
|
394
|
+
Purge checks the live FK graph against the declaration, then visits lifecycle children before parents.
|
|
395
|
+
A cyclic lifecycle relation aborts the whole purge run before any row is removed.
|
|
396
|
+
Each selected row gets its own READ COMMITTED transaction and lock. The DELETE runs as the reserved role.
|
|
397
|
+
A remaining cascade/restrict lifecycle child blocks its parent; a nullify child clears its FK on the real delete.
|
|
398
|
+
Unsupported live graph paths are reported without invoking hooks or deleting rows.
|
|
399
|
+
Rows removed by `ON DELETE CASCADE` get no individual destroy hook or receipt.
|
|
400
|
+
|
|
401
|
+
`budgetMs` defaults to 30000 and is checked between rows; a current transaction finishes.
|
|
402
|
+
`retryAfterMs` defaults to one hour for skipped/failed keys; zero disables it.
|
|
403
|
+
Skipped and failed rows do not consume `batch`. The result has per-table counts, skip reasons,
|
|
404
|
+
`noHook` for removed tables without destroy hooks, and `incomplete` when the time budget ends the run.
|
|
405
|
+
`afterDestroyErrors` counts after-hook failures; `afterDestroyFailures` records the first message per table/key.
|
|
406
|
+
The optional `onError` sink receives each after-hook fault; a throwing sink does not stop the purge.
|
|
407
|
+
|
|
408
|
+
`everystack.purge_log` stores the run id, table/key, batch, deletion and attempt times, actor, outcome,
|
|
409
|
+
before-hook status and optional detail. A purged receipt commits with its DELETE.
|
|
410
|
+
A failed attempt records failure after rolling back its savepoint, while retaining the row lock.
|
|
411
|
+
The ledger grants INSERT only to the reserved role; guards refuse UPDATE, DELETE and TRUNCATE.
|
|
412
|
+
It contains no row payload. Its owner can disable guards, so it is not evidence against that trusted owner.
|
|
413
|
+
`db:doctor --purge-after <days>` counts overdue rows without hooks or writes; without that flag it skips the count.
|
|
414
|
+
The example's account-erasure cron remains paused.
|
|
415
|
+
|
|
416
|
+
`hooks.beforeDestroy(row, ctx)` and `hooks.afterDestroy(row, ctx)` describe physical removal.
|
|
417
|
+
`beforeDestroy` runs only for rows whose DELETE succeeded at statement level, inside the transaction with the removed row's data.
|
|
418
|
+
A throw cancels that removal and is a failure (a `failed` receipt, retry cooldown). `return veto(reason)` cancels it as a
|
|
419
|
+
decision: a `skipped` receipt with detail `held: <reason>` and no cooldown, so the next run asks again.
|
|
420
|
+
A successful beforeDestroy can repeat if later work rolls back the removal or commit fails.
|
|
421
|
+
Purge checks its reserved-role DELETE and deferred constraints before the hook, which runs under the original caller's role.
|
|
422
|
+
Purge passes `{ phase: 'destroy', source: 'purge', batch: <deleted_batch>, user: null, client: null }`; hooks that authorize on `ctx.user` must handle that null user.
|
|
423
|
+
The API passes `{ phase: 'destroy', source: 'api', batch: null, user, client }` after a hard DELETE returns the authorized rows, before commit.
|
|
424
|
+
`veto`, `isVeto`, `HookVeto` and `LifecycleContext` are exported here and from `@everystack/server/purge`. All lifecycle
|
|
425
|
+
hooks (`beforeStamp`, `afterStamp`, `afterRestore`, the destroy pair) are in [Extending lifecycle](../../docs/handler.md#extending-lifecycle).
|
|
426
|
+
A driver without transactions refuses API hard DELETE on a table with a destroy hook: `db.transaction is not a function`.
|
|
427
|
+
`afterDestroy` runs once per removed row after commit, best-effort; process death in between can prevent delivery.
|
|
428
|
+
Irreversible external effects belong in `afterDestroy`. Make them idempotent; no transaction is passed to either hook.
|
|
429
|
+
Existing `beforeDelete` and `afterDelete` remain API request hooks, including on lifecycle soft deletion.
|
|
430
|
+
|
|
431
|
+
### Display options: `orderBy`, `limit`, `where`, and `hasOne`
|
|
432
|
+
|
|
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.
|
|
434
|
+
|
|
435
|
+
```typescript
|
|
436
|
+
const Post = defineModel('posts', {
|
|
437
|
+
fields: { id: field.uuid().primaryKey() },
|
|
438
|
+
relations: {
|
|
439
|
+
recent: hasMany('comments', 'postId', { orderBy: [{ field: 'createdAt', order: 'desc' }], limit: 5, where: { approved: true } }),
|
|
440
|
+
pinned: hasOne('comments', 'postId', { where: { pinned: true } }),
|
|
169
441
|
},
|
|
170
442
|
});
|
|
443
|
+
// After linkModels:
|
|
444
|
+
// recent.orderBy = [{ field: 'createdAt', order: 'desc' }, { field: 'id', order: 'asc' }]
|
|
445
|
+
// pinned.orderBy = [{ field: 'id', order: 'asc' }]
|
|
171
446
|
```
|
|
172
447
|
|
|
448
|
+
- **`orderBy`** names fields of the TARGET model. `linkModels` checks them and normalizes the list once: it appends the target's primary key, ascending, as the tie-breaker (every key column, for a composite key), so two rows never tie. A key column you list keeps its place and direction and is not added again. A `hasMany` with no `orderBy` gets no order, as before.
|
|
449
|
+
- **`limit`** is a positive integer. `hasOne` takes none: it is one row.
|
|
450
|
+
- **`where`** is an equality map over target fields, the same semantics as the admin `ResourceConfig.where`: equality only, and `null` means `IS NULL`. Values are a string, a finite number, a boolean or `null`. It is a display filter, **not access control**: what a role may read is its abilities (RLS).
|
|
451
|
+
- **`hasOne`** is the first target row whose `column` points back at this row, in `orderBy`. With no `orderBy` that is the target's primary key, ascending; an explicit `orderBy` gets the key appended. It takes no `dependent` (declare it on a `hasMany` over the same column) and no `limit`.
|
|
452
|
+
- Errors name the relation. The shape (an empty `orderBy`, a direction other than `'asc'`/`'desc'`, a field listed twice, a `limit` of `0`, a non-scalar `where` value) is refused at `defineModel`. A field the target does not have is refused by `linkModels` (`orderBy field 'craetedAt' is not a field of model 'comments'`), as is an `orderBy` or `hasOne` over a target with no primary key.
|
|
453
|
+
- In the generated `schema.generated.ts` a `hasOne` is `one(target)`: the inverse of the target's `belongsTo`, as `many(target)` is for a `hasMany`. The target declares that `belongsTo`. A `hasOne` between two tables joined by more than one foreign key is refused by `db:generate` for now: drizzle cannot pair a `one()` by name alone.
|
|
454
|
+
- **Drizzle `with` ignores the display options.** Drizzle relations carry no order, limit or filter, so your own `db.query.posts.findMany({ with: { recent: true } })` returns every comment, unordered, and `with: { pinned: true }` returns any one of the post's comments, pinned or not. In your own queries put the filter, order and limit in the query (`with: { comments: { where, orderBy, limit } }`). The API handler does this for you (next bullet). The generated schema marks each such relation with a one-line comment; a relation that declares none gets no comment.
|
|
455
|
+
- **The API handler applies `orderBy`, `limit` and `where` to a `hasMany` embed, per parent.** `GET /posts?select=*,recent(*)` returns each post's own five newest approved comments, on both read paths (see `docs/handler.md`, `relations`). `deriveHandlerConfig` hands them over in schema-property terms. A `hasOne` is handed over as `hasOne: true` with its normalized `orderBy` and its `where`, and embeds as one object or `null` (`GET /posts?select=*,pinned(*)`). The database chooses the row by that order, so it is never an arbitrary one.
|
|
456
|
+
|
|
173
457
|
## Compile
|
|
174
458
|
|
|
175
459
|
`@everystack/model` is the declaration surface; `@everystack/cli` is the compiler. With your `models` array:
|
|
@@ -202,12 +486,13 @@ The same compiler the CLI uses is importable for your own CI and tests — point
|
|
|
202
486
|
```typescript
|
|
203
487
|
import {
|
|
204
488
|
compileMigration, generateMigrationSql, introspectSchema, diffSchema, // data layer
|
|
489
|
+
compileDeclaredState, // the declared state of the set
|
|
205
490
|
compileTableContract, introspectContract, diffContracts, // authz layer
|
|
206
491
|
buildOwnerProbeSql, evaluateOwnerProbe, // owner-isolation red-team
|
|
207
492
|
} from '@everystack/cli/model';
|
|
208
493
|
```
|
|
209
494
|
|
|
210
|
-
- **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.
|
|
211
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`).
|
|
212
497
|
- **Red-team** — `buildOwnerProbeSql` / `evaluateOwnerProbe` drive two JWT identities per owner-scoped table to catch IDOR (`db:authz:owner`).
|
|
213
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/constraints.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import type { RawSql } from './raw-sql';
|
|
14
14
|
import type { ReferentialAction } from './field';
|
|
15
|
+
import { externalTableRef, type ExternalTableRef } from './external';
|
|
15
16
|
|
|
16
17
|
export type TableConstraint =
|
|
17
18
|
/** `UNIQUE (a, b)` over two or more field keys — the composite the field builder can't carry. */
|
|
@@ -28,7 +29,15 @@ export type TableConstraint =
|
|
|
28
29
|
* `.references()` can't carry. Local field keys, a model thunk for the target, and the target's
|
|
29
30
|
* field keys; optional `onDelete`/`onUpdate` referential actions.
|
|
30
31
|
*/
|
|
31
|
-
| { kind: 'foreignKey'; columns: string[];
|
|
32
|
+
| { kind: 'foreignKey'; columns: string[];
|
|
33
|
+
/** The target model thunk. Absent exactly when {@link external} is set. */
|
|
34
|
+
references?: () => unknown;
|
|
35
|
+
/**
|
|
36
|
+
* The target as a table NO model declares: `foreignKey(cols, { schema, table }, refColumns)`.
|
|
37
|
+
* Then `refColumns` are PostgreSQL column names, taken verbatim. See external.ts.
|
|
38
|
+
*/
|
|
39
|
+
external?: ExternalTableRef;
|
|
40
|
+
refColumns: string[]; onDelete?: ReferentialAction; onUpdate?: ReferentialAction; constraintName?: string;
|
|
32
41
|
/** `validate: false` — see {@link foreignKey}. Stored only when FALSE; the default
|
|
33
42
|
* (validate against the rows already there) is the absence of the field. */
|
|
34
43
|
validate?: boolean };
|
|
@@ -100,6 +109,11 @@ export function assertCheckNames(table: string, constraints: readonly TableConst
|
|
|
100
109
|
* target columns are explicit (a composite FK references a composite key, not just the PK), and
|
|
101
110
|
* the referential actions ride here the same way they ride on `.references()`.
|
|
102
111
|
*
|
|
112
|
+
* A table no model declares is named as an object, the composite twin of the external
|
|
113
|
+
* `.references({ schema, table, column })`: `foreignKey(['orgId', 'teamId'], { schema: 'auth',
|
|
114
|
+
* table: 'teams' }, ['org_id', 'team_id'])`. The reference columns are then PostgreSQL column
|
|
115
|
+
* names, verbatim (there is no model whose field keys they could be).
|
|
116
|
+
*
|
|
103
117
|
* `validate: false` means the same thing it means on `.references()` — "enforce forward,
|
|
104
118
|
* grandfather legacy": the key is installed `NOT VALID` and is never asked to prove itself
|
|
105
119
|
* against the rows that were already there. See {@link FieldBuilder.references} for the whole
|
|
@@ -107,14 +121,18 @@ export function assertCheckNames(table: string, constraints: readonly TableConst
|
|
|
107
121
|
*/
|
|
108
122
|
export function foreignKey(
|
|
109
123
|
columns: string[],
|
|
110
|
-
references: () => unknown,
|
|
124
|
+
references: (() => unknown) | ExternalTableRef,
|
|
111
125
|
refColumns: string[],
|
|
112
126
|
opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction; name?: string; validate?: boolean },
|
|
113
127
|
): TableConstraint {
|
|
128
|
+
const call = `foreignKey(${columns.join(', ')})`;
|
|
129
|
+
if (typeof references !== 'function' && (references === null || typeof references !== 'object')) {
|
|
130
|
+
throw new Error(`${call}: the target is ${references === null ? 'null' : typeof references}; pass a model thunk or { schema, table }`);
|
|
131
|
+
}
|
|
114
132
|
return {
|
|
115
133
|
kind: 'foreignKey',
|
|
116
134
|
columns,
|
|
117
|
-
references,
|
|
135
|
+
...(typeof references === 'function' ? { references } : { external: externalTableRef(references, refColumns, call) }),
|
|
118
136
|
refColumns,
|
|
119
137
|
...(opts?.onDelete ? { onDelete: opts.onDelete } : {}),
|
|
120
138
|
...(opts?.onUpdate ? { onUpdate: opts.onUpdate } : {}),
|