@everystack/model 0.4.23 → 0.4.25

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/src/model.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { dependentWhereRefusal } from './dependents';
1
2
  /**
2
3
  * `defineModel` — Layer 2. One call colocates everything about a table: its
3
4
  * fields, its authorization (`abilities`), its mutation hooks, and its relations.
@@ -22,34 +23,34 @@ import type { RawSql } from './raw-sql';
22
23
  import { validateTriggersFor, type TriggerSpec, type DependsOnRef } from './derived';
23
24
  import { TABLE_PRIVILEGES, type TablePrivilege } from './privileges';
24
25
 
26
+ import { assertDeleteHookNames, assertDestroyHooks, type AfterLifecycleHook, type BeforeLifecycleHook, type DestroyHook } from './lifecycle-hooks';
27
+ export type { AfterLifecycleHook, BeforeLifecycleHook, DestroyContext, DestroyHook, HookVeto, LifecycleContext } from './lifecycle-hooks';
28
+
25
29
  /**
26
30
  * Per-table mutation hooks. Imperative function bodies the framework cannot
27
- * absorb (purge an S3 object on delete, stamp a column on create) — held
31
+ * absorb (remove a stored object after destroy, set a column on create) — held
28
32
  * verbatim and passed through to the handler's `hooks`.
29
33
  */
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
-
39
34
  export interface ModelHooks {
40
35
  beforeCreate?: (...args: any[]) => any;
41
36
  afterCreate?: (...args: any[]) => any;
42
37
  beforeUpdate?: (...args: any[]) => any;
43
38
  afterUpdate?: (...args: any[]) => any;
44
- beforeDelete?: (...args: any[]) => any;
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. */
39
+ /** Before commit, once per matched root. Cascaded children run no hook; use deleteWhen. */
40
+ beforeDelete?: BeforeLifecycleHook;
41
+ /** After commit, once per request with the root rows. Best-effort. */
42
+ afterDelete?: AfterLifecycleHook;
43
+ /** Before an SQL destroy attempt, per candidate root. A veto or throw holds that root.
44
+ * The row may survive the attempt; do nothing irreversible here. No transaction is passed.
45
+ * Hooks may repeat after a failed commit. Treat the database row as read-only. */
49
46
  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. */
47
+ /** After commit, only for removed roots; FK cascade children run no hook. Best-effort:
48
+ * a crash can lose the call. Cannot undo removal. Treat the database row as read-only. */
52
49
  afterDestroy?: DestroyHook;
50
+ /** Lifecycle tables: after `POST {table}/restore` commits, with `[{ <key>: id }]`, the batch and the counts. Best-effort. */
51
+ afterRestore?: AfterLifecycleHook;
52
+ /** Reserved for a future release; createHandler refuses it. */
53
+ beforeRestore?: never;
53
54
  }
54
55
 
55
56
  /**
@@ -119,7 +120,7 @@ export interface ModelDefinition {
119
120
  *
120
121
  * The guard used to ask only about the public read, which is the visibility axis. That let a
121
122
  * model with an owner-scoped read and a `delete` ability default to `false` and begin
122
- * destroying rows — with an S3 purge job keyed on `deleted_at IS NOT NULL`, that orphaned
123
+ * destroying rows — with an S3 cleanup job keyed on `deleted_at IS NOT NULL`, that orphaned
123
124
  * every file it should have cleaned up. On those models the flag adds no policy at all, so
124
125
  * visibility was never the axis that mattered.
125
126
  *
@@ -137,9 +138,13 @@ export interface ModelDefinition {
137
138
  * ({@link LIFECYCLE_COLUMNS}): `deletedAt` timestamptz, `deletedBy` text (no foreign key),
138
139
  * `deletedBatch` uuid, `deletedXid` bigint. The author never declares them, never combines
139
140
  * lifecycle with `softDelete`, and declares a single-column primary key; each is refused here.
140
- * `true` is the only value today.
141
+ *
142
+ * `{ deleteWhen: sql\`<predicate>\` }` is lifecycle too, with a guard: a row may be marked
143
+ * deleted only when the predicate is TRUE for it (FALSE or NULL refuses, SQLSTATE 23514), whether
144
+ * the delete is the row's own or a parent's cascade. Bare column names, like a policy predicate.
145
+ * Destroy does not re-check this predicate after deletion. `{}` is `true`.
141
146
  */
