@everystack/model 0.4.7 → 0.4.8
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/package.json +1 -1
- package/src/derive.ts +3 -2
- package/src/model.ts +34 -0
package/package.json
CHANGED
package/src/derive.ts
CHANGED
|
@@ -83,8 +83,9 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
|
|
|
83
83
|
};
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
//
|
|
87
|
-
|
|
86
|
+
// Declared, not inferred — one `softDelete` decision drives both the API filter and the
|
|
87
|
+
// RLS guard, so the two can never disagree about what a soft-deleted row is.
|
|
88
|
+
if (model.softDelete) softDeleteTables.push(table);
|
|
88
89
|
|
|
89
90
|
if (model.hooks) hooks[table] = model.hooks;
|
|
90
91
|
|
package/src/model.ts
CHANGED
|
@@ -59,6 +59,20 @@ export interface ModelDefinition {
|
|
|
59
59
|
constraints?: TableConstraint[];
|
|
60
60
|
/** The write principal — drives RLS posture. Default `'app'`. See {@link WrittenBy}. */
|
|
61
61
|
writtenBy?: WrittenBy;
|
|
62
|
+
/**
|
|
63
|
+
* `true` excludes soft-deleted rows (`deleted_at IS NULL`) from the public read policies
|
|
64
|
+
* and from the generic data API. **Required — not inferred — on any model that has a
|
|
65
|
+
* `deletedAt` field and a public read.**
|
|
66
|
+
*
|
|
67
|
+
* It used to be inferred from the field's mere presence. A column named `deleted_at` is
|
|
68
|
+
* not a statement of visibility intent: it is carried for audit, for app-layer filtering,
|
|
69
|
+
* for half-finished migrations. Inferring the predicate made the compiler author a
|
|
70
|
+
* security rule nobody wrote — and because `db:pull` renders `deletedAt` straight from the
|
|
71
|
+
* live column, a brownfield model acquired it automatically and the FIRST plan narrowed a
|
|
72
|
+
* policy the adopter had written. `createHandler`'s own `softDelete: { column, tables }`
|
|
73
|
+
* was always explicit; this matches it. Convention may propose it, never enforce it.
|
|
74
|
+
*/
|
|
75
|
+
softDelete?: boolean;
|
|
62
76
|
/**
|
|
63
77
|
* The Postgres schema the table lives in. Default `'public'`. Package-owned
|
|
64
78
|
* tables declare their own (`'dist'`, `'ops'`, `'auth'`) so one db:generate run
|
|
@@ -118,6 +132,8 @@ export interface ModelDescriptor {
|
|
|
118
132
|
constraints: TableConstraint[];
|
|
119
133
|
/** The resolved write principal (default `'app'`) — drives RLS posture. See {@link WrittenBy}. */
|
|
120
134
|
writtenBy: WrittenBy;
|
|
135
|
+
/** `true` excludes `deleted_at IS NULL` rows from public reads and the data API. Declared, never inferred. */
|
|
136
|
+
softDelete: boolean;
|
|
121
137
|
/** The resolved Postgres schema (default `'public'`). Package tables live in their own. */
|
|
122
138
|
schema: string;
|
|
123
139
|
/** `true` removes the table from the generic data API (not in `exposedTables`). Default `false`. */
|
|
@@ -174,6 +190,23 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
174
190
|
}
|
|
175
191
|
}
|
|
176
192
|
|
|
193
|
+
// The soft-delete guard used to be inferred from the field's presence, which meant a model
|
|
194
|
+
// could gain — or, once the inference was removed, silently LOSE — a predicate that decides
|
|
195
|
+
// what anonymous users can see, with nothing in any diff to review. Neither default is safe,
|
|
196
|
+
// so there is no default: on the exact set of models where the answer changes behaviour (a
|
|
197
|
+
// `deletedAt` field plus a public read), it must be declared. `softDelete: true` is what the
|
|
198
|
+
// inference used to do.
|
|
199
|
+
const hasPublicRead = (def.abilities ?? []).some(
|
|
200
|
+
(a) => a.action === 'read' && !a.condition.role && !a.condition.owner && !a.condition.via,
|
|
201
|
+
);
|
|
202
|
+
if ('deletedAt' in def.fields && hasPublicRead && def.softDelete === undefined) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`model '${table}': has a 'deletedAt' field and a public read, so it must declare softDelete. `
|
|
205
|
+
+ `softDelete: true excludes soft-deleted rows from the public read policies (what the compiler used to infer from the field's presence); `
|
|
206
|
+
+ `softDelete: false leaves them readable. This is a visibility decision, so it is declared, not guessed.`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
177
210
|
validateTriggersFor('table', table, def.triggers ?? []);
|
|
178
211
|
|
|
179
212
|
// A rename and a move in one increment can't be composed from two independent markers
|
|
@@ -197,6 +230,7 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
197
230
|
primaryKey,
|
|
198
231
|
constraints: def.constraints ?? [],
|
|
199
232
|
writtenBy: def.writtenBy ?? 'app',
|
|
233
|
+
softDelete: def.softDelete ?? false,
|
|
200
234
|
schema: def.schema ?? 'public',
|
|
201
235
|
private: def.private ?? false,
|
|
202
236
|
...(def.renamedFrom ? { renamedFrom: def.renamedFrom } : {}),
|