@everystack/model 0.4.21 → 0.4.23
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 +48 -6
- package/README.md +207 -2
- package/package.json +1 -1
- package/src/constraints.ts +93 -9
- package/src/dependents.ts +168 -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 -6
- package/src/link.ts +40 -0
- package/src/model.ts +231 -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,54 @@ 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.23] - 2026-10-23
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- external FK target, .references({ schema, table, column })
|
|
12
|
+
- F7a relation options; hasMany dependent sets the child FK ON DELETE
|
|
13
|
+
- relation display options and hasOne (F7b)
|
|
14
|
+
- per-parent embed order, limit and where on both read paths (F10)
|
|
15
|
+
- hasOne one-row embed on both read paths (P2)
|
|
16
|
+
- hasMany dependent 'cascade' is ON DELETE CASCADE on the child's foreign key (D1)
|
|
17
|
+
- lifecycle: true declares four generated columns (L1.1)
|
|
18
|
+
- lifecycle gets a reserved owner role and hides deleted rows from read roles (L1.2a)
|
|
19
|
+
- lifecycle triggers turn DELETE into a soft delete (L1.2b)
|
|
20
|
+
- lifecycle restore, for whoever may delete the row (L1.3)
|
|
21
|
+
- a lifecycle parent's delete soft-deletes its cascade children in the same batch (L2 + L6, P1.2)
|
|
22
|
+
- the handler deletes, hides and restores lifecycle rows through the database (H1, P1.4)
|
|
23
|
+
- db:provision creates the reserved lifecycle role (P2.7)
|
|
24
|
+
- lifecycle columns are hidden from callers that cannot manage the table
|
|
25
|
+
- beforeDestroy and afterDestroy hooks at a real removal
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- F3e review minors: catalog FK target, one target slot, derived refusal
|
|
30
|
+
- F7a review: derive dependent ON DELETE per set, never write a field spec
|
|
31
|
+
- F7b review: mark display relations in drizzle, withhold only where/limit
|
|
32
|
+
- a beforeDestroy HandlerError renders with its own status
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- break the link.ts <-> relations.ts import cycle (F7b)
|
|
37
|
+
|
|
38
|
+
### Documentation
|
|
39
|
+
|
|
40
|
+
- name the one runtime-vs-generated Drizzle difference a parent dependent creates
|
|
41
|
+
- handler relation orderBy/limit/where; the API handler applies the display options (F11)
|
|
42
|
+
- lifecycle, dependents, hooks at the real removal, purge
|
|
43
|
+
- define destroy timing and verification limits
|
|
44
|
+
|
|
45
|
+
## [0.4.22] - 2026-10-16
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- check() takes an explicit constraint name; db:pull keeps a live name
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- db:apply runs a rename-only plan
|
|
54
|
+
|
|
7
55
|
## [0.4.21] - 2026-10-14
|
|
8
56
|
|
|
9
57
|
### Added
|
|
@@ -20,12 +68,6 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
20
68
|
- rowOwnership exceptRoles lifts the backstop for manage roles
|
|
21
69
|
- only a plain manage role lifts the rowOwnership backstop
|
|
22
70
|
|
|
23
|
-
## [Unreleased]
|
|
24
|
-
|
|
25
|
-
### Added
|
|
26
|
-
|
|
27
|
-
- apply state prerequisites atomically
|
|
28
|
-
|
|
29
71
|
## [0.4.20] - 2026-09-26
|
|
30
72
|
|
|
31
73
|
### BREAKING CHANGES
|
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,26 @@ 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
|
+
|
|
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.
|
|
109
|
+
|
|
90
110
|
## Authorization
|
|
91
111
|
|
|
92
112
|
`can(action, condition)` declares one rule (default-deny: no matching `can()` means denied). Conditions:
|
|
@@ -157,17 +177,202 @@ flag explicitly for the same reason.
|
|
|
157
177
|
## Relations
|
|
158
178
|
|
|
159
179
|
```typescript
|
|
160
|
-
import { belongsTo, hasMany } from '@everystack/model';
|
|
180
|
+
import { belongsTo, hasMany, hasOne } from '@everystack/model';
|
|
161
181
|
|
|
162
182
|
defineModel('notes', {
|
|
163
183
|
fields: { /* … */ },
|
|
164
184
|
relations: {
|
|
165
185
|
account: belongsTo(() => Account, 'accountId'),
|
|
166
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
|
+
```typescript
|
|
223
|
+
const Post = defineModel('posts', {
|
|
224
|
+
fields: { id: field.uuid().primaryKey() },
|
|
225
|
+
relations: { comments: hasMany('comments', 'postId', { dependent: 'nullify' }) },
|
|
226
|
+
});
|
|
227
|
+
const Comment = defineModel('comments', {
|
|
228
|
+
fields: { id: field.uuid().primaryKey(), postId: field.uuid().references('posts') },
|
|
229
|
+
});
|
|
230
|
+
// db:plan: comments_post_id_posts_id_fk is re-created ON DELETE SET NULL.
|
|
231
|
+
// Deleting a post sets its comments' post_id to NULL.
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
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:
|
|
235
|
+
|
|
236
|
+
- **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`.
|
|
237
|
+
- **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.
|
|
238
|
+
- **`'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.
|
|
239
|
+
- 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.
|
|
240
|
+
- **`'nullify'` needs a nullable column.** On a `NOT NULL` field it is a compile error.
|
|
241
|
+
- It applies to the one foreign key on `column`. Another foreign key from the same child to the same parent is untouched.
|
|
242
|
+
- 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.
|
|
243
|
+
- **`'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.
|
|
244
|
+
- **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`.
|
|
245
|
+
- Any other value (for example `'softDelete'`) is refused by name.
|
|
246
|
+
- `belongsTo` takes no `dependent`. A `hasMany` with no `dependent` is display only and compiles exactly as before.
|
|
247
|
+
|
|
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.
|
|
249
|
+
|
|
250
|
+
### Lifecycle
|
|
251
|
+
|
|
252
|
+
`lifecycle: true` declares deletion in two steps: hide the row now, remove it later with an explicit purge.
|
|
253
|
+
For example, deleting a post hides its lifecycle comments in the same batch. Restoring the post restores that
|
|
254
|
+
batch subtree. Purging first removes the expired comments, then the post.
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
const Post = defineModel('posts', {
|
|
258
|
+
lifecycle: true,
|
|
259
|
+
fields: { id: field.uuid().primaryKey() },
|
|
260
|
+
relations: { comments: hasMany('comments', 'postId', { dependent: 'cascade' }) },
|
|
261
|
+
abilities: [can('manage', { role: 'admin' })],
|
|
262
|
+
});
|
|
263
|
+
const Comment = defineModel('comments', {
|
|
264
|
+
lifecycle: true,
|
|
265
|
+
fields: { id: field.uuid().primaryKey(), postId: field.uuid().references('posts') },
|
|
266
|
+
abilities: [can('manage', { role: 'admin' })],
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
The compiler adds nullable `deletedAt` (timestamptz), `deletedBy` (text, no foreign key),
|
|
271
|
+
`deletedBatch` (uuid) and `deletedXid` (bigint). `LIFECYCLE_COLUMNS` names them. They enter the schema plan,
|
|
272
|
+
fingerprint and generated schema. Do not declare these fields yourself, including their snake_case spellings.
|
|
273
|
+
Lifecycle needs a single-column primary key and RLS. Omit `lifecycle` to disable it; `false` is refused.
|
|
274
|
+
Do not combine it with `softDelete`. The boolean remains supported separately; its removal has not shipped.
|
|
275
|
+
|
|
276
|
+
A DELETE stamps these columns and reports zero removed rows. Read roles see only live rows;
|
|
277
|
+
a plain manage role can see deleted rows. The API hides `deletedBy`, `deletedBatch` and `deletedXid` from other roles.
|
|
278
|
+
`deletedAt` stays visible. The generated guard refuses direct writes to the four columns by ordinary callers.
|
|
279
|
+
Only `everystack_lifecycle`, a reserved NOLOGIN role, performs physical deletion. It receives SELECT,
|
|
280
|
+
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.
|
|
282
|
+
|
|
283
|
+
| `dependent` | Lifecycle parent's soft deletion | Physical removal |
|
|
284
|
+
| --- | --- | --- |
|
|
285
|
+
| `restrict` | Refuses while live lifecycle children remain | FK remains RESTRICT; purge waits for all remaining lifecycle children |
|
|
286
|
+
| `cascade` | Stamps lifecycle children in the parent's batch | FK is RESTRICT; purge removes children first |
|
|
287
|
+
| `nullify` | Leaves the child and its link unchanged | SET NULL clears the link at purge |
|
|
288
|
+
|
|
289
|
+
Cascade and restrict from a lifecycle parent require lifecycle children. Nullify requires a nullable FK.
|
|
290
|
+
An actual `ON DELETE CASCADE` into a lifecycle table is refused. Plain-table cascades still remove rows without
|
|
291
|
+
running app hooks. `dependent: 'destroy'` has not shipped.
|
|
292
|
+
AFTER DELETE audit triggers are supported; they do not run on a soft deletion.
|
|
293
|
+
`db:pull` refuses author BEFORE DELETE triggers on lifecycle tables because they can suppress purge DELETE.
|
|
294
|
+
It also refuses TRUNCATE triggers because TRUNCATE bypasses lifecycle row removal.
|
|
295
|
+
|
|
296
|
+
Call `<table>_restore(id)` to restore a batch subtree, or use the API's `POST /<table>/restore { id }`.
|
|
297
|
+
Restore checks the caller's delete rule, not its read policy, because read policies hide deleted rows.
|
|
298
|
+
Missing, live and unauthorized roots all raise the same `42501` permission error.
|
|
299
|
+
A lifecycle delete/manage rule cannot use `via`, a subquery, `current_user`, `session_user` or role `public`.
|
|
300
|
+
Keep predicates to column comparisons and request claims; a schema-qualified helper inside restore runs as the reserved role.
|
|
301
|
+
Restore uses set-based updates, with per-row collision fallback only when uniqueness is declared.
|
|
302
|
+
See [restore limits](../../docs/handler.md#lifecycle-tables) for undeclared unique errors and concurrent restores.
|
|
303
|
+
|
|
304
|
+
The API process is fully trusted. SQL on its connection can rewrite the request claims value.
|
|
305
|
+
`deleted_by` is attribution, not proof. Lifecycle graph operations require READ COMMITTED;
|
|
306
|
+
an earlier snapshot can miss a related row committed since it began.
|
|
307
|
+
|
|
308
|
+
### Purge
|
|
309
|
+
|
|
310
|
+
Import `purgeExpired` and `ENSURE_PURGE_LOG_SQL` from `@everystack/server/purge`.
|
|
311
|
+
On the trusted owner connection, run the ensure statements before the first purge.
|
|
312
|
+
Pass the ops Drizzle database, the full linked model set, and `{ after, batch }`.
|
|
313
|
+
`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`.
|
|
315
|
+
This is trusted server code, not a client RPC capability.
|
|
316
|
+
|
|
317
|
+
Purge checks the live FK graph against the declaration, then visits lifecycle children before parents.
|
|
318
|
+
A cyclic lifecycle relation aborts the whole purge run before any row is removed.
|
|
319
|
+
Each selected row gets its own READ COMMITTED transaction and lock. The DELETE runs as the reserved role.
|
|
320
|
+
A remaining cascade/restrict lifecycle child blocks its parent; a nullify child clears its FK on the real delete.
|
|
321
|
+
Unsupported live graph paths are reported without invoking hooks or deleting rows.
|
|
322
|
+
Rows removed by `ON DELETE CASCADE` get no individual destroy hook or receipt.
|
|
323
|
+
|
|
324
|
+
`budgetMs` defaults to 30000 and is checked between rows; a current transaction finishes.
|
|
325
|
+
`retryAfterMs` defaults to one hour for skipped/failed keys; zero disables it.
|
|
326
|
+
Skipped and failed rows do not consume `batch`. The result has per-table counts, skip reasons,
|
|
327
|
+
`noHook` for removed tables without destroy hooks, and `incomplete` when the time budget ends the run.
|
|
328
|
+
`afterDestroyErrors` counts after-hook failures; `afterDestroyFailures` records the first message per table/key.
|
|
329
|
+
The optional `onError` sink receives each after-hook fault; a throwing sink does not stop the purge.
|
|
330
|
+
|
|
331
|
+
`everystack.purge_log` stores the run id, table/key, batch, deletion and attempt times, actor, outcome,
|
|
332
|
+
before-hook status and optional detail. A purged receipt commits with its DELETE.
|
|
333
|
+
A failed attempt records failure after rolling back its savepoint, while retaining the row lock.
|
|
334
|
+
The ledger grants INSERT only to the reserved role; guards refuse UPDATE, DELETE and TRUNCATE.
|
|
335
|
+
It contains no row payload. Its owner can disable guards, so it is not evidence against that trusted owner.
|
|
336
|
+
`db:doctor --purge-after <days>` counts overdue rows without hooks or writes; without that flag it skips the count.
|
|
337
|
+
The example's account-erasure cron remains paused.
|
|
338
|
+
|
|
339
|
+
`hooks.beforeDestroy(row, ctx)` and `hooks.afterDestroy(row, ctx)` describe physical removal.
|
|
340
|
+
`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. A successful beforeDestroy can repeat if later work rolls back the removal or commit fails.
|
|
342
|
+
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.
|
|
345
|
+
A driver without transactions refuses API hard DELETE on a table with a destroy hook: `db.transaction is not a function`.
|
|
346
|
+
`afterDestroy` runs once per removed row after commit, best-effort; process death in between can prevent delivery.
|
|
347
|
+
Irreversible external effects belong in `afterDestroy`. Make them idempotent; no transaction is passed to either hook.
|
|
348
|
+
Existing `beforeDelete` and `afterDelete` remain API request hooks, including on lifecycle soft deletion.
|
|
349
|
+
|
|
350
|
+
### Display options: `orderBy`, `limit`, `where`, and `hasOne`
|
|
351
|
+
|
|
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.
|
|
353
|
+
|
|
354
|
+
```typescript
|
|
355
|
+
const Post = defineModel('posts', {
|
|
356
|
+
fields: { id: field.uuid().primaryKey() },
|
|
357
|
+
relations: {
|
|
358
|
+
recent: hasMany('comments', 'postId', { orderBy: [{ field: 'createdAt', order: 'desc' }], limit: 5, where: { approved: true } }),
|
|
359
|
+
pinned: hasOne('comments', 'postId', { where: { pinned: true } }),
|
|
167
360
|
},
|
|
168
361
|
});
|
|
362
|
+
// After linkModels:
|
|
363
|
+
// recent.orderBy = [{ field: 'createdAt', order: 'desc' }, { field: 'id', order: 'asc' }]
|
|
364
|
+
// pinned.orderBy = [{ field: 'id', order: 'asc' }]
|
|
169
365
|
```
|
|
170
366
|
|
|
367
|
+
- **`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.
|
|
368
|
+
- **`limit`** is a positive integer. `hasOne` takes none: it is one row.
|
|
369
|
+
- **`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).
|
|
370
|
+
- **`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`.
|
|
371
|
+
- 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.
|
|
372
|
+
- 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.
|
|
373
|
+
- **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.
|
|
374
|
+
- **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.
|
|
375
|
+
|
|
171
376
|
## Compile
|
|
172
377
|
|
|
173
378
|
`@everystack/model` is the declaration surface; `@everystack/cli` is the compiler. With your `models` array:
|
package/package.json
CHANGED
package/src/constraints.ts
CHANGED
|
@@ -12,12 +12,16 @@
|
|
|
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. */
|
|
18
19
|
| { kind: 'unique'; columns: string[] }
|
|
19
|
-
/**
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* `CHECK (<predicate>)` — a raw SQL predicate, typically spanning columns. `name` is present
|
|
22
|
+
* only when declared; absent, the compiler names it by position (see {@link positionalCheckName}).
|
|
23
|
+
*/
|
|
24
|
+
| { kind: 'check'; predicate: string; name?: string }
|
|
21
25
|
/** A standalone index — `CREATE [UNIQUE] INDEX [USING <method>] … (keys) [INCLUDE (cols)] [WHERE <predicate>]`. */
|
|
22
26
|
| { kind: 'index'; columns: string[]; isUnique: boolean; predicate?: string; method?: string; includeColumns?: string[]; rawPositions?: boolean[]; indexName?: string }
|
|
23
27
|
/**
|
|
@@ -25,7 +29,15 @@ export type TableConstraint =
|
|
|
25
29
|
* `.references()` can't carry. Local field keys, a model thunk for the target, and the target's
|
|
26
30
|
* field keys; optional `onDelete`/`onUpdate` referential actions.
|
|
27
31
|
*/
|
|
28
|
-
| { 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;
|
|
29
41
|
/** `validate: false` — see {@link foreignKey}. Stored only when FALSE; the default
|
|
30
42
|
* (validate against the rows already there) is the absence of the field. */
|
|
31
43
|
validate?: boolean };
|
|
@@ -35,9 +47,60 @@ export function unique(...columns: string[]): TableConstraint {
|
|
|
35
47
|
return { kind: 'unique', columns };
|
|
36
48
|
}
|
|
37
49
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
50
|
+
/**
|
|
51
|
+
* A table CHECK from a raw predicate: `check(sql\`published_at >= created_at\`)`.
|
|
52
|
+
*
|
|
53
|
+
* `{ name }` declares the name the database calls it: `check(sql\`…\`, { name: 'contents_state_check' })`.
|
|
54
|
+
* Undeclared, the compiler names it by POSITION among the table's unnamed checks
|
|
55
|
+
* (`<table>_check_<n>`), which is fine for a greenfield model and fragile for everything that
|
|
56
|
+
* names a constraint: inserting or reordering an unnamed check renames its siblings. A declared
|
|
57
|
+
* name is used verbatim, does not take a position, and is stable under reordering. Same rule as
|
|
58
|
+
* `index().name()` and `foreignKey({ name })`: the generated name is the fallback.
|
|
59
|
+
*/
|
|
60
|
+
export function check(predicate: RawSql, opts?: { name?: string }): TableConstraint {
|
|
61
|
+
return {
|
|
62
|
+
kind: 'check',
|
|
63
|
+
predicate: predicate.sql,
|
|
64
|
+
...(opts?.name !== undefined ? { name: assertConstraintName(opts.name, `check(sql\`${predicate.sql}\`)`, MAX_IDENTIFIER_BYTES) } : {}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The name an UNNAMED table check gets: `<table>_check_<position>`, where position counts only
|
|
70
|
+
* the unnamed checks before it — a named check never takes a position, so naming one check
|
|
71
|
+
* cannot rename the others. ONE implementation, shared by the compiler, `db:pull` (which declares
|
|
72
|
+
* a live name exactly when it is not this) and {@link assertCheckNames}.
|
|
73
|
+
*/
|
|
74
|
+
export function positionalCheckName(table: string, position: number): string {
|
|
75
|
+
return `${table}_check_${position}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Refuse declared check names that cannot coexist in one table: the same name twice, or a name
|
|
80
|
+
* an unnamed check in the same table gets by position. Run by `defineModel`, the first point
|
|
81
|
+
* that knows the table — the identifier itself was already validated at {@link check}.
|
|
82
|
+
*/
|
|
83
|
+
export function assertCheckNames(table: string, constraints: readonly TableConstraint[]): void {
|
|
84
|
+
const positional = new Set<string>();
|
|
85
|
+
let position = 0;
|
|
86
|
+
for (const c of constraints) {
|
|
87
|
+
if (c.kind === 'check' && c.name === undefined) positional.add(positionalCheckName(table, position++));
|
|
88
|
+
}
|
|
89
|
+
const declared = new Set<string>();
|
|
90
|
+
for (const c of constraints) {
|
|
91
|
+
if (c.kind !== 'check' || c.name === undefined) continue;
|
|
92
|
+
const label = `check(sql\`${c.predicate}\`)`;
|
|
93
|
+
if (declared.has(c.name)) {
|
|
94
|
+
throw new Error(`model '${table}': two checks are named '${c.name}' (the second is ${label}). A constraint name is unique within its table.`);
|
|
95
|
+
}
|
|
96
|
+
if (positional.has(c.name)) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`model '${table}': ${label} is named '${c.name}', which an unnamed check in this table gets by position. `
|
|
99
|
+
+ 'Name that check as well, or choose another name.',
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
declared.add(c.name);
|
|
103
|
+
}
|
|
41
104
|
}
|
|
42
105
|
|
|
43
106
|
/**
|
|
@@ -46,6 +109,11 @@ export function check(predicate: RawSql): TableConstraint {
|
|
|
46
109
|
* target columns are explicit (a composite FK references a composite key, not just the PK), and
|
|
47
110
|
* the referential actions ride here the same way they ride on `.references()`.
|
|
48
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
|
+
*
|
|
49
117
|
* `validate: false` means the same thing it means on `.references()` — "enforce forward,
|
|
50
118
|
* grandfather legacy": the key is installed `NOT VALID` and is never asked to prove itself
|
|
51
119
|
* against the rows that were already there. See {@link FieldBuilder.references} for the whole
|
|
@@ -53,14 +121,18 @@ export function check(predicate: RawSql): TableConstraint {
|
|
|
53
121
|
*/
|
|
54
122
|
export function foreignKey(
|
|
55
123
|
columns: string[],
|
|
56
|
-
references: () => unknown,
|
|
124
|
+
references: (() => unknown) | ExternalTableRef,
|
|
57
125
|
refColumns: string[],
|
|
58
126
|
opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction; name?: string; validate?: boolean },
|
|
59
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
|
+
}
|
|
60
132
|
return {
|
|
61
133
|
kind: 'foreignKey',
|
|
62
134
|
columns,
|
|
63
|
-
references,
|
|
135
|
+
...(typeof references === 'function' ? { references } : { external: externalTableRef(references, refColumns, call) }),
|
|
64
136
|
refColumns,
|
|
65
137
|
...(opts?.onDelete ? { onDelete: opts.onDelete } : {}),
|
|
66
138
|
...(opts?.onUpdate ? { onUpdate: opts.onUpdate } : {}),
|
|
@@ -184,15 +256,27 @@ const INDEX_NAME = /^[a-z_][a-z0-9_$]*$/;
|
|
|
184
256
|
* surface that lets a model name a database object — an index, a foreign key constraint —
|
|
185
257
|
* because two copies of a validation rule is how one of them ends up laxer than the other.
|
|
186
258
|
*/
|
|
187
|
-
export function assertConstraintName(name: string, what = 'constraint'): string {
|
|
259
|
+
export function assertConstraintName(name: string, what = 'constraint', maxBytes?: number): string {
|
|
188
260
|
if (!INDEX_NAME.test(name)) {
|
|
189
261
|
throw new Error(
|
|
190
262
|
`${what} name '${name}' is not a bare lowercase identifier (${INDEX_NAME}). It is interpolated into DDL that takes no parameters, so it is refused here rather than built into SQL.`,
|
|
191
263
|
);
|
|
192
264
|
}
|
|
265
|
+
// PostgreSQL truncates a longer identifier with only a NOTICE, so the database would hold a
|
|
266
|
+
// name the model does not declare, and every later plan would see the difference.
|
|
267
|
+
// Opt-in: index and foreign key names loaded without this limit before check names had it.
|
|
268
|
+
const bytes = new TextEncoder().encode(name).length;
|
|
269
|
+
if (maxBytes !== undefined && bytes > maxBytes) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`${what} name '${name}' is ${bytes} bytes. PostgreSQL identifiers are at most ${maxBytes} bytes and a longer one is truncated silently, so it is refused here.`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
193
274
|
return name;
|
|
194
275
|
}
|
|
195
276
|
|
|
277
|
+
/** NAMEDATALEN - 1 on a stock PostgreSQL build. */
|
|
278
|
+
const MAX_IDENTIFIER_BYTES = 63;
|
|
279
|
+
|
|
196
280
|
export function index(...entries: Array<string | RawSql>): IndexBuilder {
|
|
197
281
|
return new IndexBuilder(entries);
|
|
198
282
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hasMany `dependent` → the ON DELETE of the CHILD's foreign key.
|
|
3
|
+
*
|
|
4
|
+
* `hasMany(() => Comment, 'postId', { dependent: 'nullify' })` on posts says: deleting a post sets
|
|
5
|
+
* comments.post_id to NULL. The foreign key that does it belongs to comments, so the compiler
|
|
6
|
+
* gives the child's FK on `postId` the action `set null`, and a plan shows it as an ordinary FK
|
|
7
|
+
* change.
|
|
8
|
+
*
|
|
9
|
+
* The result is a property of the MODEL SET, never of a field spec. `relationDeleteActions(models)`
|
|
10
|
+
* is a pure function: it returns a map keyed by (child model, field key) and writes nothing. Every
|
|
11
|
+
* FK reader that holds the set consults it. A field builder shared by two children, or a child
|
|
12
|
+
* reused in two sets with different parents, therefore compiles per (set, child), and compiling
|
|
13
|
+
* one set never changes what another compiles to.
|
|
14
|
+
*
|
|
15
|
+
* Ratified rules (F7):
|
|
16
|
+
* - 'restrict' → 'restrict', 'nullify' → 'set null', 'cascade' → 'cascade'.
|
|
17
|
+
* - Lifecycle (P1.2): a lifecycle parent's 'cascade' needs a lifecycle child and compiles to
|
|
18
|
+
* 'restrict' (the soft-delete cascade runs in triggers). Any other cascade into a lifecycle
|
|
19
|
+
* table is refused, and so is a lifecycle parent's 'cascade' or 'restrict' onto a plain child.
|
|
20
|
+
* - Neither side wins. Both may declare; agreeing is fine, disagreeing is an error naming both.
|
|
21
|
+
* - Matched on (target, column): a child's other FKs to the same parent are untouched.
|
|
22
|
+
* - 'nullify' on a NOT NULL child column is an error (the delete would fail).
|
|
23
|
+
* - The child must declare `.references()` to this model. A dependent never creates a foreign
|
|
24
|
+
* key: the child file must show its own constraint.
|
|
25
|
+
*
|
|
26
|
+
* linkModels runs the same checks at load. With no dependent in the set the map is empty and no
|
|
27
|
+
* thunk is called.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { ReferentialAction } from './field';
|
|
31
|
+
import type { ModelDescriptor } from './model';
|
|
32
|
+
import type { HasManyDependent, Relation } from './relations';
|
|
33
|
+
|
|
34
|
+
const ACTION: Record<HasManyDependent, ReferentialAction> = { restrict: 'restrict', nullify: 'set null', cascade: 'cascade' };
|
|
35
|
+
|
|
36
|
+
/** The derived ON DELETE per child model, per field key. Empty when the set declares no dependent. */
|
|
37
|
+
export type DependentActions = ReadonlyMap<ModelDescriptor, ReadonlyMap<string, ReferentialAction>>;
|
|
38
|
+
|
|
39
|
+
const nameOf = (m: ModelDescriptor): string => (!m.schema || m.schema === 'public' ? m.table : `${m.schema}.${m.table}`);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Why an ON DELETE CASCADE into a lifecycle table is refused, and the fix. One text for every
|
|
43
|
+
* declaration site (a parent's hasMany, the child's own onDelete), so the reason cannot drift.
|
|
44
|
+
*/
|
|
45
|
+
export function lifecycleCascadeHarm(where: string): string {
|
|
46
|
+
return (
|
|
47
|
+
`ON DELETE CASCADE into a lifecycle table is refused. The cascaded delete is turned into a soft delete, `
|
|
48
|
+
+ `so the parent row is removed and ${where} still points at it. `
|
|
49
|
+
+ `Use 'restrict' or 'nullify' (onDelete 'restrict' or 'set null') instead; `
|
|
50
|
+
+ `to soft-delete the children with their parent, make the parent lifecycle: true and declare hasMany { dependent: 'cascade' } on it`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate every dependent in the set and return the actions it implies. Pure: nothing is written.
|
|
56
|
+
* `targetOf` resolves a thunk; linkModels passes one that knows the names it has resolved but not
|
|
57
|
+
* yet bound, so an error leaves nothing half-linked.
|
|
58
|
+
*/
|
|
59
|
+
export function relationDeleteActions(
|
|
60
|
+
models: readonly ModelDescriptor[],
|
|
61
|
+
targetOf: (thunk: () => unknown) => ModelDescriptor = (thunk) => thunk() as ModelDescriptor,
|
|
62
|
+
): DependentActions {
|
|
63
|
+
// Keyed by (child, field key); `site`/`dependent` name the relation for a later disagreement.
|
|
64
|
+
const planned = new Map<ModelDescriptor, Map<string, { onDelete: ReferentialAction; site: string; dependent: string }>>();
|
|
65
|
+
for (const parent of new Set(models)) {
|
|
66
|
+
for (const [name, rel] of Object.entries(parent.relations) as Array<[string, Relation]>) {
|
|
67
|
+
if (rel.dependent === undefined) continue;
|
|
68
|
+
const dependent = rel.dependent;
|
|
69
|
+
const site = `model '${nameOf(parent)}': relation '${name}'`;
|
|
70
|
+
const at = `${site} declares dependent '${dependent}'`;
|
|
71
|
+
if (rel.kind !== 'hasMany' || !Object.hasOwn(ACTION, dependent)) {
|
|
72
|
+
// defineModel refuses these shapes; a hand-edited descriptor still never compiles.
|
|
73
|
+
throw new Error(`${at}, which is not a hasMany 'restrict', 'nullify' or 'cascade'`);
|
|
74
|
+
}
|
|
75
|
+
const child = targetOf(rel.target);
|
|
76
|
+
const where = `${nameOf(child)}.${rel.column}`;
|
|
77
|
+
const f = child.fields[rel.column];
|
|
78
|
+
if (!f) throw new Error(`${at}, but model '${nameOf(child)}' has no field '${rel.column}'`);
|
|
79
|
+
const spec = f.spec;
|
|
80
|
+
|
|
81
|
+
if (!spec.references) {
|
|
82
|
+
if (spec.referencesExternal) {
|
|
83
|
+
const ext = spec.referencesExternal;
|
|
84
|
+
throw new Error(
|
|
85
|
+
`${at}, but ${where} references '${ext.schema}.${ext.table}', not '${nameOf(parent)}': `
|
|
86
|
+
+ `declare .references() on ${where} to this model (dependent never creates a foreign key)`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const covering = child.constraints.find((c) => c.kind === 'foreignKey' && c.columns.includes(rel.column));
|
|
90
|
+
if (covering && covering.kind === 'foreignKey') {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`${at}, but ${where} is covered by a table-level foreignKey(${covering.columns.join(', ')}): `
|
|
93
|
+
+ 'dependent applies only to a field .references(); declare onDelete on that foreignKey(...) instead',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
throw new Error(
|
|
97
|
+
`${at}, but ${where} has no foreign key to '${nameOf(parent)}': `
|
|
98
|
+
+ `declare .references() on ${where} (dependent never creates a foreign key)`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
const referenced = targetOf(spec.references);
|
|
102
|
+
if (referenced !== parent) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`${at}, but ${where} references '${nameOf(referenced)}', not '${nameOf(parent)}': `
|
|
105
|
+
+ `declare .references() on ${where} to this model (dependent never creates a foreign key)`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const pk = parent.primaryKey[0] ?? 'id';
|
|
109
|
+
if (rel.references !== undefined && rel.references !== pk) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`${at} with references '${rel.references}', but ${where}'s foreign key points at the primary key '${pk}': `
|
|
112
|
+
+ 'dependent needs the relation to join on that key',
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if (dependent === 'nullify' && spec.isNotNull) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`${at}, but ${where} is NOT NULL: ON DELETE SET NULL would fail on every delete; `
|
|
118
|
+
+ "make the field nullable or use dependent 'restrict'",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (dependent === 'cascade' && child.lifecycle && !parent.lifecycle) {
|
|
123
|
+
throw new Error(`${at}, but ${where} is in a lifecycle table: ${lifecycleCascadeHarm(where)}`);
|
|
124
|
+
}
|
|
125
|
+
if (dependent === 'cascade' && parent.lifecycle && !child.lifecycle) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`${at}, but ${where} is not in a lifecycle table. Deleting a lifecycle row is a soft delete, so the parent `
|
|
128
|
+
+ `row stays and its foreign key never fires: ${where} would stay live under a deleted parent. `
|
|
129
|
+
+ `Make '${nameOf(child)}' lifecycle: true (its rows are then soft-deleted with the parent), `
|
|
130
|
+
+ `or use dependent 'restrict' or 'nullify'`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (dependent === 'restrict' && parent.lifecycle && !child.lifecycle) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`${at}, but ${where} is not in a lifecycle table. Deleting a lifecycle row is a soft delete, so the foreign key `
|
|
137
|
+
+ `never fires; the restrict check runs in a trigger as the lifecycle role, which holds no rights on a plain table. `
|
|
138
|
+
+ `Make '${nameOf(child)}' lifecycle: true, or use dependent 'nullify'`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// A lifecycle parent soft-deletes its lifecycle 'cascade' children through triggers
|
|
143
|
+
// (lifecycle-derived.ts), never through the foreign key: its DELETE never removes the row,
|
|
144
|
+
// and an RI cascade into a lifecycle table would leave the child pointing at a removed
|
|
145
|
+
// parent once one is purged. So that foreign key is RESTRICT; the relation keeps 'cascade'.
|
|
146
|
+
const onDelete: ReferentialAction = dependent === 'cascade' && parent.lifecycle ? 'restrict' : ACTION[dependent];
|
|
147
|
+
const forChild = planned.get(child) ?? new Map();
|
|
148
|
+
const earlier = forChild.get(rel.column);
|
|
149
|
+
if (earlier && earlier.onDelete !== onDelete) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`${at} (ON DELETE ${onDelete.toUpperCase()}), but ${earlier.site} declares dependent '${earlier.dependent}' `
|
|
152
|
+
+ `for the same foreign key ${where}: they disagree`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (spec.onDelete !== undefined && spec.onDelete !== onDelete) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`${at} (ON DELETE ${onDelete.toUpperCase()}), but model '${nameOf(child)}': field '${rel.column}' `
|
|
158
|
+
+ `declares onDelete '${spec.onDelete}': they disagree; declare one, or make them agree`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (!earlier) forChild.set(rel.column, { onDelete, site, dependent });
|
|
162
|
+
planned.set(child, forChild);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const out = new Map<ModelDescriptor, ReadonlyMap<string, ReferentialAction>>();
|
|
166
|
+
for (const [child, fields] of planned) out.set(child, new Map([...fields].map(([k, v]) => [k, v.onDelete])));
|
|
167
|
+
return out;
|
|
168
|
+
}
|