142
- lifecycle?: true;
147
+ lifecycle?: true | LifecycleOptions;
143
148
  /**
144
149
  * The Postgres schema the table lives in. Default `'public'`. Package-owned
145
150
  * tables declare their own (`'dist'`, `'ops'`, `'auth'`) so one db:generate run
@@ -220,8 +225,11 @@ export interface ModelDescriptor {
220
225
  privileges: Record<string, TablePrivilege[]>;
221
226
  /** `true` excludes `deleted_at IS NULL` rows from public reads and the data API. Declared, never inferred. */
222
227
  softDelete: boolean;
223
- /** Present only when declared: `fields` carries the generated {@link LIFECYCLE_COLUMNS}. */
228
+ /** Present only when declared: `fields` carries the generated {@link LIFECYCLE_COLUMNS}. Always
229
+ * `true` on the descriptor, whichever form the definition used. */
224
230
  lifecycle?: true;
231
+ /** `lifecycle: { deleteWhen }`'s predicate text. Absent when there is no guard. */
232
+ deleteWhen?: string;
225
233
  /** The resolved Postgres schema (default `'public'`). Package tables live in their own. */
226
234
  schema: string;
227
235
  /** `true` removes the table from the generic data API (not in `exposedTables`). Default `false`. */
@@ -307,14 +315,40 @@ function lifecycleFields(): Record<(typeof LIFECYCLE_COLUMNS)[number], FieldBuil
307
315
  };
308
316
  }
309
317
 
