@everystack/model 0.4.22 → 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 CHANGED
@@ -4,6 +4,44 @@ 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
+
7
45
  ## [0.4.22] - 2026-10-16
8
46
 
9
47
  ### 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 })`, `.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).
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,202 @@ 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' }] }),
169
188
  },
170
189
  });
171
190
  ```
172
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 } }),
360
+ },
361
+ });
362
+ // After linkModels:
363
+ // recent.orderBy = [{ field: 'createdAt', order: 'desc' }, { field: 'id', order: 'asc' }]
364
+ // pinned.orderBy = [{ field: 'id', order: 'asc' }]
365
+ ```
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
+
173
376
  ## Compile
174
377
 
175
378
  `@everystack/model` is the declaration surface; `@everystack/cli` is the compiler. With your `models` array:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/model",
3
- "version": "0.4.22",
3
+ "version": "0.4.23",
4
4
  "description": "Declare a table's schema, validation, and authorization once — compiled to migrations, RLS, and handler config",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -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[]; references: () => unknown; refColumns: string[]; onDelete?: ReferentialAction; onUpdate?: ReferentialAction; constraintName?: 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 } : {}),
@@ -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
+ }
package/src/derive.ts CHANGED
@@ -23,15 +23,24 @@ export interface RelationConfig {
23
23
  from: string;
24
24
  to: string;
25
25
  many?: boolean;
26
+ /** To-one from the parent side: the first target row in `orderBy` order, or null. */
27
+ hasOne?: true;
28
+ orderBy?: Array<{ column: string; order: 'asc' | 'desc' }>;
29
+ limit?: number;
30
+ where?: Record<string, string | number | boolean | null>;
26
31
  }
27
32
 
28
33
  export interface DerivedHandlerConfig {
29
34
  exposedTables: string[];
30
35
  hiddenColumns: Record<string, string[]>;
36
+ /** Plain manage roles that may read lifecycle metadata; private fields stay hidden. */
37
+ hiddenColumnsExceptRoles?: Record<string, string[]>;
31
38
  protectedFields: Record<string, string[]>;
32
39
  /** `exceptRoles` is present only when a `can('manage', { role })` exists for the table. */
33
40
  rowOwnership: Record<string, { column: string; userField: string; exceptRoles?: string[] }>;
34
- softDelete: { tables: string[] };
41
+ /** `lifecycle` (present only when a lifecycle model exists) lists the `lifecycle: true` tables;
42
+ * each is also in `tables`, so the read filter applies to it. */
43
+ softDelete: { tables: string[]; lifecycle?: string[] };
35
44
  relations: Record<string, Record<string, RelationConfig>>;
36
45
  hooks: Record<string, ModelHooks>;
37
46
  }
@@ -50,9 +59,11 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
50
59
  linkModels(models);
51
60
  const exposedTables: string[] = [];
52
61
  const hiddenColumns: Record<string, string[]> = {};
62
+ const hiddenColumnsExceptRoles: Record<string, string[]> = {};
53
63
  const protectedFields: Record<string, string[]> = {};
54
64
  const rowOwnership: DerivedHandlerConfig['rowOwnership'] = {};
55
65
  const softDeleteTables: string[] = [];
66
+ const lifecycleTables: string[] = [];
56
67
  const relations: Record<string, Record<string, RelationConfig>> = {};
57
68
  const hooks: Record<string, ModelHooks> = {};
58
69
 
@@ -79,6 +90,13 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
79
90
  // The first owner-shaped ability → the handler's rowOwnership IDOR backstop.
80
91
  // (RLS, compiled from the same ability in Phase 1, is the primary enforcement;
81
92
  // this is the HTTP-path guard. A model's owner abilities share one column.)
93
+ const exceptRoles = [
94
+ ...new Set(
95
+ model.abilities
96
+ .filter((a) => a.action === 'manage' && isPlainRole(a.condition))
97
+ .map((a) => a.condition.role as string),
98
+ ),
99
+ ];
82
100
  const ownerAbility = model.abilities.find((a) => a.condition.owner);
