@everystack/model 0.4.26 → 0.4.27
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 +15 -0
- package/README.md +9 -43
- package/package.json +1 -1
- package/src/can.ts +0 -44
- package/src/derive.ts +6 -10
- package/src/index.ts +2 -2
- package/src/legacy-soft-delete.ts +19 -0
- package/src/link.ts +2 -0
- package/src/model.ts +24 -88
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,21 @@ 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.27] - 2026-11-04
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
- softDelete is removed; lifecycle is the one soft delete
|
|
12
|
+
- createHandler refuses softDelete; lifecycle tables are the lifecycle option
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- a legacy descriptor with softDelete is refused wherever models are linked, derived or compiled
|
|
17
|
+
|
|
18
|
+
### Documentation
|
|
19
|
+
|
|
20
|
+
- softDelete is removed; lifecycle is the one soft delete
|
|
21
|
+
|
|
7
22
|
## [0.4.26] - 2026-10-31
|
|
8
23
|
|
|
9
24
|
### Added
|
package/README.md
CHANGED
|
@@ -132,48 +132,15 @@ defineModel('posts', {
|
|
|
132
132
|
|
|
133
133
|
## Soft delete
|
|
134
134
|
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
Soft delete is `lifecycle: true`: see [Lifecycle](#lifecycle). It is the only soft delete. On a
|
|
136
|
+
model without it, a `deletedAt` field is a plain column and a `DELETE` removes the row.
|
|
137
|
+
|
|
138
|
+
To keep a `deleted_at` filter on a plain model's read, write it on the ability:
|
|
137
139
|
|
|
138
140
|
```typescript
|
|
139
|
-
|
|
140
|
-
fields: {
|
|
141
|
-
id: field.uuid().primaryKey().defaultRandom(),
|
|
142
|
-
accountId: field.uuid().notNull().references(() => Account),
|
|
143
|
-
s3Key: field.text().notNull(),
|
|
144
|
-
deletedAt: field.timestamptz(),
|
|
145
|
-
},
|
|
146
|
-
softDelete: true,
|
|
147
|
-
abilities: [can('read', { owner: 'accountId' }), can('delete', { owner: 'accountId' })],
|
|
148
|
-
});
|
|
141
|
+
can('read', { role: 'anon', sql: sql`deleted_at IS NULL` })
|
|
149
142
|
```
|
|
150
143
|
|
|
151
|
-
It changes two different things, and neither default is safe:
|
|
152
|
-
|
|
153
|
-
- **Durability, on every model** — `true` makes a `DELETE` mark `deleted_at` instead of removing
|
|
154
|
-
the row, and hides marked rows from the data API's reads and updates. `false` makes `DELETE`
|
|
155
|
-
permanent.
|
|
156
|
-
- **Visibility, only where there is a public read** — `true` AND-s `deleted_at IS NULL` into the
|
|
157
|
-
anon policy. On an owner-scoped or admin-only model the compiled SQL is identical either way,
|
|
158
|
-
so durability is the axis that matters there.
|
|
159
|
-
|
|
160
|
-
**`defineModel` refuses to guess** when a model has `deletedAt` AND either a public read or a way
|
|
161
|
-
to DELETE (`can('delete')`, `can('manage')`, or a `privileges` DELETE grant). Omit the line on
|
|
162
|
-
such a model and it throws at import, naming which branch it tripped. Where nothing reads
|
|
163
|
-
publicly and nothing deletes, the flag provably changes neither the SQL nor the handler, so
|
|
164
|
-
nothing is required.
|
|
165
|
-
|
|
166
|
-
Pick `true` if the row is meant to survive its own deletion — anything with a cleanup or destroy
|
|
167
|
-
job, an audit trail, or rows other tables reference. Pick `false` if `deleted_at` here is
|
|
168
|
-
audit-only and a `DELETE` really should remove the row. It is not cosmetic: a table whose S3
|
|
169
|
-
cleanup job finds work by `deleted_at IS NOT NULL` orphans every file it should have removed if
|
|
170
|
-
the row is hard-deleted instead.
|
|
171
|
-
|
|
172
|
-
A column named `deleted_at` is not a statement of intent — it is carried for audit, for
|
|
173
|
-
app-layer filtering, for half-finished migrations. This used to be inferred from the field's
|
|
174
|
-
presence, which made the compiler author a security rule nobody wrote. `db:pull` writes the
|
|
175
|
-
flag explicitly for the same reason.
|
|
176
|
-
|
|
177
144
|
## Relations
|
|
178
145
|
|
|
179
146
|
```typescript
|
|
@@ -245,7 +212,7 @@ The foreign key belongs to the child table, so the action lands there: in the pl
|
|
|
245
212
|
- The relation must join on the primary key (the key a field `.references()` points at). A field covered only by a table-level `foreignKey(...)` declares `onDelete` there.
|
|
246
213
|
- **`'cascade'` deletes the children in the database** (Rails `delete_all`). It runs no hook of the child and no code of yours. A child that must run code when it goes is not a cascade; that is `'destroy'`, which is not available yet. `'cascade'` works over a `NOT NULL` column, and it follows the child's own foreign keys in turn: deleting a thread deletes its replies and their reactions.
|
|
247
214
|
- **A cascade is not checked against the child's own abilities.** PostgreSQL runs the referential action as the owner of the child table, so a role that may delete the parent row removes its children even when it holds no `DELETE` grant on the child and the child's row-level policy would refuse it. Grant `delete` on the parent only to roles that may remove everything below it. `'nullify'` has the same property for `UPDATE`.
|
|
248
|
-
- Any other value
|
|
215
|
+
- Any other value is refused by name.
|
|
249
216
|
- `belongsTo` takes no `dependent`. A `hasMany` with no `dependent` is display only and compiles exactly as before.
|
|
250
217
|
|
|
251
218
|
`db:pull` renders the action on the child (`.references(() => Post, { onDelete: 'set null' })`) and no `dependent` on the parent; that agrees with a parent that declares one, so pull then plan is empty. A parent rendered `lifecycle: true` is the exception: `db:pull` renders the `dependent` of each key from a lifecycle child on the parent, from the key's `ON DELETE`, and leaves a plain child's key as it is, with a comment: only a lifecycle table may hold a key into a lifecycle table, so that model refuses to compile until you make it `lifecycle: true` or drop the `.references()`.
|
|
@@ -274,7 +241,6 @@ The compiler adds nullable `deletedAt` (timestamptz), `deletedBy` (text, no fore
|
|
|
274
241
|
`deletedBatch` (uuid) and `deletedXid` (bigint). `LIFECYCLE_COLUMNS` names them. They enter the schema plan,
|
|
275
242
|
fingerprint and generated schema. Do not declare these fields yourself, including their snake_case spellings.
|
|
276
243
|
Lifecycle needs a single-column primary key and RLS. Omit `lifecycle` to disable it; `false` is refused.
|
|
277
|
-
Do not combine it with `softDelete`. The boolean remains supported separately; its removal has not shipped.
|
|
278
244
|
|
|
279
245
|
A DELETE sets these columns and reports zero removed rows. Read roles see only live rows;
|
|
280
246
|
a plain manage role can see deleted rows. The API hides `deletedBy`, `deletedBatch` and `deletedXid` from other roles.
|
|
@@ -570,7 +536,7 @@ everystack db:pull --stage dev # reverse an existing database into model s
|
|
|
570
536
|
|
|
571
537
|
### Handler config
|
|
572
538
|
|
|
573
|
-
`deriveHandlerConfig(models)` compiles the same Models into the authorization config `createHandler` consumes (exposed tables, hidden/protected columns, relations, row ownership, **`
|
|
539
|
+
`deriveHandlerConfig(models)` compiles the same Models into the authorization config `createHandler` consumes (exposed tables, hidden/protected columns, relations, row ownership, the **`lifecycle`** tables) — so the HTTP handler and the database enforce the *same* declared rules, with no second source of truth.
|
|
574
540
|
|
|
575
541
|
```typescript
|
|
576
542
|
import { deriveHandlerConfig } from '@everystack/model';
|
|
@@ -578,8 +544,8 @@ const handler = createHandler(db, schema, deriveHandlerConfig(models));
|
|
|
578
544
|
```
|
|
579
545
|
|
|
580
546
|
Do not hand-write the per-table keys beside it. Each one is a view of the models, and a
|
|
581
|
-
hand-kept copy drifts silently:
|
|
582
|
-
hard-deleting rows whose S3 objects a destroy job keyed on `deleted_at IS NOT NULL`. Handler-level
|
|
547
|
+
hand-kept copy drifts silently: a soft-delete table list losing two entries is what once let a
|
|
548
|
+
table start hard-deleting rows whose S3 objects a destroy job keyed on `deleted_at IS NOT NULL`. Handler-level
|
|
583
549
|
options that are not per-table — `auth`, `pgSettings`, `rpc`, limits — stay explicit.
|
|
584
550
|
|
|
585
551
|
## Programmatic API (`@everystack/cli/model`)
|
package/package.json
CHANGED
package/src/can.ts
CHANGED
|
@@ -193,50 +193,6 @@ export function privilegesGrantDelete(privileges: Record<string, readonly string
|
|
|
193
193
|
return Object.values(privileges ?? {}).some((privs) => privs.includes('DELETE'));
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
/** The three facts the soft-delete rule turns on. See {@link needsSoftDeleteDeclaration}. */
|
|
197
|
-
export interface SoftDeleteFacts {
|
|
198
|
-
/** A `deletedAt` / `deleted_at` field is present. */
|
|
199
|
-
hasDeletedAtField: boolean;
|
|
200
|
-
/** Some ability is a PUBLIC read — see {@link isPublicReadAbility}. */
|
|
201
|
-
hasPublicRead: boolean;
|
|
202
|
-
/**
|
|
203
|
-
* A DELETE can reach this table — via an ability ({@link isDeleteAbility}) or via a
|
|
204
|
-
* `privileges` grant ({@link privilegesGrantDelete}). The SPELLING of the grant was never
|
|
205
|
-
* load-bearing; what matters is that rows can be destroyed.
|
|
206
|
-
*/
|
|
207
|
-
grantsDelete: boolean;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* THE rule deciding when `softDelete` must be declared rather than defaulted.
|
|
212
|
-
*
|
|
213
|
-
* `softDelete` changes observable behaviour on TWO axes, and the refusal has to cover both:
|
|
214
|
-
*
|
|
215
|
-
* - **visibility** — on a model with a public read, `true` AND-s `deleted_at IS NULL` into the
|
|
216
|
-
* anon policy. What a caller with no session can see.
|
|
217
|
-
* - **durability** — on ANY model that can be deleted from, `true` makes the handler mark
|
|
218
|
-
* `deleted_at` instead of removing the row. What a DELETE destroys.
|
|
219
|
-
*
|
|
220
|
-
* The guard originally asked only the visibility question, so a model with an owner-scoped
|
|
221
|
-
* read and a `delete` ability sailed past it and silently defaulted to `false`. That is how
|
|
222
|
-
* `uploads` and `image_variants` began hard-deleting rows, orphaning their S3 objects forever
|
|
223
|
-
* — on those models `softDelete: true` adds no policy predicate at all, so visibility was
|
|
224
|
-
* never the axis that mattered.
|
|
225
|
-
*
|
|
226
|
-
* Deliberately NOT "any model with a `deletedAt` field": where nothing reads publicly and
|
|
227
|
-
* nothing deletes, the flag provably changes neither SQL nor handler behaviour, and refusing
|
|
228
|
-
* there would be ceremony charged to every brownfield pull for no hazard.
|
|
229
|
-
*
|
|
230
|
-
* Stated over FACTS, not over a descriptor, because it has two callers holding two
|
|
231
|
-
* representations: `defineModel` has structured {@link Ability} objects, and `db:pull`'s
|
|
232
|
-
* renderer only ever holds rendered source text. Both extract the same three facts and share
|
|
233
|
-
* this one boolean, so the two surfaces cannot drift on the rule itself — the failure that
|
|
234
|
-
* already bit this guard once, when the refusal and the renderer disagreed about `role: 'anon'`.
|
|
235
|
-
*/
|
|
236
|
-
export function needsSoftDeleteDeclaration(facts: SoftDeleteFacts): boolean {
|
|
237
|
-
return facts.hasDeletedAtField && (facts.hasPublicRead || facts.grantsDelete);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
196
|
/** Declare one authorization rule. */
|
|
241
197
|
export function can(action: Action, condition: AbilityCondition = {}): Ability {
|
|
242
198
|
return { action, condition };
|
package/src/derive.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* the API handler's options blob.
|
|
4
4
|
*
|
|
5
5
|
* `exposedTables`, `hiddenColumns`, `protectedFields`, `rowOwnership`,
|
|
6
|
-
* `
|
|
6
|
+
* `lifecycle`, `relations`, and `hooks` are all *views of the model*, so they
|
|
7
7
|
* derive from it instead of a hand-maintained literal that drifts from the schema.
|
|
8
8
|
*
|
|
9
9
|
* Scope boundary: only the PER-TABLE surface derives here. App/handler-level
|
|
@@ -16,6 +16,7 @@ import type { ModelDescriptor } from './model';
|
|
|
16
16
|
import type { ModelHooks } from './model';
|
|
17
17
|
import { assertDeleteHookNames, assertDestroyHooks } from './lifecycle-hooks';
|
|
18
18
|
import type { AbilityCondition } from './can';
|
|
19
|
+
import { assertNoLegacySoftDelete } from './legacy-soft-delete';
|
|
19
20
|
import { linkModels } from './link';
|
|
20
21
|
|
|
21
22
|
/** The handler's RelationConfig shape, mirrored structurally (no @everystack/api dep). */
|
|
@@ -39,9 +40,8 @@ export interface DerivedHandlerConfig {
|
|
|
39
40
|
protectedFields: Record<string, string[]>;
|
|
40
41
|
/** `exceptRoles` is present only when a `can('manage', { role })` exists for the table. */
|
|
41
42
|
rowOwnership: Record<string, { column: string; userField: string; exceptRoles?: string[] }>;
|
|
42
|
-
/** `lifecycle`
|
|
43
|
-
|
|
44
|
-
softDelete: { tables: string[]; lifecycle?: string[] };
|
|
43
|
+
/** The `lifecycle: true` tables, present only when a lifecycle model exists. */
|
|
44
|
+
lifecycle?: string[];
|
|
45
45
|
relations: Record<string, Record<string, RelationConfig>>;
|
|
46
46
|
hooks: Record<string, ModelHooks>;
|
|
47
47
|
}
|
|
@@ -57,13 +57,13 @@ function primaryKeyOf(model: ModelDescriptor): string {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerConfig {
|
|
60
|
+
for (const model of models) assertNoLegacySoftDelete(model);
|
|
60
61
|
linkModels(models);
|
|
61
62
|
const exposedTables: string[] = [];
|
|
62
63
|
const hiddenColumns: Record<string, string[]> = {};
|
|
63
64
|
const hiddenColumnsExceptRoles: Record<string, string[]> = {};
|
|
64
65
|
const protectedFields: Record<string, string[]> = {};
|
|
65
66
|
const rowOwnership: DerivedHandlerConfig['rowOwnership'] = {};
|
|
66
|
-
const softDeleteTables: string[] = [];
|
|
67
67
|
const lifecycleTables: string[] = [];
|
|
68
68
|
const relations: Record<string, Record<string, RelationConfig>> = {};
|
|
69
69
|
const hooks: Record<string, ModelHooks> = {};
|
|
@@ -114,13 +114,9 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
|
|
|
114
114
|
if (exceptRoles.length) rowOwnership[table].exceptRoles = exceptRoles;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
// Declared, not inferred — one `softDelete` decision drives both the API filter and the
|
|
118
|
-
// RLS guard, so the two can never disagree about what a soft-deleted row is.
|
|
119
|
-
if (model.softDelete) softDeleteTables.push(table);
|
|
120
117
|
// A lifecycle table soft-deletes in the database (its delete trigger); the handler reads it as
|
|
121
118
|
// a soft-delete table and drives that machinery instead of writing deleted_at itself.
|
|
122
119
|
if (model.lifecycle) {
|
|
123
|
-
softDeleteTables.push(table);
|
|
124
120
|
lifecycleTables.push(table);
|
|
125
121
|
hiddenColumns[table] = [...new Set([...(hiddenColumns[table] ?? []), 'deletedBy', 'deletedBatch', 'deletedXid'])];
|
|
126
122
|
hiddenColumnsExceptRoles[table] = exceptRoles;
|
|
@@ -176,7 +172,7 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
|
|
|
176
172
|
...(lifecycleTables.length ? { hiddenColumnsExceptRoles } : {}),
|
|
177
173
|
protectedFields,
|
|
178
174
|
rowOwnership,
|
|
179
|
-
|
|
175
|
+
...(lifecycleTables.length ? { lifecycle: lifecycleTables } : {}),
|
|
180
176
|
relations,
|
|
181
177
|
hooks,
|
|
182
178
|
};
|
package/src/index.ts
CHANGED
|
@@ -23,8 +23,7 @@ export type { TableConstraint } from './constraints';
|
|
|
23
23
|
export { sql } from './raw-sql';
|
|
24
24
|
export type { RawSql } from './raw-sql';
|
|
25
25
|
|
|
26
|
-
export { can, isColumnAbility, isPublicReadAbility, isDeleteAbility,
|
|
27
|
-
export type { SoftDeleteFacts } from './can';
|
|
26
|
+
export { can, isColumnAbility, isPublicReadAbility, isDeleteAbility, privilegesGrantDelete, sessionlessAudience } from './can';
|
|
28
27
|
export type { Ability, AbilityCondition, Action } from './can';
|
|
29
28
|
|
|
30
29
|
export { belongsTo, hasMany, hasOne } from './relations';
|
|
@@ -53,6 +52,7 @@ export type {
|
|
|
53
52
|
} from './derived';
|
|
54
53
|
|
|
55
54
|
export { deriveHandlerConfig, deriveForceRlsCarveouts, deriveFunctionSchemas } from './derive';
|
|
55
|
+
export { assertNoLegacySoftDelete } from './legacy-soft-delete';
|
|
56
56
|
export type { DerivedHandlerConfig, RelationConfig } from './derive';
|
|
57
57
|
|
|
58
58
|
export { ENSURE_DESTROY_LOG_SQL, DESTROY_LOG_MARKER_BODIES, DESTROY_LOG_MARKER_LANGUAGES, DESTROY_LOG_MARKER_SEARCH_PATH } from './destroy-log';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `softDelete` was removed from the model. defineModel refuses the key, but a descriptor built
|
|
3
|
+
* by an older @everystack/model, or exported by an older published package, never passes
|
|
4
|
+
* through it and may still carry the key. Compiled silently, its public read would lose the
|
|
5
|
+
* `deleted_at IS NULL` guard and the handler's DELETE would destroy rows it used to mark.
|
|
6
|
+
*
|
|
7
|
+
* So every path that links, derives or compiles models refuses such a descriptor: linkModels,
|
|
8
|
+
* deriveHandlerConfig and compileTableContract (the security boundary itself). Present with
|
|
9
|
+
* any value is refused, `false` included: the descriptor is from an older build either way.
|
|
10
|
+
*/
|
|
11
|
+
export function assertNoLegacySoftDelete(model: { table: string; schema?: string }): void {
|
|
12
|
+
if (!('softDelete' in model)) return;
|
|
13
|
+
const name = model.schema && model.schema !== 'public' ? `${model.schema}.${model.table}` : model.table;
|
|
14
|
+
throw new Error(
|
|
15
|
+
`model '${name}': softDelete was removed: this model was built by an older @everystack/model or comes from an older package; ` +
|
|
16
|
+
'upgrade it, or use lifecycle: true. ' +
|
|
17
|
+
"To keep a deleted_at filter on a read, write it on the ability: can('read', { ..., sql: sql`deleted_at IS NULL` }).",
|
|
18
|
+
);
|
|
19
|
+
}
|
package/src/link.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ModelDescriptor } from './model';
|
|
2
|
+
import { assertNoLegacySoftDelete } from './legacy-soft-delete';
|
|
2
3
|
import { parseQualified } from './qualified';
|
|
3
4
|
import { assertExternalTargets } from './external';
|
|
4
5
|
import { relationDeleteActions } from './dependents';
|
|
@@ -49,6 +50,7 @@ export function referenceLocation(target: (() => unknown) | undefined, location:
|
|
|
49
50
|
*/
|
|
50
51
|
export function linkModels<T extends readonly ModelDescriptor[]>(models: T): T {
|
|
51
52
|
const uniqueModels = [...new Set(models)];
|
|
53
|
+
for (const model of uniqueModels) assertNoLegacySoftDelete(model);
|
|
52
54
|
const byName = new Map<string, ModelDescriptor[]>();
|
|
53
55
|
for (const model of uniqueModels) {
|
|
54
56
|
const candidates = byName.get(model.table) ?? [];
|
package/src/model.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { dependentWhereRefusal } from './dependents';
|
|
|
15
15
|
import { field, type FieldBuilder } from './field';
|
|
16
16
|
import { referenceLocation } from './link';
|
|
17
17
|
import type { Ability } from './can';
|
|
18
|
-
import { can,
|
|
18
|
+
import { can, sessionlessAudience } from './can';
|
|
19
19
|
import { assertRelationShape, type Relation } from './relations';
|
|
20
20
|
import { assertCheckNames, type TableConstraint } from './constraints';
|
|
21
21
|
import { lifecycleCascadeHarm } from './dependents';
|
|
@@ -102,42 +102,18 @@ export interface ModelDefinition {
|
|
|
102
102
|
*/
|
|
103
103
|
privileges?: Record<string, TablePrivilege[]>;
|
|
104
104
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* then filtered out of GET (for EVERY role, unless `?deleted=include|only`), out of
|
|
111
|
-
* embedded relation reads, and out of UPDATE. This half applies to every model, whatever
|
|
112
|
-
* its audience.
|
|
113
|
-
* - **RLS**: `deleted_at IS NULL` is AND-ed into the PUBLIC read policies. This half applies
|
|
114
|
-
* only where there is a public read — on an owner-scoped or admin-only model the compiled
|
|
115
|
-
* contract is byte-identical either way (pinned in cli's `public-read-pin.test.ts`).
|
|
116
|
-
*
|
|
117
|
-
* **Required — not inferred — on any model with a `deletedAt` field that either has a public
|
|
118
|
-
* read OR grants DELETE** (`can('delete')`, or `can('manage')`, which is "all actions"). See
|
|
119
|
-
* {@link needsSoftDeleteDeclaration}, the one rule this and `db:pull` share.
|
|
120
|
-
*
|
|
121
|
-
* The guard used to ask only about the public read, which is the visibility axis. That let a
|
|
122
|
-
* model with an owner-scoped read and a `delete` ability default to `false` and begin
|
|
123
|
-
* destroying rows — with an S3 cleanup job keyed on `deleted_at IS NOT NULL`, that orphaned
|
|
124
|
-
* every file it should have cleaned up. On those models the flag adds no policy at all, so
|
|
125
|
-
* visibility was never the axis that mattered.
|
|
126
|
-
*
|
|
127
|
-
* It used to be inferred from the field's mere presence. A column named `deleted_at` is
|
|
128
|
-
* not a statement of visibility intent: it is carried for audit, for app-layer filtering,
|
|
129
|
-
* for half-finished migrations. Inferring the predicate made the compiler author a
|
|
130
|
-
* security rule nobody wrote — and because `db:pull` renders `deletedAt` straight from the
|
|
131
|
-
* live column, a brownfield model acquired it automatically and the FIRST plan narrowed a
|
|
132
|
-
* policy the adopter had written. `createHandler`'s own `softDelete: { column, tables }`
|
|
133
|
-
* was always explicit; this matches it. Convention may propose it, never enforce it.
|
|
105
|
+
* Removed. `lifecycle: true` is the one soft delete. defineModel refuses this key, `true` or
|
|
106
|
+
* `false`, so an upgrade never compiles quietly into a model whose read lost its
|
|
107
|
+
* `deleted_at IS NULL` guard or whose DELETE starts destroying rows it used to mark. For a
|
|
108
|
+
* soft delete, declare `lifecycle: true`; to keep a `deleted_at` filter on a read, write it on
|
|
109
|
+
* the ability: `can('read', { ..., sql: sql\`deleted_at IS NULL\` })`.
|
|
134
110
|
*/
|
|
135
|
-
softDelete?:
|
|
111
|
+
softDelete?: never;
|
|
136
112
|
/**
|
|
137
113
|
* `lifecycle: true` — a soft delete with a delay. defineModel generates four nullable columns
|
|
138
114
|
* ({@link LIFECYCLE_COLUMNS}): `deletedAt` timestamptz, `deletedBy` text (no foreign key),
|
|
139
|
-
* `deletedBatch` uuid, `deletedXid` bigint. The author never declares them
|
|
140
|
-
*
|
|
115
|
+
* `deletedBatch` uuid, `deletedXid` bigint. The author never declares them and declares a
|
|
116
|
+
* single-column primary key; each is refused here.
|
|
141
117
|
*
|
|
142
118
|
* `{ deleteWhen: sql\`<predicate>\` }` is lifecycle too, with a guard: a row may be marked
|
|
143
119
|
* deleted only when the predicate is TRUE for it (FALSE or NULL refuses, SQLSTATE 23514), whether
|
|
@@ -223,8 +199,6 @@ export interface ModelDescriptor {
|
|
|
223
199
|
writtenBy: WrittenBy;
|
|
224
200
|
/** Beyond-CRUD privileges per role, unioned with what the abilities grant. See {@link ModelDefinition.privileges}. */
|
|
225
201
|
privileges: Record<string, TablePrivilege[]>;
|
|
226
|
-
/** `true` excludes `deleted_at IS NULL` rows from public reads and the data API. Declared, never inferred. */
|
|
227
|
-
softDelete: boolean;
|
|
228
202
|
/** Present only when declared: `fields` carries the generated {@link LIFECYCLE_COLUMNS}. Always
|
|
229
203
|
* `true` on the descriptor, whichever form the definition used. */
|
|
230
204
|
lifecycle?: true;
|
|
@@ -350,12 +324,6 @@ function assertLifecycle(table: string, def: ModelDefinition): void {
|
|
|
350
324
|
} else if (value !== true) {
|
|
351
325
|
throw new Error(`model '${table}': lifecycle: ${JSON.stringify(value)} — the only values are lifecycle: true and lifecycle: { deleteWhen: sql\`…\` }.`);
|
|
352
326
|
}
|
|
353
|
-
if (def.softDelete !== undefined) {
|
|
354
|
-
throw new Error(
|
|
355
|
-
`model '${table}': lifecycle replaces softDelete — delete softDelete: ${String(def.softDelete)}. ` +
|
|
356
|
-
`lifecycle: true already declares what a delete does.`,
|
|
357
|
-
);
|
|
358
|
-
}
|
|
359
327
|
for (const name of LIFECYCLE_RESERVED) {
|
|
360
328
|
if (name in def.fields) {
|
|
361
329
|
throw new Error(
|
|
@@ -483,7 +451,22 @@ function assertLifecycleRestoreSafe(table: string, def: ModelDefinition): void {
|
|
|
483
451
|
}
|
|
484
452
|
}
|
|
485
453
|
|
|
454
|
+
/**
|
|
455
|
+
* `softDelete` was removed. Refuse the key, `true` or `false`, rather than ignore it: an ignored
|
|
456
|
+
* `true` would drop the `deleted_at IS NULL` guard from a public read and turn a marking DELETE
|
|
457
|
+
* into a destroying one, with nothing in any diff to review.
|
|
458
|
+
*/
|
|
459
|
+
function assertNoSoftDelete(table: string, def: ModelDefinition): void {
|
|
460
|
+
if (!('softDelete' in def)) return;
|
|
461
|
+
throw new Error(
|
|
462
|
+
`model '${table}': softDelete was removed. For a soft delete, use lifecycle: true. ` +
|
|
463
|
+
"To keep a deleted_at filter on a read, write it on the ability: can('read', { ..., sql: sql`deleted_at IS NULL` }). " +
|
|
464
|
+
'If it said false, delete the line.',
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
486
468
|
export function defineModel(table: string, def: ModelDefinition): ModelDescriptor {
|
|
469
|
+
assertNoSoftDelete(table, def);
|
|
487
470
|
assertDeleteHookNames(table, def.hooks);
|
|
488
471
|
assertDestroyHooks(table, def.hooks, isLifecycle(def));
|
|
489
472
|
// Refuse first, then inject: the author-declared collisions are checked against what the author wrote.
|
|
@@ -643,52 +626,6 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
643
626
|
}
|
|
644
627
|
}
|
|
645
628
|
|
|
646
|
-
// The soft-delete guard used to be inferred from the field's presence, which meant a model
|
|
647
|
-
// could gain — or, once the inference was removed, silently LOSE — a predicate that decides
|
|
648
|
-
// what anonymous users can see, with nothing in any diff to review. Neither default is safe,
|
|
649
|
-
// so there is no default: on the exact set of models where the answer changes behaviour, it
|
|
650
|
-
// must be declared. `softDelete: true` is what the inference used to do.
|
|
651
|
-
//
|
|
652
|
-
// That set is NOT "has a public read". It is the union of two axes — see
|
|
653
|
-
// `needsSoftDeleteDeclaration`, which is the whole rule and is shared with db:pull so the two
|
|
654
|
-
// surfaces cannot drift on it.
|
|
655
|
-
// `isPublicReadAbility`, not a local copy: this refusal and db:pull's renderer were asking
|
|
656
|
-
// the same question — "can a caller with no session see these rows?" — and answering it
|
|
657
|
-
// differently. The renderer already counted `role: 'anon'`; this refusal did not, so a table
|
|
658
|
-
// whose authenticated read diverges (rendered as an anon-scoped read) slipped past the very
|
|
659
|
-
// decision the refusal exists to force. It now fires on the anonymous audience however it is
|
|
660
|
-
// spelled. Explicit `softDelete: false` remains the one-line answer, and pull writes that
|
|
661
|
-
// line into every model it renders, so a pulled checkout keeps loading.
|
|
662
|
-
const hasPublicRead = (def.abilities ?? []).some(isPublicReadAbility);
|
|
663
|
-
// Either spelling of a reachable DELETE. `privileges` matters most on an `rls: false` table,
|
|
664
|
-
// where the grant is unrestricted and no policy stands between it and every row.
|
|
665
|
-
const grantsDelete = (def.abilities ?? []).some(isDeleteAbility) || privilegesGrantDelete(def.privileges);
|
|
666
|
-
// Both spellings of the field key. The guard used to test `'deletedAt' in def.fields`
|
|
667
|
-
// literally, so a model keying the column `deleted_at` — the shape a snake_case brownfield
|
|
668
|
-
// schema pulls as — carried a soft-delete column past a guard that never looked at it.
|
|
669
|
-
const softDeleteField = 'deletedAt' in def.fields || 'deleted_at' in def.fields;
|
|
670
|
-
// A lifecycle model has declared what a delete does; its generated deletedAt is not an author field.
|
|
671
|
-
if (needsSoftDeleteDeclaration({ hasDeletedAtField: softDeleteField, hasPublicRead, grantsDelete }) && def.softDelete === undefined) {
|
|
672
|
-
// DURABILITY FIRST. The old message called this "a visibility decision", which is false
|
|
673
|
-
// for exactly the models that broke: with no public read, `softDelete: true` adds no
|
|
674
|
-
// policy predicate at all. Naming the wrong consequence teaches the wrong fix, so each
|
|
675
|
-
// axis is stated only when it actually fired.
|
|
676
|
-
const why = grantsDelete
|
|
677
|
-
? `grants DELETE (via can('delete'), can('manage') — which is "all actions" — or a privileges DELETE grant), `
|
|
678
|
-
+ `so it must declare softDelete. `
|
|
679
|
-
+ `This is a durability decision: softDelete: true makes a DELETE mark deleted_at instead of destroying the row; `
|
|
680
|
-
+ `softDelete: false makes DELETE permanent.`
|
|
681
|
-
: `has a public read, so it must declare softDelete.`;
|
|
682
|
-
const visibility = hasPublicRead
|
|
683
|
-
? ` It is also a visibility decision: softDelete: true excludes soft-deleted rows from the public read policies `
|
|
684
|
-
+ `(what the compiler used to infer from the field's presence); softDelete: false leaves them readable.`
|
|
685
|
-
: '';
|
|
686
|
-
throw new Error(
|
|
687
|
-
`model '${table}': has a 'deletedAt' field and ${why}${visibility} `
|
|
688
|
-
+ `What a DELETE destroys is declared, not guessed — add softDelete: true or softDelete: false.`,
|
|
689
|
-
);
|
|
690
|
-
}
|
|
691
|
-
|
|
692
629
|
// A privilege outside the table-privilege vocabulary is still refused — that is a typo, not a
|
|
693
630
|
// state a database can hold. The DML four ARE accepted now: they used to throw here so that a
|
|
694
631
|
// grant could never be declared without its policy, but that invariant was vacuous for any
|
|
@@ -767,7 +704,6 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
767
704
|
writtenBy: def.writtenBy ?? 'app',
|
|
768
705
|
privileges: def.privileges ?? {},
|
|
769
706
|
// Stays false on a lifecycle model: its reads are filtered by the RLS guard (L1.2a), its deletes arrive with the triggers.
|
|
770
|
-
softDelete: def.softDelete ?? false,
|
|
771
707
|
...(lifecycle ? { lifecycle: true as const } : {}),
|
|
772
708
|
...(deleteWhen !== undefined ? { deleteWhen } : {}),
|
|
773
709
|
schema: def.schema ?? 'public',
|