318
+ /** `lifecycle: { … }` options. */
319
+ export interface LifecycleOptions {
320
+ /** A row may be marked deleted only when this is TRUE for it; FALSE or NULL refuses. */
321
+ deleteWhen?: RawSql;
322
+ }
323
+
324
+ /** True for `lifecycle: true` and for `lifecycle: { … }` (validated by assertLifecycle). */
325
+ function isLifecycle(def: ModelDefinition): boolean {
326
+ return def.lifecycle === true || (typeof def.lifecycle === 'object' && def.lifecycle !== null);
327
+ }
328
+
329
+ /** The guard predicate text, or undefined. Call after assertLifecycle. */
330
+ function deleteWhenOf(def: ModelDefinition): string | undefined {
331
+ const value = def.lifecycle;
332
+ return typeof value === 'object' && value !== null && value.deleteWhen !== undefined ? value.deleteWhen.sql : undefined;
333
+ }
334
+
310
335
  function assertLifecycle(table: string, def: ModelDefinition): void {
311
336
  const value: unknown = def.lifecycle;
312
337
  if (value === undefined) return;
313
338
  if (value === false) {
314
339
  throw new Error(`model '${table}': lifecycle: false is not supported yet. Omit the key for a model without lifecycle.`);
315
340
  }
316
- if (value !== true) {
317
- throw new Error(`model '${table}': lifecycle: ${JSON.stringify(value)} — the only value is lifecycle: true.`);
341
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
342
+ const unknown = Object.keys(value).filter((k) => k !== 'deleteWhen');
343
+ if (unknown.length > 0) {
344
+ throw new Error(`model '${table}': lifecycle: { ${unknown.join(', ')} } — the only key is deleteWhen (lifecycle: { deleteWhen: sql\`…\` }).`);
345
+ }
346
+ const when = (value as { deleteWhen?: unknown }).deleteWhen;
347
+ if (when !== undefined && !(typeof when === 'object' && when !== null && typeof (when as RawSql).sql === 'string' && (when as RawSql).sql.trim() !== '')) {
348
+ throw new Error(`model '${table}': lifecycle: { deleteWhen } — deleteWhen must be sql\`…\`, a non-empty predicate over this table's columns.`);
349
+ }
350
+ } else if (value !== true) {
351
+ throw new Error(`model '${table}': lifecycle: ${JSON.stringify(value)} — the only values are lifecycle: true and lifecycle: { deleteWhen: sql\`…\` }.`);
318
352
  }
319
353
  if (def.softDelete !== undefined) {
320
354
  throw new Error(
@@ -340,7 +374,7 @@ function assertLifecycle(table: string, def: ModelDefinition): void {
340
374
  }
341
375
  // A cascade into this table, declared on this table's own foreign key. A parent's
342
376
  // 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).
377
+ // lifecycle parent: the triggers soft-delete the children, and destroy follows the real CASCADE).
344
378
  const tableName = def.schema && def.schema !== 'public' ? `${def.schema}.${table}` : table;
345
379
  for (const [name, f] of Object.entries(def.fields)) {
346
380
  if (f.spec.onDelete !== 'cascade') continue;
@@ -358,6 +392,10 @@ function assertLifecycle(table: string, def: ModelDefinition): void {
358
392
  `Mark exactly one field .primaryKey().`,
359
393
  );
360
394
  }
395
+ const [key, keyField] = Object.entries(def.fields).find(([, f]) => f.spec.isPrimaryKey)!;
396
+ if (keyField.spec.isArray) {
397
+ throw new Error(`model '${tableName}': lifecycle needs a primary key PostgreSQL can list in a key array; ${key} is an array type`);
398
+ }
361
399
  // The generated trigger and function names are `<table>_lifecycle_transition` and `_guard`.
362
400
  // PostgreSQL silently truncates an identifier past 63 bytes, so the declared name would not be the live one.
363
401
  // UTF-8 bytes, as PostgreSQL counts them. TextEncoder rather than Buffer: a model also loads outside Node.
@@ -375,10 +413,12 @@ function assertLifecycle(table: string, def: ModelDefinition): void {
375
413
  * Restore runs the table's delete rule inside a SECURITY DEFINER function as the lifecycle role.
376
414
  * Refuse a delete rule whose meaning would change there: a subquery (`via`, or SELECT in
377
415
  * 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.
416
+ * the login, not the caller. TRUNCATE is refused because it skips the delete triggers; TRIGGER
417
+ * because its holder can create triggers on the table; REFERENCES because its holder can
418
+ * add a foreign key that blocks or cascades a destroy.
379
419
  */
380
420
  function assertLifecycleRestoreSafe(table: string, def: ModelDefinition): void {
381
- if (def.lifecycle !== true) return;
421
+ if (!isLifecycle(def)) return;
382
422
  const why = 'restore runs the delete rule inside a SECURITY DEFINER function as the lifecycle owner';
383
423
  const abilities = def.abilities ?? [];
384
424
  // A role-less (or authenticated) delete on a model scoped only through `via` compiles to the
@@ -427,14 +467,31 @@ function assertLifecycleRestoreSafe(table: string, def: ModelDefinition): void {
427
467
  `triggers, so it would erase rows past the soft delete and restore. Remove TRUNCATE from the entry.`,
428
468
  );
429
469
  }
470
+ const beyondDelete: Record<string, string> = {
471
+ TRIGGER: 'TRIGGER lets the holder create triggers on the table, so its own code would run inside every delete and destroy',
472
+ REFERENCES: 'REFERENCES lets the holder add a foreign key that blocks or cascades a destroy',
473
+ };
474
+ for (const [role, privs] of Object.entries(def.privileges ?? {})) {
475
+ if (!Array.isArray(privs)) continue;
476
+ for (const priv of ['TRIGGER', 'REFERENCES'] as const) {
477
+ if (!privs.includes(priv)) continue;
478
+ throw new Error(
479
+ `model '${table}': privileges['${role}'] grants ${priv} on a lifecycle: true model — ${beyondDelete[priv]}. ` +
480
+ `Remove ${priv} from the entry.`,
481
+ );
482
+ }
483
+ }
430
484
  }
431
485
 
432
486
  export function defineModel(table: string, def: ModelDefinition): ModelDescriptor {
487
+ assertDeleteHookNames(table, def.hooks);
488
+ assertDestroyHooks(table, def.hooks, isLifecycle(def));
433
489
  // Refuse first, then inject: the author-declared collisions are checked against what the author wrote.
434
490
  assertRoleNotReserved(table, def);
435
491
  assertLifecycle(table, def);
436
492
  assertLifecycleRestoreSafe(table, def);
437
- const lifecycle = def.lifecycle === true;
493
+ const lifecycle = isLifecycle(def);
494
+ const deleteWhen = deleteWhenOf(def);
438
495
  const fields: Record<string, FieldBuilder> = lifecycle ? { ...def.fields, ...lifecycleFields() } : def.fields;
439
496
 
440
497
  for (const [name, field] of Object.entries(def.fields)) {
@@ -443,6 +500,8 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
443
500
  for (const [name, relation] of Object.entries(def.relations ?? {})) {
444
501
  assertRelationShape(def.schema && def.schema !== 'public' ? `${def.schema}.${table}` : table, name, relation);
445
502
  referenceLocation(relation.target, `${table}.${name}`);
503
+ const filtered = lifecycle ? dependentWhereRefusal(def.schema && def.schema !== 'public' ? `${def.schema}.${table}` : table, name, relation) : undefined;
504
+ if (filtered) throw new Error(filtered);
446
505
  }
447
506
  const primaryKey = Object.entries(def.fields)
448
507
  .filter(([, f]) => f.spec.isPrimaryKey)
@@ -453,6 +512,28 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
453
512
  throw new Error(`model '${table}': can('execute') is a function ability — declare it on the defineFunction it gates.`);
454
513
  }
455
514
 
515
+ if (a.condition.deleted !== undefined) {
516
+ if (a.action !== 'read') {
517
+ throw new Error(`model '${table}': deleted applies only to can('read'), not can('${a.action}').`);
518
+ }
519
+ if (a.condition.deleted !== 'include' && a.condition.deleted !== 'only') {
520
+ throw new Error(`model '${table}': can('read', { deleted: ${JSON.stringify(a.condition.deleted)} }) — deleted must be 'include' or 'only'.`);
521
+ }
522
+ if (!lifecycle) {
523
+ throw new Error(`model '${table}': can('read', { deleted }) — deleted applies only to a lifecycle model; this model has no deleted rows to include.`);
524
+ }
525
+ if (!a.condition.role) {
526
+ throw new Error(`model '${table}': can('read', { deleted }) — deleted needs a role: it changes that role's own read policy. Name the role that may see deleted rows.`);
527
+ }
528
+ // A public tier is every caller of the API, and db:pull could not fold the table back.
529
+ if (['anon', 'authenticated', 'public'].includes(a.condition.role.toLowerCase())) {
530
+ throw new Error(
531
+ `model '${table}': can('read', { role: '${a.condition.role}', deleted }) — deleted rows are for a named internal role, ` +
532
+ `never a public tier (anon, authenticated, public). Name the role that searches or restores, such as 'support_role'.`,
533
+ );
534
+ }
535
+ }
536
+
456
537
  // A column list is authorization, not a selection hint. If it reaches a table-grant path
457
538
  // unnoticed, Postgres receives a whole-table privilege. Admit only the shapes the column
458
539
  // compiler understands, and reject an empty list because it is indistinguishable from no
@@ -484,7 +565,9 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
484
565
  );
485
566
  }
486
567
  for (const column of a.condition.columns) {
487
- if (!(column in def.fields)) {
568
+ // deletedAt is visible to every reader of a lifecycle model, so a column read may list it.
569
+ // deletedBy, deletedBatch and deletedXid are hidden by design and stay refused.
570
+ if (!(column in def.fields) && !(lifecycle && a.action === 'read' && column === 'deletedAt')) {
488
571
  throw new Error(
489
572
  `model '${table}': columns names '${column}', but no such field exists. Column abilities use model field keys.`,
490
573
  );
@@ -686,6 +769,7 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
686
769
  // Stays false on a lifecycle model: its reads are filtered by the RLS guard (L1.2a), its deletes arrive with the triggers.
687
770
  softDelete: def.softDelete ?? false,
688
771
  ...(lifecycle ? { lifecycle: true as const } : {}),
772
+ ...(deleteWhen !== undefined ? { deleteWhen } : {}),
689
773
  schema: def.schema ?? 'public',
690
774
  private: def.private ?? false,
691
775
  rls: def.rls ?? true,
package/src/pg-type.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { FieldSpec } from './field';
2
+
1
3
  /**
2
4
  * pg-type — the ONE definition of a column type's spelling.
3
5
  *
@@ -95,3 +97,67 @@ export function pgTypeRefusal(typeName: string): string | null {
95
97
  }
96
98
  return null;
97
99
  }
100
+
101
+ /**
102
+ * Postgres type for a field, with the precision drizzle-kit would emit — in
103
+ * `format_type`'s vocabulary, because this feeds the IR that diffs against
104
+ * introspection. A serial field's *column type* is integer/bigint (`serial` is
105
+ * DDL shorthand, not a type — `format_type` never reports it); the sequence
106
+ * default is carried separately (see compileTableSchema), and the DDL renderers
107
+ * spell the shorthand (see SERIAL_DDL).
108
+ */
109
+ const PG_TYPE: Record<string, string> = {
110
+ text: 'text',
111
+ uuid: 'uuid',
112
+ integer: 'integer',
113
+ boolean: 'boolean',
114
+ json: 'json',
115
+ jsonb: 'jsonb',
116
+ bigint: 'bigint',
117
+ serial: 'integer',
118
+ bigserial: 'bigint',
119
+ real: 'real',
120
+ doublePrecision: 'double precision',
121
+ timestamptz: 'timestamp with time zone',
122
+ // `format_type` canonicalizes a bare `timestamp` column to this spelling, and
123
+ // introspection compares against it — so the compiler must emit the same, or an
124
+ // unchanged `timestamp` column round-trips to a spurious ALTER COLUMN TYPE.
125
+ timestamp: 'timestamp without time zone',
126
+ date: 'date',
127
+ };
128
+
129
+ export function pgType(spec: FieldSpec): string {
130
+ // An array is the base type with `[]` appended — `text` → `text[]`, exactly as
131
+ // `format_type` spells it.
132
+ return spec.isArray ? `${scalarPgType(spec)}[]` : scalarPgType(spec);
133
+ }
134
+
135
+ function scalarPgType(spec: FieldSpec): string {
136
+ // Parameterized types build their own canonical string — exactly as `format_type`
137
+ // spells it (no space after the comma; `varchar` is `character varying`; a
138
+ // precision-only numeric carries scale 0), so an unchanged column round-trips clean.
139
+ if (spec.type === 'numeric') {
140
+ if (spec.precision != null) return `numeric(${spec.precision},${spec.scale ?? 0})`;
141
+ return 'numeric';
142
+ }
143
+ if (spec.type === 'varchar') {
144
+ return spec.length != null ? `character varying(${spec.length})` : 'character varying';
145
+ }
146
+ if (spec.type === 'enum') {
147
+ // The column's type is the enum's own type name; `format_type` reports the same bare
148
+ // name, so it round-trips. The `CREATE TYPE` is emitted separately, before the table.
149
+ if (!spec.enumName) throw new Error('field bridge: an enum field needs a type name — field.enum(name, values)');
150
+ return spec.enumName;
151
+ }
152
+ if (spec.type === 'pgType') {
153
+ // A verbatim type: the declared string IS the canonical `format_type` spelling
154
+ // (the factory refused anything else), so it round-trips byte-for-byte.
155
+ if (!spec.pgTypeName) throw new Error('field bridge: a pgType field needs its type name — field.pgType(name)');
156
+ return spec.pgTypeName;
157
+ }
158
+ const t = PG_TYPE[spec.type];
159
+ if (!t) {
160
+ throw new Error(`field bridge: no DDL mapping for field type '${spec.type}' yet`);
161
+ }
162
+ return t;
163
+ }
package/src/wire.ts CHANGED
@@ -46,8 +46,10 @@
46
46
  *
47
47
  * v1 — the first version. Shapes as they stood when the guard was introduced.
48
48
  * v2 — ProvisionWirePayload gains `lifecycleRole` (provision the reserved lifecycle role).
49
+ * v3 — ReconcileWirePayload gains `applicationRoles` (the lifecycle catalog check's roles). A v2
50
+ * CLI's payload carries none, and a reconcile of lifecycle-owned objects refuses without them.
49
51
  */
50
- export const MODEL_WIRE_CONTRACT = 2;
52
+ export const MODEL_WIRE_CONTRACT = 3;
51
53
 
52
54
  /**
53
55
  * Every Model-derived wire payload carries this. The server refuses a payload whose `modelContract`
@@ -237,6 +239,12 @@ export interface ReconcileWirePayload extends ModelWireEnvelope {
237
239
  rebuild?: boolean;
238
240
  overwriteDrift?: boolean;
239
241
  only?: string[];
242
+ /**
243
+ * The application roles the Models' compiled contracts name (catalogInputsFromModels in the
244
+ * CLI): the lifecycle catalog check's one input the declared layer cannot supply. Absent while a
245
+ * declared object is lifecycle-owned = the reconcile refuses (fail closed).
246
+ */
247
+ applicationRoles?: string[];
240
248
  actor?: string | null;
241
249
  gitRef?: string | null;
242
250
  }