83
101
  if (ownerAbility) {
84
102
  rowOwnership[table] = {
@@ -90,19 +108,20 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
90
108
  // counts: a role-scoped update or delete alone does not lift it, and neither does a
91
109
  // manage ability with any other condition, which narrows what RLS lets it write
92
110
  // (fail closed: a whitelist of `role`, not a blacklist of the rest).
93
- const exceptRoles = [
94
- ...new Set(
95
- model.abilities
96
- .filter((a) => a.action === 'manage' && isPlainRole(a.condition))
97
- .map((a) => a.condition.role as string),
98
- ),
99
- ];
100
111
  if (exceptRoles.length) rowOwnership[table].exceptRoles = exceptRoles;
101
112
  }
102
113
 
103
114
  // Declared, not inferred — one `softDelete` decision drives both the API filter and the
104
115
  // RLS guard, so the two can never disagree about what a soft-deleted row is.
105
116
  if (model.softDelete) softDeleteTables.push(table);
117
+ // A lifecycle table soft-deletes in the database (its delete trigger); the handler reads it as
118
+ // a soft-delete table and drives that machinery instead of writing deleted_at itself.
119
+ if (model.lifecycle) {
120
+ softDeleteTables.push(table);
121
+ lifecycleTables.push(table);
122
+ hiddenColumns[table] = [...new Set([...(hiddenColumns[table] ?? []), 'deletedBy', 'deletedBatch', 'deletedXid'])];
123
+ hiddenColumnsExceptRoles[table] = exceptRoles;
124
+ }
106
125
 
107
126
  if (model.hooks) hooks[table] = model.hooks;
108
127
 
@@ -112,7 +131,18 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
112
131
  for (const name of relationNames) {
113
132
  const rel = model.relations[name];
114
133
  const target = rel.target();
115
- if (rel.kind === 'belongsTo') {
134
+ if (rel.kind === 'hasOne') {
135
+ // this.<pk> → target.<column>, one row. linkModels normalized the orderBy: it always
136
+ // ends with the target's key, so the handler chooses the same row every time.
137
+ resolved[name] = {
138
+ table: target.table,
139
+ from: rel.references ?? primaryKeyOf(model),
140
+ to: rel.column,
141
+ hasOne: true,
142
+ orderBy: (rel.orderBy ?? []).map((t) => ({ column: t.field, order: t.order })),
143
+ };
144
+ if (rel.where) resolved[name].where = { ...rel.where };
145
+ } else if (rel.kind === 'belongsTo') {
116
146
  // this.<column> → target.<pk>
117
147
  resolved[name] = {
118
148
  table: target.table,
@@ -127,18 +157,23 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
127
157
  to: rel.column,
128
158
  many: true,
129
159
  };
160
+ // Model field names are the schema property names the handler resolves.
161
+ if (rel.orderBy) resolved[name].orderBy = rel.orderBy.map((t) => ({ column: t.field, order: t.order }));
162
+ if (rel.limit !== undefined) resolved[name].limit = rel.limit;
163
+ if (rel.where) resolved[name].where = { ...rel.where };
130
164
  }
131
165
  }
132
- relations[table] = resolved;
166
+ if (Object.keys(resolved).length) relations[table] = resolved;
133
167
  }
134
168
  }
135
169
 
136
170
  return {
137
171
  exposedTables,
138
172
  hiddenColumns,
173
+ ...(lifecycleTables.length ? { hiddenColumnsExceptRoles } : {}),
139
174
  protectedFields,
140
175
  rowOwnership,
141
- softDelete: { tables: softDeleteTables },
176
+ softDelete: { tables: softDeleteTables, ...(lifecycleTables.length ? { lifecycle: lifecycleTables } : {}) },
142
177
  relations,
143
178
  hooks,
144
179
  };