@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/src/model.ts
CHANGED
|
@@ -11,12 +11,13 @@
|
|
|
11
11
|
* compiler (`compileTableContract`, what `db:generate` emits as RLS + grants).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import type
|
|
14
|
+
import { field, type FieldBuilder } from './field';
|
|
15
15
|
import { referenceLocation } from './link';
|
|
16
16
|
import type { Ability } from './can';
|
|
17
|
-
import { isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete, sessionlessAudience } from './can';
|
|
18
|
-
import type
|
|
19
|
-
import type
|
|
17
|
+
import { can, isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete, sessionlessAudience } from './can';
|
|
18
|
+
import { assertRelationShape, type Relation } from './relations';
|
|
19
|
+
import { assertCheckNames, type TableConstraint } from './constraints';
|
|
20
|
+
import { lifecycleCascadeHarm } from './dependents';
|
|
20
21
|
import type { RawSql } from './raw-sql';
|
|
21
22
|
import { validateTriggersFor, type TriggerSpec, type DependsOnRef } from './derived';
|
|
22
23
|
import { TABLE_PRIVILEGES, type TablePrivilege } from './privileges';
|
|
@@ -26,6 +27,15 @@ import { TABLE_PRIVILEGES, type TablePrivilege } from './privileges';
|
|
|
26
27
|
* absorb (purge an S3 object on delete, stamp a column on create) — held
|
|
27
28
|
* verbatim and passed through to the handler's `hooks`.
|
|
28
29
|
*/
|
|
30
|
+
/** Context for a real removal. Hooks must be idempotent: retries can repeat external effects. */
|
|
31
|
+
export interface DestroyContext {
|
|
32
|
+
source: 'purge' | 'api';
|
|
33
|
+
user: Record<string, unknown> | null;
|
|
34
|
+
client: Record<string, unknown> | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type DestroyHook = (row: Record<string, unknown>, ctx: DestroyContext) => void | Promise<void>;
|
|
38
|
+
|
|
29
39
|
export interface ModelHooks {
|
|
30
40
|
beforeCreate?: (...args: any[]) => any;
|
|
31
41
|
afterCreate?: (...args: any[]) => any;
|
|
@@ -33,6 +43,13 @@ export interface ModelHooks {
|
|
|
33
43
|
afterUpdate?: (...args: any[]) => any;
|
|
34
44
|
beforeDelete?: (...args: any[]) => any;
|
|
35
45
|
afterDelete?: (...args: any[]) => any;
|
|
46
|
+
/** Runs after the DELETE statement, before commit. A throw rolls back only the database
|
|
47
|
+
* delete, never an external effect. No transaction is passed. Must be idempotent.
|
|
48
|
+
* Treat the row as read-only: it is the same object later serialized into the response. */
|
|
49
|
+
beforeDestroy?: DestroyHook;
|
|
50
|
+
/** Runs after commit, best-effort. Cannot undo the removal. Must be idempotent.
|
|
51
|
+
* Treat the row as read-only: it is the same object later serialized into the response. */
|
|
52
|
+
afterDestroy?: DestroyHook;
|
|
36
53
|
}
|
|
37
54
|
|
|
38
55
|
/**
|
|
@@ -115,6 +132,14 @@ export interface ModelDefinition {
|
|
|
115
132
|
* was always explicit; this matches it. Convention may propose it, never enforce it.
|
|
116
133
|
*/
|
|
117
134
|
softDelete?: boolean;
|
|
135
|
+
/**
|
|
136
|
+
* `lifecycle: true` — a soft delete with a delay. defineModel generates four nullable columns
|
|
137
|
+
* ({@link LIFECYCLE_COLUMNS}): `deletedAt` timestamptz, `deletedBy` text (no foreign key),
|
|
138
|
+
* `deletedBatch` uuid, `deletedXid` bigint. The author never declares them, never combines
|
|
139
|
+
* lifecycle with `softDelete`, and declares a single-column primary key; each is refused here.
|
|
140
|
+
* `true` is the only value today.
|
|
141
|
+
*/
|
|
142
|
+
lifecycle?: true;
|
|
118
143
|
/**
|
|
119
144
|
* The Postgres schema the table lives in. Default `'public'`. Package-owned
|
|
120
145
|
* tables declare their own (`'dist'`, `'ops'`, `'auth'`) so one db:generate run
|
|
@@ -195,6 +220,8 @@ export interface ModelDescriptor {
|
|
|
195
220
|
privileges: Record<string, TablePrivilege[]>;
|
|
196
221
|
/** `true` excludes `deleted_at IS NULL` rows from public reads and the data API. Declared, never inferred. */
|
|
197
222
|
softDelete: boolean;
|
|
223
|
+
/** Present only when declared: `fields` carries the generated {@link LIFECYCLE_COLUMNS}. */
|
|
224
|
+
lifecycle?: true;
|
|
198
225
|
/** The resolved Postgres schema (default `'public'`). Package tables live in their own. */
|
|
199
226
|
schema: string;
|
|
200
227
|
/** `true` removes the table from the generic data API (not in `exposedTables`). Default `false`. */
|
|
@@ -227,11 +254,194 @@ export interface MaterializedTableMarker {
|
|
|
227
254
|
comment?: string;
|
|
228
255
|
}
|
|
229
256
|
|
|
257
|
+
/** The field keys `lifecycle: true` generates, in the order defineModel appends them. */
|
|
258
|
+
export const LIFECYCLE_COLUMNS = ['deletedAt', 'deletedBy', 'deletedBatch', 'deletedXid'] as const;
|
|
259
|
+
|
|
260
|
+
/** Both spellings of each generated key: an author declaring either collides with the generated column. */
|
|
261
|
+
const LIFECYCLE_RESERVED = [...LIFECYCLE_COLUMNS, 'deleted_at', 'deleted_by', 'deleted_batch', 'deleted_xid'];
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The role a lifecycle model grants to its delete machinery. defineModel appends its abilities
|
|
265
|
+
* to every `lifecycle: true` model, so no author ability may name it, on any model.
|
|
266
|
+
*/
|
|
267
|
+
export const LIFECYCLE_OWNER_ROLE = 'everystack_lifecycle';
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The lifecycle role's abilities: read every row (deleted ones included), update only the four
|
|
271
|
+
* generated columns, delete. No insert, no manage.
|
|
272
|
+
*/
|
|
273
|
+
function lifecycleAbilities(): Ability[] {
|
|
274
|
+
const role = LIFECYCLE_OWNER_ROLE;
|
|
275
|
+
return [
|
|
276
|
+
can('read', { role }),
|
|
277
|
+
can('update', { role, columns: [...LIFECYCLE_COLUMNS] }),
|
|
278
|
+
can('delete', { role }),
|
|
279
|
+
];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function assertRoleNotReserved(table: string, def: ModelDefinition): void {
|
|
283
|
+
// A privileges entry is refused too, even an empty list: TABLE_PRIVILEGES includes TRUNCATE
|
|
284
|
+
// and TRIGGER, which would let the role bypass RLS or attach triggers beside the guards.
|
|
285
|
+
if (def.privileges && Object.prototype.hasOwnProperty.call(def.privileges, LIFECYCLE_OWNER_ROLE)) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
`model '${table}': privileges['${LIFECYCLE_OWNER_ROLE}'] — the role '${LIFECYCLE_OWNER_ROLE}' is reserved for lifecycle; ` +
|
|
288
|
+
`the compiler grants it automatically on a lifecycle: true model. Remove the entry.`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
for (const a of def.abilities ?? []) {
|
|
292
|
+
if (a.condition.role !== LIFECYCLE_OWNER_ROLE) continue;
|
|
293
|
+
throw new Error(
|
|
294
|
+
`model '${table}': can('${a.action}', { role: '${LIFECYCLE_OWNER_ROLE}' }) — the role '${LIFECYCLE_OWNER_ROLE}' ` +
|
|
295
|
+
`is reserved for lifecycle; the compiler grants it automatically on a lifecycle: true model. Remove the ability.`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Fresh builders on every call: a descriptor owns its fields, never shares them with another model. */
|
|
301
|
+
function lifecycleFields(): Record<(typeof LIFECYCLE_COLUMNS)[number], FieldBuilder> {
|
|
302
|
+
return {
|
|
303
|
+
deletedAt: field.timestamptz().nullable(),
|
|
304
|
+
deletedBy: field.text().nullable(),
|
|
305
|
+
deletedBatch: field.uuid().nullable(),
|
|
306
|
+
deletedXid: field.bigint().nullable(),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function assertLifecycle(table: string, def: ModelDefinition): void {
|
|
311
|
+
const value: unknown = def.lifecycle;
|
|
312
|
+
if (value === undefined) return;
|
|
313
|
+
if (value === false) {
|
|
314
|
+
throw new Error(`model '${table}': lifecycle: false is not supported yet. Omit the key for a model without lifecycle.`);
|
|
315
|
+
}
|
|
316
|
+
if (value !== true) {
|
|
317
|
+
throw new Error(`model '${table}': lifecycle: ${JSON.stringify(value)} — the only value is lifecycle: true.`);
|
|
318
|
+
}
|
|
319
|
+
if (def.softDelete !== undefined) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`model '${table}': lifecycle replaces softDelete — delete softDelete: ${String(def.softDelete)}. ` +
|
|
322
|
+
`lifecycle: true already declares what a delete does.`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
for (const name of LIFECYCLE_RESERVED) {
|
|
326
|
+
if (name in def.fields) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`model '${table}': field '${name}' — with lifecycle: true the compiler generates this column; delete the declaration.`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// The lifecycle role's policies and the deleted_at read guard are RLS policies; under disabled
|
|
333
|
+
// row security they filter nothing while reading as though they do.
|
|
334
|
+
if (def.rls === false) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`model '${table}': lifecycle: true with rls: false — lifecycle hides deleted rows with RLS policies, ` +
|
|
337
|
+
`and rls: false declares there are none, so the guard would be dead. Either drop rls: false (lifecycle needs RLS) ` +
|
|
338
|
+
`or drop lifecycle: true.`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
// A cascade into this table, declared on this table's own foreign key. A parent's
|
|
342
|
+
// hasMany { dependent: 'cascade' } is checked in relationDeleteActions (allowed from a
|
|
343
|
+
// lifecycle parent, where it compiles to RESTRICT and the triggers soft-delete the children).
|
|
344
|
+
const tableName = def.schema && def.schema !== 'public' ? `${def.schema}.${table}` : table;
|
|
345
|
+
for (const [name, f] of Object.entries(def.fields)) {
|
|
346
|
+
if (f.spec.onDelete !== 'cascade') continue;
|
|
347
|
+
throw new Error(`model '${tableName}': field '${name}' declares onDelete 'cascade'. ${lifecycleCascadeHarm(`${tableName}.${name}`)}.`);
|
|
348
|
+
}
|
|
349
|
+
for (const c of def.constraints ?? []) {
|
|
350
|
+
if (c.kind !== 'foreignKey' || c.onDelete !== 'cascade') continue;
|
|
351
|
+
const where = `${tableName}.${c.columns.join(', ')}`;
|
|
352
|
+
throw new Error(`model '${tableName}': foreignKey(${c.columns.join(', ')}) declares onDelete 'cascade'. ${lifecycleCascadeHarm(where)}.`);
|
|
353
|
+
}
|
|
354
|
+
const pk = Object.entries(def.fields).filter(([, f]) => f.spec.isPrimaryKey).length;
|
|
355
|
+
if (pk !== 1) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
`model '${table}': lifecycle needs a single-column primary key — this model declares ${pk === 0 ? 'none' : `${pk} (composite)`}. ` +
|
|
358
|
+
`Mark exactly one field .primaryKey().`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
// The generated trigger and function names are `<table>_lifecycle_transition` and `_guard`.
|
|
362
|
+
// PostgreSQL silently truncates an identifier past 63 bytes, so the declared name would not be the live one.
|
|
363
|
+
// UTF-8 bytes, as PostgreSQL counts them. TextEncoder rather than Buffer: a model also loads outside Node.
|
|
364
|
+
const longest = `${table}_lifecycle_transition`;
|
|
365
|
+
const bytes = new TextEncoder().encode(longest).length;
|
|
366
|
+
if (bytes > 63) {
|
|
367
|
+
throw new Error(
|
|
368
|
+
`model '${table}': lifecycle generates '${longest}' (${bytes} bytes), past PostgreSQL's 63-byte identifier limit. ` +
|
|
369
|
+
`A lifecycle table name may be at most 42 bytes; shorten the table name.`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Restore runs the table's delete rule inside a SECURITY DEFINER function as the lifecycle role.
|
|
376
|
+
* Refuse a delete rule whose meaning would change there: a subquery (`via`, or SELECT in
|
|
377
|
+
* where/sql) runs with that role's privileges, and current_user/session_user name that role or
|
|
378
|
+
* the login, not the caller. TRUNCATE is refused because it skips the delete triggers.
|
|
379
|
+
*/
|
|
380
|
+
function assertLifecycleRestoreSafe(table: string, def: ModelDefinition): void {
|
|
381
|
+
if (def.lifecycle !== true) return;
|
|
382
|
+
const why = 'restore runs the delete rule inside a SECURITY DEFINER function as the lifecycle owner';
|
|
383
|
+
const abilities = def.abilities ?? [];
|
|
384
|
+
// A role-less (or authenticated) delete on a model scoped only through `via` compiles to the
|
|
385
|
+
// via subquery too, though the ability itself names no via.
|
|
386
|
+
const viaScopedModel = !abilities.some((a) => a.condition.owner) && abilities.some((a) => a.condition.via);
|
|
387
|
+
for (const a of abilities) {
|
|
388
|
+
if (a.action !== 'delete' && a.action !== 'manage') continue;
|
|
389
|
+
// PUBLIC would get EXECUTE on restore, and a branch with no role behind it: anon restores.
|
|
390
|
+
if (a.condition.role && a.condition.role.toLowerCase() === 'public') {
|
|
391
|
+
throw new Error(
|
|
392
|
+
`model '${table}': can('${a.action}', { role: '${a.condition.role}' }) on a lifecycle: true model — ${why}, and ` +
|
|
393
|
+
`restore is granted to whoever may delete, so every role, anon included, could restore rows. ` +
|
|
394
|
+
`Use a named role (authenticated, or one of your own) for the delete rule.`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
const authClass = !a.condition.role || a.condition.role === 'authenticated';
|
|
398
|
+
if (a.condition.via || (viaScopedModel && authClass)) {
|
|
399
|
+
const spelled = a.condition.via ? `can('${a.action}', { via: '${a.condition.via}' })` : `can('${a.action}') on a model scoped by via`;
|
|
400
|
+
throw new Error(
|
|
401
|
+
`model '${table}': ${spelled} on a lifecycle: true model — ${why}, where a via ` +
|
|
402
|
+
`subquery would read the parent with that role's privileges. Use owner, or a role with a plain where, for the delete rule.`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
for (const key of ['where', 'sql'] as const) {
|
|
406
|
+
const text = (a.condition[key] as { sql?: unknown } | undefined)?.sql;
|
|
407
|
+
if (typeof text !== 'string') continue;
|
|
408
|
+
const offender = /\bselect\b/i.test(text)
|
|
409
|
+
? 'a subquery (SELECT)'
|
|
410
|
+
: /\bcurrent_user\b/i.test(text)
|
|
411
|
+
? 'current_user'
|
|
412
|
+
: /\bsession_user\b/i.test(text)
|
|
413
|
+
? 'session_user'
|
|
414
|
+
: null;
|
|
415
|
+
if (!offender) continue;
|
|
416
|
+
throw new Error(
|
|
417
|
+
`model '${table}': can('${a.action}', { ${key} }) uses ${offender} on a lifecycle: true model — ${why}, ` +
|
|
418
|
+
`where a subquery runs with that role's privileges and current_user/session_user do not name the caller. ` +
|
|
419
|
+
`Replace it with a predicate on the row's own columns or the request claims.`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
for (const [role, privs] of Object.entries(def.privileges ?? {})) {
|
|
424
|
+
if (!Array.isArray(privs) || !privs.includes('TRUNCATE')) continue;
|
|
425
|
+
throw new Error(
|
|
426
|
+
`model '${table}': privileges['${role}'] grants TRUNCATE on a lifecycle: true model — TRUNCATE skips the delete ` +
|
|
427
|
+
`triggers, so it would erase rows past the soft delete and restore. Remove TRUNCATE from the entry.`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
230
432
|
export function defineModel(table: string, def: ModelDefinition): ModelDescriptor {
|
|
433
|
+
// Refuse first, then inject: the author-declared collisions are checked against what the author wrote.
|
|
434
|
+
assertRoleNotReserved(table, def);
|
|
435
|
+
assertLifecycle(table, def);
|
|
436
|
+
assertLifecycleRestoreSafe(table, def);
|
|
437
|
+
const lifecycle = def.lifecycle === true;
|
|
438
|
+
const fields: Record<string, FieldBuilder> = lifecycle ? { ...def.fields, ...lifecycleFields() } : def.fields;
|
|
439
|
+
|
|
231
440
|
for (const [name, field] of Object.entries(def.fields)) {
|
|
232
441
|
referenceLocation(field.spec.references, `${table}.${name}`);
|
|
233
442
|
}
|
|
234
443
|
for (const [name, relation] of Object.entries(def.relations ?? {})) {
|
|
444
|
+
assertRelationShape(def.schema && def.schema !== 'public' ? `${def.schema}.${table}` : table, name, relation);
|
|
235
445
|
referenceLocation(relation.target, `${table}.${name}`);
|
|
236
446
|
}
|
|
237
447
|
const primaryKey = Object.entries(def.fields)
|
|
@@ -282,6 +492,15 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
282
492
|
}
|
|
283
493
|
}
|
|
284
494
|
|
|
495
|
+
// A generated lifecycle column is not an author field, so no author ability may scope by it.
|
|
496
|
+
// (A column ability's owner is already refused above against the author's fields.)
|
|
497
|
+
if (lifecycle && a.condition.owner && LIFECYCLE_RESERVED.includes(a.condition.owner)) {
|
|
498
|
+
throw new Error(
|
|
499
|
+
`model '${table}': can('${a.action}', { owner: '${a.condition.owner}' }) names '${a.condition.owner}', but no such field exists ` +
|
|
500
|
+
`among the model's fields — with lifecycle: true the compiler generates it, and an ability cannot scope by it.`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
285
504
|
// `owner`/`via` compare a row column to the caller's JWT claim; a no-session audience has
|
|
286
505
|
// none. Truthiness matches the compiler, which treats `owner: ''` as no row scope.
|
|
287
506
|
const rowScope = a.condition.owner ? 'owner' : a.condition.via ? 'via' : null;
|
|
@@ -365,6 +584,7 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
365
584
|
// literally, so a model keying the column `deleted_at` — the shape a snake_case brownfield
|
|
366
585
|
// schema pulls as — carried a soft-delete column past a guard that never looked at it.
|
|
367
586
|
const softDeleteField = 'deletedAt' in def.fields || 'deleted_at' in def.fields;
|
|
587
|
+
// A lifecycle model has declared what a delete does; its generated deletedAt is not an author field.
|
|
368
588
|
if (needsSoftDeleteDeclaration({ hasDeletedAtField: softDeleteField, hasPublicRead, grantsDelete }) && def.softDelete === undefined) {
|
|
369
589
|
// DURABILITY FIRST. The old message called this "a visibility decision", which is false
|
|
370
590
|
// for exactly the models that broke: with no public read, `softDelete: true` adds no
|
|
@@ -450,17 +670,22 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
450
670
|
);
|
|
451
671
|
}
|
|
452
672
|
|
|
673
|
+
assertCheckNames(table, def.constraints ?? []);
|
|
674
|
+
|
|
453
675
|
return {
|
|
454
676
|
table,
|
|
455
|
-
fields
|
|
456
|
-
|
|
677
|
+
fields,
|
|
678
|
+
// Appended after every author check above: the internal set may name the generated columns.
|
|
679
|
+
abilities: lifecycle ? [...(def.abilities ?? []), ...lifecycleAbilities()] : def.abilities ?? [],
|
|
457
680
|
hooks: def.hooks,
|
|
458
681
|
relations: def.relations ?? {},
|
|
459
682
|
primaryKey,
|
|
460
683
|
constraints: def.constraints ?? [],
|
|
461
684
|
writtenBy: def.writtenBy ?? 'app',
|
|
462
685
|
privileges: def.privileges ?? {},
|
|
686
|
+
// Stays false on a lifecycle model: its reads are filtered by the RLS guard (L1.2a), its deletes arrive with the triggers.
|
|
463
687
|
softDelete: def.softDelete ?? false,
|
|
688
|
+
...(lifecycle ? { lifecycle: true as const } : {}),
|
|
464
689
|
schema: def.schema ?? 'public',
|
|
465
690
|
private: def.private ?? false,
|
|
466
691
|
rls: def.rls ?? true,
|
package/src/module.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* See docs/plans/v3-module-complete-generation.md.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import type
|
|
22
|
+
import { LIFECYCLE_OWNER_ROLE, type ModelDescriptor } from './model';
|
|
23
23
|
import type { DerivedDescriptor, SequenceDescriptor } from './derived';
|
|
24
24
|
import { rejectHoles, functionArgSignature } from './derived';
|
|
25
25
|
import { deriveFunctionSchemas } from './derive';
|
|
@@ -223,7 +223,12 @@ export function moduleDerived(modules: Module[]): DerivedDescriptor[] {
|
|
|
223
223
|
return modules.flatMap((m) => m.derived);
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
/**
|
|
226
|
+
/**
|
|
227
|
+
* The union of every module's governed roles — deduped and sorted. Widen-only by construction.
|
|
228
|
+
* A module holding a lifecycle model also governs {@link LIFECYCLE_OWNER_ROLE}, which the
|
|
229
|
+
* compiler grants on that model.
|
|
230
|
+
*/
|
|
227
231
|
export function moduleGovernedRoles(modules: Module[]): string[] {
|
|
228
|
-
|
|
232
|
+
const lifecycle = modules.some((m) => m.models.some((model) => model.lifecycle));
|
|
233
|
+
return [...new Set([...modules.flatMap((m) => m.governedRoles), ...(lifecycle ? [LIFECYCLE_OWNER_ROLE] : [])])].sort();
|
|
229
234
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The link-time half of the relation display options: the checks and the orderBy normalization
|
|
3
|
+
* that need the resolved TARGET model. A leaf: it imports only types, so linkModels can use it
|
|
4
|
+
* without an import cycle through relations.ts (which imports referenceThunk from link.ts).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ModelDescriptor } from './model';
|
|
8
|
+
import type { Relation, RelationOrder } from './relations';
|
|
9
|
+
|
|
10
|
+
/** True when linkModels must evaluate this relation's target to normalize its display options. */
|
|
11
|
+
export function hasDisplayOptions(rel: Relation): boolean {
|
|
12
|
+
return rel.kind === 'hasOne' || rel.orderBy !== undefined || rel.where !== undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const nameOf = (m: ModelDescriptor): string => (!m.schema || m.schema === 'public' ? m.table : `${m.schema}.${m.table}`);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The normalized orderBy of a hasMany or hasOne against its resolved TARGET, or undefined when
|
|
19
|
+
* there is none (a hasMany with no orderBy keeps today's order: none is added). Pure: linkModels
|
|
20
|
+
* writes the result, once every reference in the set has resolved.
|
|
21
|
+
*
|
|
22
|
+
* Every orderBy and where field must be a field of the target. The target's primary key is
|
|
23
|
+
* appended, ascending, for each key column the orderBy does not list, so the order is total. A
|
|
24
|
+
* hasOne with no orderBy is ordered by that key. Idempotent: a normalized orderBy lists the key.
|
|
25
|
+
*/
|
|
26
|
+
export function normalizeRelationDisplay(model: string, name: string, rel: Relation, target: ModelDescriptor): RelationOrder[] | undefined {
|
|
27
|
+
const at = `model '${model}': relation '${name}'`;
|
|
28
|
+
for (const key of Object.keys(rel.where ?? {})) {
|
|
29
|
+
if (!Object.prototype.hasOwnProperty.call(target.fields, key)) {
|
|
30
|
+
throw new Error(`${at}: where field '${key}' is not a field of model '${nameOf(target)}'`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (rel.orderBy === undefined && rel.kind !== 'hasOne') return undefined;
|
|
34
|
+
const declared = rel.orderBy ?? [];
|
|
35
|
+
for (const term of declared) {
|
|
36
|
+
if (!Object.prototype.hasOwnProperty.call(target.fields, term.field)) {
|
|
37
|
+
throw new Error(`${at}: orderBy field '${term.field}' is not a field of model '${nameOf(target)}'`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (target.primaryKey.length === 0) {
|
|
41
|
+
throw new Error(`${at}: model '${nameOf(target)}' has no primary key to break orderBy ties; declare one`);
|
|
42
|
+
}
|
|
43
|
+
const listed = new Set(declared.map((t) => t.field));
|
|
44
|
+
return [
|
|
45
|
+
...declared.map((t) => ({ field: t.field, order: t.order })),
|
|
46
|
+
...target.primaryKey.filter((k) => !listed.has(k)).map((k) => ({ field: k, order: 'asc' as const })),
|
|
47
|
+
];
|
|
48
|
+
}
|
package/src/relations.ts
CHANGED
|
@@ -4,16 +4,74 @@
|
|
|
4
4
|
*
|
|
5
5
|
* belongsTo(() => Author, 'authorId') // this.authorId → Author.<pk>
|
|
6
6
|
* hasMany(() => Comment, 'postId') // this.<pk> → Comment.postId
|
|
7
|
+
* hasMany(() => Comment, 'postId', { dependent: 'nullify' }) // delete a post → postId NULL
|
|
8
|
+
* hasOne(() => Image, 'postId') // the first Image whose postId is this.<pk>
|
|
7
9
|
*
|
|
8
10
|
* A table-name target avoids sibling imports; linkModels resolves it against the
|
|
9
11
|
* complete set. Model thunks remain supported for forward references.
|
|
12
|
+
*
|
|
13
|
+
* The third argument is a string (the `references` key, as it always was) or an options object
|
|
14
|
+
* (the ratified F7 API). `dependent` is the parent's word about delete behaviour; it compiles to
|
|
15
|
+
* the ON DELETE of the CHILD's foreign key (see dependents.ts). `orderBy`, `limit` and `where`
|
|
16
|
+
* are DISPLAY ONLY: they emit no SQL and never narrow a dependent. Their shape is checked at
|
|
17
|
+
* defineModel; their field names are TARGET fields, so linkModels checks them and normalizes the
|
|
18
|
+
* orderBy once (relation-display.ts): the target's primary key is appended as the tie-breaker.
|
|
10
19
|
*/
|
|
11
20
|
|
|
12
21
|
import type { ModelDescriptor } from './model';
|
|
13
22
|
import { referenceThunk } from './link';
|
|
14
23
|
|
|
24
|
+
/** What deleting a parent row does to its children, through the child's foreign key. */
|
|
25
|
+
export type HasManyDependent = 'restrict' | 'nullify' | 'cascade';
|
|
26
|
+
|
|
27
|
+
/** One ordering term of a relation's display order. */
|
|
28
|
+
export interface RelationOrder {
|
|
29
|
+
field: string;
|
|
30
|
+
order: 'asc' | 'desc';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** An equality filter on the related rows: field → value. */
|
|
34
|
+
export type RelationWhere = Record<string, string | number | boolean | null>;
|
|
35
|
+
|
|
36
|
+
export interface BelongsToOptions {
|
|
37
|
+
/** The key on the target, when it is not the target's primary key. */
|
|
38
|
+
references?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface HasManyOptions {
|
|
42
|
+
/** The key on this table, when it is not this table's primary key. */
|
|
43
|
+
references?: string;
|
|
44
|
+
/**
|
|
45
|
+
* What a delete of this row does to the target rows, through the target's foreign key on
|
|
46
|
+
* `column`: `'restrict'` → ON DELETE RESTRICT, `'nullify'` → ON DELETE SET NULL, `'cascade'` →
|
|
47
|
+
* ON DELETE CASCADE (Rails `delete_all`: the database removes the children and runs none of
|
|
48
|
+
* their hooks). The target field must declare `.references()` to this model; a dependent never
|
|
49
|
+
* creates a foreign key.
|
|
50
|
+
*/
|
|
51
|
+
dependent?: HasManyDependent;
|
|
52
|
+
/**
|
|
53
|
+
* Display order: fields of the target. linkModels appends the target's primary key as the
|
|
54
|
+
* tie-breaker. Without it the order is unchanged (none is added).
|
|
55
|
+
*/
|
|
56
|
+
orderBy?: RelationOrder[];
|
|
57
|
+
/** Display limit: a positive integer. */
|
|
58
|
+
limit?: number;
|
|
59
|
+
/** Display filter: equality on target fields; `null` means IS NULL. Not access control. */
|
|
60
|
+
where?: RelationWhere;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** hasOne is one row: no `limit`, and no `dependent` in v1 (declare it on a hasMany). */
|
|
64
|
+
export interface HasOneOptions {
|
|
65
|
+
/** The key on this table, when it is not this table's primary key. */
|
|
66
|
+
references?: string;
|
|
67
|
+
/** Which row: target fields, then the target's primary key. Default: the primary key ascending. */
|
|
68
|
+
orderBy?: RelationOrder[];
|
|
69
|
+
/** Display filter: equality on target fields; `null` means IS NULL. Not access control. */
|
|
70
|
+
where?: RelationWhere;
|
|
71
|
+
}
|
|
72
|
+
|
|
15
73
|
export interface Relation {
|
|
16
|
-
kind: 'belongsTo' | 'hasMany';
|
|
74
|
+
kind: 'belongsTo' | 'hasMany' | 'hasOne';
|
|
17
75
|
/** Thunk to the related model (forward-reference safe). */
|
|
18
76
|
target: () => ModelDescriptor;
|
|
19
77
|
/**
|
|
@@ -28,22 +86,166 @@ export interface Relation {
|
|
|
28
86
|
* - hasMany: overrides RelationConfig.from (default: this table's pk)
|
|
29
87
|
*/
|
|
30
88
|
references?: string;
|
|
89
|
+
/** hasMany only. Present only when declared. */
|
|
90
|
+
dependent?: HasManyDependent;
|
|
91
|
+
/**
|
|
92
|
+
* Present only when declared, or on a hasOne. After linkModels it is normalized: it ends with
|
|
93
|
+
* every primary-key column of the target (a hasOne with none declared is the key ascending).
|
|
94
|
+
*/
|
|
95
|
+
orderBy?: RelationOrder[];
|
|
96
|
+
limit?: number;
|
|
97
|
+
where?: RelationWhere;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type Kind = Relation['kind'];
|
|
101
|
+
|
|
102
|
+
const OPTION_KEYS: Record<Kind, readonly string[]> = {
|
|
103
|
+
belongsTo: ['references'],
|
|
104
|
+
hasMany: ['references', 'dependent', 'orderBy', 'limit', 'where'],
|
|
105
|
+
hasOne: ['references', 'orderBy', 'where'],
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build a descriptor. A string or absent third argument yields exactly the legacy shape
|
|
110
|
+
* (`references` always present as a key), so an existing model's descriptor is unchanged. An
|
|
111
|
+
* options object adds a key only for an option it declares.
|
|
112
|
+
*/
|
|
113
|
+
function relation(kind: Kind, target: (() => ModelDescriptor) | string, column: string, opts: unknown): Relation {
|
|
114
|
+
const call = `${kind}(…, '${column}')`;
|
|
115
|
+
if (opts === undefined || typeof opts === 'string') {
|
|
116
|
+
return { kind, target: referenceThunk(target), column, references: opts };
|
|
117
|
+
}
|
|
118
|
+
if (opts === null || typeof opts !== 'object' || Array.isArray(opts)) {
|
|
119
|
+
throw new Error(`${call}: the third argument is ${opts === null ? 'null' : Array.isArray(opts) ? 'an array' : typeof opts}; pass a references string or an options object`);
|
|
120
|
+
}
|
|
121
|
+
const allowed = OPTION_KEYS[kind];
|
|
122
|
+
for (const key of Object.keys(opts)) {
|
|
123
|
+
if (allowed.includes(key)) continue;
|
|
124
|
+
if (kind === 'hasOne' && key === 'dependent') {
|
|
125
|
+
throw new Error(`${call}: hasOne takes no dependent; declare dependent on a hasMany over the same column`);
|
|
126
|
+
}
|
|
127
|
+
if (kind === 'hasOne' && key === 'limit') throw new Error(`${call}: hasOne takes no limit: it is one row`);
|
|
128
|
+
const hint = key === 'dependent' ? "; dependent is declared on the parent's hasMany" : '';
|
|
129
|
+
throw new Error(`${call}: unknown option '${key}' (expected ${allowed.join(', ')})${hint}`);
|
|
130
|
+
}
|
|
131
|
+
const o = opts as HasManyOptions;
|
|
132
|
+
if (o.references !== undefined && typeof o.references !== 'string') {
|
|
133
|
+
throw new Error(`${call}: references must be a string`);
|
|
134
|
+
}
|
|
135
|
+
const out: Relation = { kind, target: referenceThunk(target), column, references: o.references };
|
|
136
|
+
if (o.dependent !== undefined) out.dependent = o.dependent;
|
|
137
|
+
// Copied, so a later edit to the caller's array or object changes nothing. Shapes are checked
|
|
138
|
+
// at defineModel (assertRelationShape); a malformed value is kept as given for that message.
|
|
139
|
+
if (o.orderBy !== undefined) {
|
|
140
|
+
out.orderBy = Array.isArray(o.orderBy)
|
|
141
|
+
? o.orderBy.map((t) => (t !== null && typeof t === 'object' && !Array.isArray(t) ? { ...t } : t))
|
|
142
|
+
: o.orderBy;
|
|
143
|
+
}
|
|
144
|
+
if (o.limit !== undefined) out.limit = o.limit;
|
|
145
|
+
if (o.where !== undefined) {
|
|
146
|
+
out.where = o.where !== null && typeof o.where === 'object' && !Array.isArray(o.where) ? { ...o.where } : o.where;
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
31
149
|
}
|
|
32
150
|
|
|
33
151
|
/** Many-to-one: this row points at one target row via `column`. */
|
|
34
152
|
export function belongsTo(
|
|
35
153
|
target: (() => ModelDescriptor) | string,
|
|
36
154
|
column: string,
|
|
37
|
-
|
|
155
|
+
opts?: string | BelongsToOptions,
|
|
38
156
|
): Relation {
|
|
39
|
-
return
|
|
157
|
+
return relation('belongsTo', target, column, opts);
|
|
40
158
|
}
|
|
41
159
|
|
|
42
160
|
/** One-to-many: target rows point back at this row via `column`. */
|
|
43
161
|
export function hasMany(
|
|
44
162
|
target: (() => ModelDescriptor) | string,
|
|
45
163
|
column: string,
|
|
46
|
-
|
|
164
|
+
opts?: string | HasManyOptions,
|
|
165
|
+
): Relation {
|
|
166
|
+
return relation('hasMany', target, column, opts);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* One-to-one from the parent side: the first target row pointing back via `column`, in `orderBy`
|
|
171
|
+
* (default: the target's primary key ascending). Display only: it compiles no SQL.
|
|
172
|
+
*/
|
|
173
|
+
export function hasOne(
|
|
174
|
+
target: (() => ModelDescriptor) | string,
|
|
175
|
+
column: string,
|
|
176
|
+
opts?: HasOneOptions,
|
|
47
177
|
): Relation {
|
|
48
|
-
return
|
|
178
|
+
return relation('hasOne', target, column, opts);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The declaration rules that need no other model, checked by defineModel: an option or kind that
|
|
183
|
+
* is not implemented yet is refused by name, never silently ignored.
|
|
184
|
+
*/
|
|
185
|
+
export function assertRelationShape(model: string, name: string, rel: Relation): void {
|
|
186
|
+
const at = `model '${model}': relation '${name}'`;
|
|
187
|
+
if (rel.kind !== 'belongsTo' && rel.kind !== 'hasMany' && rel.kind !== 'hasOne') {
|
|
188
|
+
throw new Error(`${at}: unknown relation kind '${String(rel.kind)}' (expected belongsTo, hasMany or hasOne)`);
|
|
189
|
+
}
|
|
190
|
+
if (rel.kind === 'belongsTo') {
|
|
191
|
+
for (const key of ['orderBy', 'limit', 'where'] as const) {
|
|
192
|
+
if (rel[key] !== undefined) throw new Error(`${at}: ${key} is a hasMany or hasOne option; belongsTo takes none`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (rel.kind === 'hasOne' && rel.limit !== undefined) throw new Error(`${at}: limit is a hasMany option; hasOne is one row`);
|
|
196
|
+
assertDisplayShape(at, rel);
|
|
197
|
+
if (rel.dependent === undefined) return;
|
|
198
|
+
if (rel.kind !== 'hasMany') throw new Error(`${at}: dependent is a hasMany option; ${rel.kind} takes none`);
|
|
199
|
+
if (rel.dependent !== 'restrict' && rel.dependent !== 'nullify' && rel.dependent !== 'cascade') {
|
|
200
|
+
throw new Error(`${at}: dependent '${String(rel.dependent)}' is not a dependent value (expected 'restrict', 'nullify' or 'cascade')`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const isPlainObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
205
|
+
|
|
206
|
+
/** A value as an error message shows it. */
|
|
207
|
+
const shown = (v: unknown): string => {
|
|
208
|
+
if (v === undefined || typeof v === 'number') return String(v);
|
|
209
|
+
if (typeof v === 'string') return `'${v}'`;
|
|
210
|
+
try { return JSON.stringify(v) ?? String(v); } catch { return String(v); }
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The display options' shape, checked at defineModel (no other model needed). Their field names
|
|
215
|
+
* name TARGET fields, so `normalizeRelationDisplay` (relation-display.ts) checks those once the target is known.
|
|
216
|
+
*/
|
|
217
|
+
function assertDisplayShape(at: string, rel: Relation): void {
|
|
218
|
+
if (rel.orderBy !== undefined) {
|
|
219
|
+
const orderBy: unknown = rel.orderBy;
|
|
220
|
+
if (!Array.isArray(orderBy) || orderBy.length === 0) throw new Error(`${at}: orderBy must be a non-empty array of { field, order }`);
|
|
221
|
+
const seen = new Set<string>();
|
|
222
|
+
orderBy.forEach((term: unknown, i) => {
|
|
223
|
+
if (!isPlainObject(term) || typeof term.field !== 'string' || term.field === ''
|
|
224
|
+
|| Object.keys(term).some((k) => k !== 'field' && k !== 'order')) {
|
|
225
|
+
throw new Error(`${at}: orderBy[${i}] must be { field: string, order: 'asc' | 'desc' }`);
|
|
226
|
+
}
|
|
227
|
+
if (term.order !== 'asc' && term.order !== 'desc') {
|
|
228
|
+
throw new Error(`${at}: orderBy[${i}].order is ${shown(term.order)}; expected 'asc' or 'desc'`);
|
|
229
|
+
}
|
|
230
|
+
if (seen.has(term.field)) throw new Error(`${at}: orderBy lists field '${term.field}' twice`);
|
|
231
|
+
seen.add(term.field);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
if (rel.limit !== undefined) {
|
|
235
|
+
const limit: unknown = rel.limit;
|
|
236
|
+
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1) {
|
|
237
|
+
throw new Error(`${at}: limit must be a positive integer, got ${shown(limit)}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (rel.where !== undefined) {
|
|
241
|
+
const where: unknown = rel.where;
|
|
242
|
+
if (!isPlainObject(where) || Object.keys(where).length === 0) {
|
|
243
|
+
throw new Error(`${at}: where must be a non-empty object of field → value`);
|
|
244
|
+
}
|
|
245
|
+
for (const [key, value] of Object.entries(where)) {
|
|
246
|
+
const ok = value === null || typeof value === 'string' || typeof value === 'boolean'
|
|
247
|
+
|| (typeof value === 'number' && Number.isFinite(value));
|
|
248
|
+
if (!ok) throw new Error(`${at}: where '${key}' must be a string, a finite number, a boolean or null, got ${shown(value)}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
49
251
|
}
|
package/src/wire.ts
CHANGED
|
@@ -45,8 +45,9 @@
|
|
|
45
45
|
* accept risks wrong ownership or a wrong derived object on a production database.
|
|
46
46
|
*
|
|
47
47
|
* v1 — the first version. Shapes as they stood when the guard was introduced.
|
|
48
|
+
* v2 — ProvisionWirePayload gains `lifecycleRole` (provision the reserved lifecycle role).
|
|
48
49
|
*/
|
|
49
|
-
export const MODEL_WIRE_CONTRACT =
|
|
50
|
+
export const MODEL_WIRE_CONTRACT = 2;
|
|
50
51
|
|
|
51
52
|
/**
|
|
52
53
|
* Every Model-derived wire payload carries this. The server refuses a payload whose `modelContract`
|
|
@@ -101,6 +102,8 @@ export interface Adoptee {
|
|
|
101
102
|
export interface ProvisionWirePayload extends ModelWireEnvelope {
|
|
102
103
|
/** The declared schema set, ordered and `public`-trailing — set as the ROLE default search_path. */
|
|
103
104
|
searchPath?: string[];
|
|
105
|
+
/** The Models declare a `lifecycle: true` model: provision the reserved role everystack_lifecycle. */
|
|
106
|
+
lifecycleRole?: boolean;
|
|
104
107
|
/** Accepted by the action's payload type; convergence itself runs as db:converge-ownership. */
|
|
105
108
|
declaredOwners?: DeclaredOwner[];
|
|
106
109
|
declaredObjects?: Adoptee[];
|