@everystack/cli 0.4.103 → 0.4.105

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.
@@ -2,9 +2,9 @@
2
2
  * db:pull's lifecycle fold — reads a lifecycle table back as `lifecycle: true`.
3
3
  *
4
4
  * `lifecycle: true` generates, per table: four columns, the reserved role's grants and policies,
5
- * a `deleted_at IS NULL` guard on every other role's SELECT policy, and two functions, a restore
6
- * function and two triggers. A pull that transcribed them would render the reserved role as an
7
- * authored ability and the columns as authored fields (defineModel refuses both), and the five
5
+ * a `deleted_at IS NULL` guard on every other role's SELECT policy, generated functions,
6
+ * and triggers. A pull that transcribed them would render the reserved role as an
7
+ * authored ability and the columns as authored fields (defineModel refuses both), and the generated
8
8
  * objects as derived descriptors (they would be generated twice).
9
9
  *
10
10
  * So the fold removes all of it from the pull's inputs, and the renderer writes `lifecycle: true`.
@@ -23,6 +23,7 @@ import type { AuthzContract, PolicyContract, TableContract } from './authz-contr
23
23
  import type { DerivedCatalog } from './derived-introspect';
24
24
  import { compileTableContract } from './authz-compile';
25
25
  import { lifecycleFkIndexName } from './schema-compile';
26
+ import { functionIdentity } from './pg-argtypes';
26
27
 
27
28
  /** The generated columns as the catalog reports them (`format_type`), nullable, no default. */
28
29
  const GENERATED_COLUMNS: Record<string, string> = {
@@ -169,7 +170,7 @@ function foldContract(live: TableContract, table: TableSchema): TableContract |
169
170
  }
170
171
 
171
172
  /**
172
- * The five generated objects of one lifecycle table, exactly as the derived catalog keys them. The
173
+ * The generated objects of one lifecycle table, and the older CLI's retired restrict pair, exactly as the derived catalog keys them. The
173
174
  * restore takes the primary key, and a function signature carries no type modifier
174
175
  * (`character varying(255)` is `character varying` there). An author's own overload stays.
175
176
  */
@@ -180,11 +181,17 @@ function generatedIdentities(table: TableSchema): string[] {
180
181
  return [
181
182
  `${schema}.${name}_lifecycle_transition()`,
182
183
  `${schema}.${name}_lifecycle_guard()`,
184
+ `${schema}.${name}_lifecycle_destroy_before()`,
185
+ functionIdentity(schema, `${name}_lifecycle_destroy`, [`${arg}[]`, 'timestamptz', 'integer']),
183
186
  `${schema}.${name}_restore(${arg})`,
184
187
  `${table.table}.${name}_lifecycle_transition`,
185
188
  `${table.table}.${name}_lifecycle_guard`,
186
189
  `${schema}.${name}_lifecycle_delete_when()`,
187
190
  `${table.table}.${name}_lifecycle_delete_when`,
191
+ // CLI 0.4.101 also generated a per-row restrict pair. The transition carries that check now and
192
+ // db:apply removes the pair, so a stage built by 0.4.101 must not pull it back as authored code.
193
+ `${schema}.${name}_lifecycle_restrict()`,
194
+ `${table.table}.${name}_lifecycle_restrict`,
188
195
  ];
189
196
  }
190
197
 
@@ -203,6 +210,26 @@ function withoutGeneratedFkIndexes(t: TableSchema, lifecycleTables: ReadonlySet<
203
210
  return indexes.length === t.indexes.length ? t : { ...t, indexes };
204
211
  }
205
212
 
213
+ /** Fold only the exact read capability generated for a plain restricting child. */
214
+ function foldReferenceContract(live: TableContract, table: TableSchema, lifecycleTables: ReadonlySet<string>): TableContract | undefined {
215
+ const columns = [...new Set(table.foreignKeys.filter(fk =>
216
+ lifecycleTables.has(fk.refTable.includes('.') ? fk.refTable : `public.${fk.refTable}`)
217
+ && (!fk.onDelete || fk.onDelete === 'no action' || fk.onDelete === 'restrict')).flatMap(fk => fk.columns))].sort();
218
+ if (!columns.length) return undefined;
219
+ const role = LIFECYCLE_OWNER_ROLE;
220
+ const grants = live.columnGrants?.[role] ?? {};
221
+ if ((live.grants[role]?.length ?? 0) !== 0 || sorted(Object.keys(grants)) !== sorted(['SELECT']) || sorted(grants.SELECT) !== sorted(columns)) return undefined;
222
+ const expected: PolicyContract = { name: `${split(table.table).name}_select_${role}`, command: 'SELECT', roles: [role], permissive: true, using: 'true', check: null };
223
+ const ownPolicies = live.policies.filter(p => p.roles.includes(role));
224
+ if (ownPolicies.length !== 1 || policyKey(ownPolicies[0]) !== policyKey(expected)) return undefined;
225
+ if (live.effectivePrivileges && sorted(live.effectivePrivileges[role]) !== sorted(['SELECT'])) return undefined;
226
+ const { [role]: _grant, ...tableGrants } = live.grants;
227
+ const { [role]: _cols, ...columnGrants } = live.columnGrants ?? {};
228
+ const { [role]: _effective, ...effectivePrivileges } = live.effectivePrivileges ?? {};
229
+ return { ...live, grants: tableGrants, columnGrants, policies: live.policies.filter(p => p !== ownPolicies[0]),
230
+ ...(live.effectivePrivileges ? { effectivePrivileges } : {}) };
231
+ }
232
+
206
233
  export function foldLifecycle(snapshot: SchemaSnapshot, contract: AuthzContract, catalog: DerivedCatalog): LifecycleFold {
207
234
  const tables = new Set<string>();
208
235
  const foldedContracts = new Map<string, TableContract>();
@@ -215,6 +242,12 @@ export function foldLifecycle(snapshot: SchemaSnapshot, contract: AuthzContract,
215
242
  foldedContracts.set(table.table, folded);
216
243
  }
217
244
  if (tables.size === 0) return { snapshot, contract, catalog, tables, deleteWhen: new Map() };
245
+ for (const table of snapshot.tables) {
246
+ if (tables.has(table.table)) continue;
247
+ const live = contract.tables.find(t => t.table === table.table);
248
+ const folded = live && foldReferenceContract(live, table, tables);
249
+ if (folded) foldedContracts.set(table.table, folded);
250
+ }
218
251
 
219
252
  // A guarded table's predicate is the delete_when function's manifest input. The pull must render
220
253
  // exactly that text, or the next plan would rebuild the guard; without it, refuse rather than drop
@@ -112,6 +112,8 @@ export interface RenderOptions {
112
112
  externalDerived?: ExternalDerived;
113
113
  /** Tables db:pull folded to `lifecycle: true` (lifecycle-pull.ts): rendered with that key, no softDelete line. */
114
114
  lifecycleTables?: ReadonlySet<string>;
115
+ /** Lifecycle children detected even when the default pull leaves their columns unfolded. */
116
+ lifecycleForeignKeyTables?: ReadonlySet<string>;
115
117
  /** lifecycle: { deleteWhen }'s predicate per folded table (lifecycle-pull.ts), rendered verbatim. */
116
118
  lifecycleDeleteWhen?: ReadonlyMap<string, string>;
117
119
  }
@@ -393,6 +395,48 @@ function knownSet(tables: Iterable<string>): Set<string> {
393
395
  return new Set([...tables].map(qualifiedName));
394
396
  }
395
397
 
398
+ /** A key into a rendered lifecycle parent, declared on that parent as a hasMany dependent. */
399
+ interface RenderedDependent { child: string; column: string; dependent: 'cascade' | 'restrict' | 'nullify' }
400
+ const DEPENDENT_OF: Record<string, RenderedDependent['dependent']> = { cascade: 'cascade', 'set null': 'nullify', restrict: 'restrict' };
401
+
402
+ /**
403
+ * Every one-column foreign key from a table rendered `lifecycle: true` into another, grouped by the
404
+ * parent: the parent declares each one as a dependent (a key into a lifecycle table with none is
405
+ * refused), read from the live ON DELETE: CASCADE -> 'cascade', SET NULL -> 'nullify', RESTRICT or
406
+ * NO ACTION -> 'restrict'. A plain table's key into one has no dependent (only a lifecycle table may
407
+ * hold one): it renders as it is, with a comment, and the model's own refusal names it. So do SET
408
+ * DEFAULT and composite keys.
409
+ */
410
+ function lifecycleDependentsOf(tables: readonly TableSchema[], lifecycleTables: ReadonlySet<string> | undefined): Map<string, RenderedDependent[]> {
411
+ const out = new Map<string, RenderedDependent[]>();
412
+ const rendered = new Set([...(lifecycleTables ?? [])].map(qualifiedName));
413
+ const known = knownSet(tables.map((t) => t.table));
414
+ for (const table of tables) {
415
+ for (const fk of table.foreignKeys) {
416
+ const parent = qualifiedName(fk.refTable);
417
+ const dependent = DEPENDENT_OF[fk.onDelete ?? 'restrict'];
418
+ if (fk.columns.length !== 1 || !dependent || !rendered.has(parent) || !known.has(parent) || !rendered.has(qualifiedName(table.table))) continue;
419
+ out.set(parent, [...(out.get(parent) ?? []), { child: qualifiedName(table.table), column: fk.columns[0], dependent }]);
420
+ }
421
+ }
422
+ return out;
423
+ }
424
+
425
+ /** The parent's `relations: { … }` stanza for its dependents ('' when none). Thunks, like `.references()`: a pulled barrel is not linked. */
426
+ function dependentsStanza(table: TableSchema, dependents: readonly RenderedDependent[]): string {
427
+ if (dependents.length === 0) return '';
428
+ const fieldKeys = new Set(table.columns.map((c) => toCamelCase(c.name)));
429
+ const base = (d: RenderedDependent): string => toCamelCase(schemaOf(d.child) === 'public' ? bareName(d.child) : `${schemaOf(d.child)}_${bareName(d.child)}`);
430
+ const lines = [...dependents].sort((a, b) => (`${a.child}\u0000${a.column}` < `${b.child}\u0000${b.column}` ? -1 : 1)).map((d) => {
431
+ const field = toCamelCase(d.column);
432
+ const plain = base(d);
433
+ const shared = dependents.filter((o) => base(o) === plain).length > 1 || fieldKeys.has(plain);
434
+ const key = shared ? `${plain}By${field[0].toUpperCase()}${field.slice(1)}` : plain;
435
+ return ` ${safeKey(key, `the relation for ${liveObject('table', d.child)}`)}: hasMany(() => ${modelVarName(d.child)}, ${tsLiteral(field)}, { dependent: ${tsLiteral(d.dependent)} }),`;
436
+ });
437
+ return `\n relations: {\n${lines.join('\n')}\n },`;
438
+ }
439
+
396
440
  /**
397
441
  * A table name → its model variable: PascalCase, naively singularized (one trailing
398
442
  * `s` strips, whatever the word). `image_variants` → `ImageVariant`.
@@ -425,7 +469,7 @@ export function modelVarName(table: string): string {
425
469
  * phantom rename for every digit-boundary column and COLLIDED distinct columns
426
470
  * (`a_1` and `a1`) onto one key.
427
471
  */
428
- function toCamelCase(name: string): string {
472
+ export function toCamelCase(name: string): string {
429
473
  return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
430
474
  }
431
475
 
@@ -794,7 +838,17 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
794
838
  }
795
839
 
796
840
  /** One `export const X = defineModel(...)` block for a table. */
797
- export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented', liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>, fkTargets?: FkTargets, lifecycle: boolean | string = false): string {
841
+ export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented', liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>, fkTargets?: FkTargets, lifecycle: boolean | string = false, lifecycleForeignKeys = Boolean(lifecycle), dependents: ReadonlyMap<string, readonly RenderedDependent[]> = new Map(), lifecycleTables: ReadonlySet<string> = new Set()): string {
842
+ const self = qualifiedName(table.table);
843
+ const asParent = dependents.get(self) ?? [];
844
+ // A key a lifecycle parent declares as a dependent carries no onDelete of its own: the dependent derives it.
845
+ const declared = new Set([...dependents.values()].flat().filter((d) => d.child === self).map((d) => d.column));
846
+ // Copy for rendering only; the raw catalog still owns the adoption fingerprint.
847
+ if (declared.size) table = { ...table, foreignKeys: table.foreignKeys.map(fk =>
848
+ fk.columns.length === 1 && declared.has(fk.columns[0]) ? { ...fk, onDelete: undefined } : fk) };
849
+ // A pull cannot infer the parent relation that makes any other lifecycle cascade legal.
850
+ if (lifecycleForeignKeys) table = { ...table, foreignKeys: table.foreignKeys.map(fk =>
851
+ fk.onDelete === 'cascade' ? { ...fk, onDelete: 'restrict' } : fk) };
798
852
  // A CHECK that reverses to a single field's .validate() is rendered on the field (ergonomic);
799
853
  // the rest stay table-level check(). Both round-trip — this only chooses the nicer form.
800
854
  const validates = new Map<string, string>();
@@ -813,7 +867,13 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
813
867
  }
814
868
 
815
869
  const pulled = knownSet(known);
816
- const fields = table.columns.map((c) => renderField(table, c, pulled, enums, validates, fkTargets)).join('\n');
870
+ // A plain table's key into a table rendered lifecycle: the models refuse it; say why and the fix.
871
+ const intoLifecycle = new Set(lifecycle ? [] : table.foreignKeys
872
+ .filter((fk) => fk.columns.length === 1 && lifecycleTables.has(qualifiedName(fk.refTable)))
873
+ .map((fk) => fk.columns[0]));
874
+ const fields = table.columns.map((c) => (intoLifecycle.has(c.name)
875
+ ? ` // ${commentText(`${c.name} references a lifecycle table, and only a lifecycle table may hold a key into one: make this model lifecycle: true (and declare the dependent on the parent), or refer to it by value (drop .references())`)}\n`
876
+ : '') + renderField(table, c, pulled, enums, validates, fkTargets)).join('\n');
817
877
  const constraints = renderConstraintsBlock(table, tableChecks, pulled, fkTargets);
818
878
  // The authz decision renders FIRST — before fields — because it is the first thing a
819
879
  // reviewer must resolve about a model (and where the field-report consumer's codemod
@@ -834,7 +894,7 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
834
894
  // already uses for defineMaterializedTable.
835
895
  const owner = schemaOf(table.table);
836
896
  const schemaProp = owner === 'public' ? '' : ` schema: ${tsLiteral(owner)},\n`;
837
- return `export const ${modelVarName(table.table)} = defineModel(${tsLiteral(bareName(table.table))}, {\n${stanza.text}\n${schemaProp}${writtenBy}${softDelete} fields: {\n${fields}\n },${constraints}\n});`;
897
+ return `export const ${modelVarName(table.table)} = defineModel(${tsLiteral(bareName(table.table))}, {\n${stanza.text}\n${schemaProp}${writtenBy}${softDelete} fields: {\n${fields}\n },${constraints}${dependentsStanza(table, asParent)}\n});`;
838
898
  }
839
899
 
840
900
  /** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
@@ -847,6 +907,7 @@ function importHeader(body: string, extra: string[] = []): string {
847
907
  if (/\bcheck\(/.test(body)) symbols.push('check');
848
908
  if (/\bindex\(/.test(body)) symbols.push('index');
849
909
  if (/\bforeignKey\(/.test(body)) symbols.push('foreignKey');
910
+ if (/\bhasMany\(/.test(body)) symbols.push('hasMany');
850
911
  if (/\bdefineModule\(/.test(body)) symbols.push('defineModule');
851
912
  if (/\bsql`/.test(body)) symbols.push('sql');
852
913
  for (const s of extra) if (!symbols.includes(s)) symbols.push(s);
@@ -951,7 +1012,9 @@ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions
951
1012
  const known = knownSet(tables.map((t) => t.table));
952
1013
  const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
953
1014
 
954
- const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles), opts.fkTargets, opts.lifecycleDeleteWhen?.get(t.table) ?? opts.lifecycleTables?.has(t.table)));
1015
+ const dependents = lifecycleDependentsOf(tables, opts.lifecycleTables);
1016
+ const rendered = new Set([...(opts.lifecycleTables ?? [])].map(qualifiedName));
1017
+ const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles), opts.fkTargets, opts.lifecycleDeleteWhen?.get(t.table) ?? opts.lifecycleTables?.has(t.table), opts.lifecycleForeignKeyTables?.has(t.table) || opts.lifecycleTables?.has(t.table), dependents, rendered));
955
1018
  // The derived layer (B5): sequences + views/matviews/functions, after the models they
956
1019
  // reference, before the module wrapper that composes all three.
957
1020
  if (opts.derived?.block) blocks.push(opts.derived.block);
@@ -1027,9 +1090,12 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
1027
1090
  const known = knownSet(tables.map((t) => t.table));
1028
1091
  const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
1029
1092
 
1093
+ const dependents = lifecycleDependentsOf(tables, opts.lifecycleTables);
1094
+ const rendered = new Set([...(opts.lifecycleTables ?? [])].map(qualifiedName));
1030
1095
  const files: RenderedModelFile[] = tables.map((t) => {
1031
- const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles), opts.fkTargets, opts.lifecycleDeleteWhen?.get(t.table) ?? opts.lifecycleTables?.has(t.table));
1032
- const crossImports = referencedTables(t, known).map(
1096
+ const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles), opts.fkTargets, opts.lifecycleDeleteWhen?.get(t.table) ?? opts.lifecycleTables?.has(t.table), opts.lifecycleForeignKeyTables?.has(t.table) || opts.lifecycleTables?.has(t.table), dependents, rendered);
1097
+ const children = (dependents.get(qualifiedName(t.table)) ?? []).map((d) => d.child).filter((c) => c !== qualifiedName(t.table));
1098
+ const crossImports = [...new Set([...referencedTables(t, known), ...children])].sort().map(
1033
1099
  (target) => `import { ${modelVarName(target)} } from '${modelImportPath(target)}';`,
1034
1100
  );
1035
1101
  const header = [importHeader(block), ...crossImports].join('\n');
@@ -21,7 +21,7 @@
21
21
 
22
22
  import { createHash } from 'node:crypto';
23
23
  import type { ModelDescriptor, FieldSpec, SequenceDescriptor, DependentActions, DependentIndexes } from '@everystack/model';
24
- import { positionalCheckName } from '@everystack/model';
24
+ import { pgType, positionalCheckName } from '@everystack/model';
25
25
  import type { TableSchema, EnumType, SequenceSchema, UniqueConstraint, CheckConstraint, IndexSchema, ForeignKey } from './schema-introspect';
26
26
  import { nextvalSequence, identityClause, primaryKeyConstraintName, type RenameMap } from './schema-diff';
27
27
  import { normalizeDeparsedExpr } from './deparse-normal';
@@ -154,69 +154,8 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
154
154
  return { uniques, checks, indexes };
155
155
  }
156
156
 
157
- /**
158
- * Postgres type for a field, with the precision drizzle-kit would emit — in
159
- * `format_type`'s vocabulary, because this feeds the IR that diffs against
160
- * introspection. A serial field's *column type* is integer/bigint (`serial` is
161
- * DDL shorthand, not a type — `format_type` never reports it); the sequence
162
- * default is carried separately (see compileTableSchema), and the DDL renderers
163
- * spell the shorthand (see SERIAL_DDL).
164
- */
165
- const PG_TYPE: Record<string, string> = {
166
- text: 'text',
167
- uuid: 'uuid',
168
- integer: 'integer',
169
- boolean: 'boolean',
170
- json: 'json',
171
- jsonb: 'jsonb',
172
- bigint: 'bigint',
173
- serial: 'integer',
174
- bigserial: 'bigint',
175
- real: 'real',
176
- doublePrecision: 'double precision',
177
- timestamptz: 'timestamp with time zone',
178
- // `format_type` canonicalizes a bare `timestamp` column to this spelling, and
179
- // introspection compares against it — so the compiler must emit the same, or an
180
- // unchanged `timestamp` column round-trips to a spurious ALTER COLUMN TYPE.
181
- timestamp: 'timestamp without time zone',
182
- date: 'date',
183
- };
184
-
185
- export function pgType(spec: FieldSpec): string {
186
- // An array is the base type with `[]` appended — `text` → `text[]`, exactly as
187
- // `format_type` spells it.
188
- return spec.isArray ? `${scalarPgType(spec)}[]` : scalarPgType(spec);
189
- }
190
-
191
- function scalarPgType(spec: FieldSpec): string {
192
- // Parameterized types build their own canonical string — exactly as `format_type`
193
- // spells it (no space after the comma; `varchar` is `character varying`; a
194
- // precision-only numeric carries scale 0), so an unchanged column round-trips clean.
195
- if (spec.type === 'numeric') {
196
- if (spec.precision != null) return `numeric(${spec.precision},${spec.scale ?? 0})`;
197
- return 'numeric';
198
- }
199
- if (spec.type === 'varchar') {
200
- return spec.length != null ? `character varying(${spec.length})` : 'character varying';
201
- }
202
- if (spec.type === 'enum') {
203
- // The column's type is the enum's own type name; `format_type` reports the same bare
204
- // name, so it round-trips. The `CREATE TYPE` is emitted separately, before the table.
205
- if (!spec.enumName) throw new Error('field bridge: an enum field needs a type name — field.enum(name, values)');
206
- return spec.enumName;
207
- }
208
- if (spec.type === 'pgType') {
209
- // A verbatim type: the declared string IS the canonical `format_type` spelling
210
- // (the factory refused anything else), so it round-trips byte-for-byte.
211
- if (!spec.pgTypeName) throw new Error('field bridge: a pgType field needs its type name — field.pgType(name)');
212
- return spec.pgTypeName;
213
- }
214
- const t = PG_TYPE[spec.type];
215
- if (!t) {
216
- throw new Error(`field bridge: no DDL mapping for field type '${spec.type}' yet`);
217
- }
218
- return t;
219
- }
157
+ // Shared with the runtime destroy wrapper: casts and generated signatures must agree.
158
+ export { pgType } from '@everystack/model';
220
159
 
221
160
  /**
222
161
  * The FK referential actions declared on a field or a table-level `foreignKey(...)`, dropping
@@ -158,7 +158,7 @@ export async function createStateReferencedFunctions(
158
158
  session: SessionRunner,
159
159
  statements: readonly string[],
160
160
  declared: readonly SourceObject[],
161
- options: { actor?: string | null; gitRef?: string | null; now?: () => number } = {},
161
+ options: { actor?: string | null; gitRef?: string | null; now?: () => number; lifecyclePreflighted?: boolean } = {},
162
162
  ): Promise<string[]> {
163
163
  const referenced = stateReferencedFunctions(statements, declared);
164
164
  if (referenced.length === 0) return [];
@@ -184,6 +184,7 @@ export async function createStateReferencedFunctions(
184
184
  actor: options.actor ?? null,
185
185
  gitRef: options.gitRef ?? null,
186
186
  now: options.now,
187
+ lifecyclePreflighted: options.lifecyclePreflighted,
187
188
  });
188
189
  if (run.refusal) {
189
190
  throw new Error(`could not create the functions the state batch calls (${missing.map((o) => o.identity).join(', ')}) before it: ${run.refusal}`);
@@ -1,238 +0,0 @@
1
- /** Fail-closed verification of generated lifecycle definitions before db:pull writes. */
2
- import { LIFECYCLE_OWNER_ROLE, parseQualified, type FunctionDescriptor } from '@everystack/model';
3
- import { canonHash, canonOfFunction, type FunctionCanon } from './lifecycle-canon';
4
- import { regenerateLifecycle, lifecycleRole, MANIFEST_VERSION, LIFECYCLE_ROLES, type LifecycleDeclaration, type LifecycleManifest, type LifecycleTrigger } from './lifecycle-derived';
5
- import { normalizeArgType } from './pg-argtypes';
6
- import { parseTriggerType } from './derived-introspect';
7
- import { normalizeDeparsedExpr } from './deparse-normal';
8
- import { INTROSPECTION_SESSION, type SessionRunner } from './session';
9
-
10
- const FUNCTIONS = `SELECT n.nspname AS schema, p.proname AS name,
11
- n.nspname || '.' || p.proname || '(' || coalesce((SELECT string_agg(format_type(t, NULL), ', ' ORDER BY o) FROM unnest(p.proargtypes::oid[]) WITH ORDINALITY x(t,o)), '') || ')' AS identity,
12
- ARRAY(SELECT format_type(t,NULL) FROM unnest(p.proargtypes::oid[]) WITH ORDINALITY x(t,o) ORDER BY o) AS arg_types,
13
- pg_get_function_arguments(p.oid) AS args, pg_get_function_result(p.oid) AS returns,
14
- l.lanname AS language,p.prosecdef AS secdef,p.provolatile::text AS volatility,p.proisstrict AS strict,p.proleakproof AS leakproof,
15
- coalesce(p.proconfig,'{}'::text[]) AS config,pg_get_userbyid(p.proowner) AS owner,p.prosrc AS body,obj_description(p.oid,'pg_proc') AS comment,
16
- coalesce((SELECT array_agg(DISTINCT coalesce(g.rolname,'PUBLIC') ORDER BY coalesce(g.rolname,'PUBLIC'))
17
- FROM aclexplode(coalesce(p.proacl,acldefault('f',p.proowner))) a LEFT JOIN pg_roles g ON g.oid=a.grantee
18
- WHERE a.grantee<>p.proowner AND a.privilege_type='EXECUTE'),'{}'::name[])::text[] AS execute
19
- FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace JOIN pg_language l ON l.oid=p.prolang
20
- WHERE p.prokind IN ('f','p') AND n.nspname NOT IN ('pg_catalog','information_schema') AND n.nspname NOT LIKE 'pg_%'
21
- AND NOT EXISTS(SELECT 1 FROM pg_depend d WHERE d.objid=p.oid AND d.deptype='e')`;
22
- const TRIGGERS = `SELECT n.nspname||'.'||c.relname AS "table",t.tgname AS name,t.tgtype::int AS type,t.tgenabled::text AS enabled,
23
- t.tgconstraint<>0 AS is_constraint,t.tgdeferrable AS deferrable,t.tginitdeferred AS initially_deferred,
24
- fn.nspname||'.'||p.proname AS fn,substring(pg_get_triggerdef(t.oid) from ' WHEN [(](.*)[)] EXECUTE ') AS when_expr,
25
- coalesce((SELECT array_agg(a.attname::text ORDER BY x.ord) FROM unnest(t.tgattr::int2[]) WITH ORDINALITY x(attnum,ord)
26
- JOIN pg_attribute a ON a.attrelid=t.tgrelid AND a.attnum=x.attnum),'{}'::text[]) AS update_of,encode(t.tgargs,'hex') AS args
27
- FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid JOIN pg_namespace n ON n.oid=c.relnamespace
28
- JOIN pg_proc p ON p.oid=t.tgfoid JOIN pg_namespace fn ON fn.oid=p.pronamespace
29
- WHERE NOT t.tgisinternal AND n.nspname NOT IN ('pg_catalog','information_schema') AND n.nspname NOT LIKE 'pg_%'`;
30
- const TABLES = `SELECT n.nspname||'.'||c.relname AS identity,
31
- coalesce((SELECT jsonb_agg(jsonb_build_object('role',coalesce(r.rolname,'PUBLIC'),
32
- 'predicate',coalesce(pg_get_expr(p.polqual,p.polrelid),'true'),'permissive',p.polpermissive) ORDER BY p.polname,r.rolname)
33
- FROM pg_policy p CROSS JOIN LATERAL unnest(p.polroles) role(oid) LEFT JOIN pg_roles r ON r.oid=role.oid
34
- WHERE p.polrelid=c.oid AND p.polcmd IN ('d','*') AND r.rolname IS DISTINCT FROM 'everystack_lifecycle'),'[]'::jsonb) AS delete_policies,
35
- ARRAY(SELECT a.attname::text FROM unnest(k.conkey) WITH ORDINALITY x(num,ord) JOIN pg_attribute a ON a.attrelid=c.oid AND a.attnum=x.num ORDER BY x.ord) AS pk_columns,
36
- ARRAY(SELECT format_type(a.atttypid,a.atttypmod) FROM unnest(k.conkey) WITH ORDINALITY x(num,ord) JOIN pg_attribute a ON a.attrelid=c.oid AND a.attnum=x.num ORDER BY x.ord) AS pk_types,
37
- EXISTS(SELECT 1 FROM pg_index i WHERE i.indrelid=c.oid AND i.indisunique AND NOT i.indisprimary) AS unique,
38
- ((SELECT count(*)=4 FROM pg_attribute a WHERE a.attrelid=c.oid AND NOT a.attisdropped AND a.attname IN ('deleted_at','deleted_by','deleted_batch','deleted_xid'))
39
- AND EXISTS(SELECT 1 FROM aclexplode(coalesce(c.relacl,acldefault('r',c.relowner))) a JOIN pg_roles r ON r.oid=a.grantee
40
- WHERE r.rolname='everystack_lifecycle' AND a.privilege_type='DELETE')) AS lifecycle
41
- FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace LEFT JOIN pg_constraint k ON k.conrelid=c.oid AND k.contype='p'
42
- WHERE c.relkind IN ('r','p') AND n.nspname NOT IN ('pg_catalog','information_schema') AND n.nspname NOT LIKE 'pg_%'`;
43
- const EDGES = `SELECT pn.nspname||'.'||p.relname AS parent,cn.nspname||'.'||c.relname AS child,
44
- ARRAY(SELECT a.attname::text FROM unnest(f.conkey) WITH ORDINALITY x(num,ord) JOIN pg_attribute a ON a.attrelid=c.oid AND a.attnum=x.num ORDER BY x.ord) AS columns,
45
- ARRAY(SELECT a.attname::text FROM unnest(f.confkey) WITH ORDINALITY x(num,ord) JOIN pg_attribute a ON a.attrelid=p.oid AND a.attnum=x.num ORDER BY x.ord) AS references,
46
- f.confdeltype::text AS action FROM pg_constraint f JOIN pg_class c ON c.oid=f.conrelid JOIN pg_namespace cn ON cn.oid=c.relnamespace
47
- JOIN pg_class p ON p.oid=f.confrelid JOIN pg_namespace pn ON pn.oid=p.relnamespace WHERE f.contype='f'`;
48
-
49
- type Row = Record<string, unknown>;
50
- const record = (value: unknown): value is Row => value !== null && typeof value === 'object' && !Array.isArray(value);
51
- const strings = (value: unknown): string[] => Array.isArray(value) ? value.map(String) : [];
52
- const same = (a: unknown, b: unknown): boolean => canonHash(a) === canonHash(b);
53
- const hint = 'missing lifecycle manifest; db:apply for a managed install; db:reconcile --baseline for a brownfield object with no provenance (use --apply to persist)';
54
-
55
- interface TriggerCanon { kind: 'trigger'; identity: string; table: string; timing: string; events: string[]; forEach: string; fn: string; when: string | null; updateOf: string[]; args: string; enabled: string; constraint: boolean; deferrable: boolean; initiallyDeferred: boolean; }
56
- interface LiveFunction { name: string; argTypes: string[]; canon: FunctionCanon; comment: unknown; }
57
- export interface LifecycleVerdict { failures: string[]; notes: string[]; }
58
- export class LifecycleDriftError extends Error {
59
- constructor(readonly lines: string[]) { super('lifecycle verification failed'); }
60
- }
61
-
62
- /** Only the two fixed generated WHEN expressions need catalog spelling normalization. */
63
- /** A trigger WHEN, normalised so the generated text and PostgreSQL's deparse compare equal. Exported for tests. */
64
- export function whenExpression(value: string | null): string | null {
65
- if (value === null) return null;
66
- const text = value.replace(new RegExp(`'${LIFECYCLE_OWNER_ROLE}'::name`, 'g'), `'${LIFECYCLE_OWNER_ROLE}'`);
67
- const tokens = text.match(/'(?:[^']|'')*'|"(?:[^"]|"")*"|[^'"]+/g) ?? [];
68
- return tokens.map(token => {
69
- if (token.startsWith("'")) return token;
70
- if (token.startsWith('"')) return token === '"deleted_at"' ? 'deleted_at' : token;
71
- // PostgreSQL deparses a pg_catalog function unqualified; the generator writes it qualified.
72
- // Drop only a WHOLE `pg_catalog.` qualifier, on the raw token (before spaces go, or `OR pg_catalog.`
73
- // would fuse into `orpg_catalog.`): not after a name character, a dot or a quote, so
74
- // `ppg_catalog.g_trigger_depth` and `x.pg_catalog.f` keep their text and show as drift.
75
- return token.replace(/(?<![A-Za-z0-9_$."])pg_catalog\./gi, '').replace(/[()\s]/g, '').toLowerCase();
76
- }).join('');
77
- }
78
- function canonOfTrigger(trigger: LifecycleTrigger): TriggerCanon {
79
- return { kind: 'trigger', identity: `${trigger.on.schema}.${trigger.on.table}.${trigger.name}`, table: `${trigger.on.schema}.${trigger.on.table}`, timing: trigger.timing,
80
- events: trigger.events, forEach: trigger.forEach, fn: trigger.execute.name, when: whenExpression(trigger.condition ?? null),
81
- updateOf: trigger.of ?? [], args: '', enabled: 'O', constraint: false, deferrable: false, initiallyDeferred: false };
82
- }
83
- function typeIdentity(type: string): string {
84
- return normalizeArgType(type) + '|' + (type.match(/\([^)]*\)/g) ?? []).join('').replace(/\s+/g, '');
85
- }
86
- function declaration(value: unknown): value is LifecycleDeclaration {
87
- if (!record(value) || !record(value.tables) || !Array.isArray(value.edges)) return false;
88
- for (const [id, table] of Object.entries(value.tables)) {
89
- if (!record(table) || typeof table.schema !== 'string' || typeof table.table !== 'string' || id !== `${table.schema}.${table.table}`
90
- || !record(table.pk) || typeof table.pk.column !== 'string' || typeof table.pk.type !== 'string'
91
- || typeof table.unique !== 'boolean' || !record(table.auth) || Object.values(table.auth).some(p => typeof p !== 'string')) return false;
92
- }
93
- return value.edges.every(edge => record(edge) && typeof edge.parent === 'string' && typeof edge.child === 'string'
94
- && typeof edge.column === 'string' && (edge.dependent === 'cascade' || edge.dependent === 'restrict'));
95
- }
96
-
97
- /** Fold catalog policies by role exactly as the compiler records DELETE/ALL rules. */
98
- function sameDeleteRules(auth: Record<string, string>, policies: unknown): boolean {
99
- if (!Array.isArray(policies)) return false;
100
- const byRole = new Map<string, string[]>();
101
- for (const policy of policies) {
102
- if (!record(policy) || typeof policy.role !== 'string' || typeof policy.predicate !== 'string' || policy.permissive !== true) return false;
103
- const predicates = byRole.get(policy.role) ?? [];
104
- predicates.push(normalizeDeparsedExpr(policy.predicate));
105
- byRole.set(policy.role, predicates);
106
- }
107
- const live = Object.fromEntries([...byRole].map(([role, predicates]) => [role,
108
- predicates.length === 1 ? predicates[0] : predicates.map(p => `(${p})`).join(' OR ')]));
109
- const declared = Object.fromEntries(Object.entries(auth).map(([role, predicate]) => [role, normalizeDeparsedExpr(predicate)]));
110
- return same(declared, live);
111
- }
112
-
113
- /**
114
- * An older CLI generated a per-row `<t>_lifecycle_restrict` function (manifest role 'restrict') and
115
- * BEFORE DELETE trigger; the restrict check now lives in the transition. That pair is stale output,
116
- * not a malformed manifest or an author trigger: say so, and name the remedy.
117
- */
118
- const OLD_RESTRICT = 'stale, generated by an older CLI: run db:apply to remove the old restrict function and trigger';
119
- const oldRestrictName = (table: string): string => `${table}_lifecycle_restrict`;
120
-
121
- /** Four catalog reads share one read-only snapshot. No function bodies or generated SQL execute. */
122
- export async function verifyLifecycle(session: SessionRunner, governedRoles: readonly string[] = ['anon', 'authenticated', 'admin', LIFECYCLE_OWNER_ROLE]): Promise<LifecycleVerdict> {
123
- const results = await session([FUNCTIONS, TRIGGERS, TABLES, EDGES], INTROSPECTION_SESSION);
124
- if (results.length !== 4 || results.some(rows => !Array.isArray(rows))) throw new Error('lifecycle catalog could not be read completely');
125
- const [functions, triggers, tables, edges] = results as Row[][];
126
- const problems = new Map<string, string>();
127
- const add = (id: string, reason: string): void => { if (!problems.has(id)) problems.set(id, `drift: ${id}: ${reason}`); };
128
- const notes = new Set<string>();
129
- const tableById = new Map(tables.map(t => [String(t.identity), t]));
130
- const lifecycleTables = new Set(tables.filter(t => t.lifecycle === true).map(t => String(t.identity)));
131
- const liveFunctions = new Map<string, LiveFunction>();
132
- for (const row of functions) {
133
- const canon: FunctionCanon = { kind: 'function', identity: String(row.identity), args: String(row.args), returns: String(row.returns),
134
- language: String(row.language), secdef: row.secdef === true, volatility: String(row.volatility), strict: row.strict === true, leakproof: row.leakproof === true,
135
- config: strings(row.config), owner: String(row.owner), execute: strings(row.execute).sort(), body: String(row.body) };
136
- liveFunctions.set(canon.identity, { name: `${row.schema}.${row.name}`, canon, argTypes: strings(row.arg_types), comment: row.comment });
137
- }
138
- const liveTriggers = new Map<string, TriggerCanon>();
139
- const triggerTypes = new Map<string, number>();
140
- for (const row of triggers) {
141
- const shape = parseTriggerType(Number(row.type));
142
- const canon: TriggerCanon = { kind: 'trigger', identity: `${row.table}.${row.name}`, table: String(row.table), timing: shape.timing, events: shape.events, forEach: shape.forEach,
143
- fn: String(row.fn), when: whenExpression(row.when_expr == null ? null : String(row.when_expr)), updateOf: strings(row.update_of), args: String(row.args), enabled: String(row.enabled),
144
- constraint: row.is_constraint === true, deferrable: row.deferrable === true, initiallyDeferred: row.initially_deferred === true };
145
- liveTriggers.set(canon.identity, canon);
146
- triggerTypes.set(canon.identity, Number(row.type));
147
- }
148
- const familyByName = (live: LiveFunction): boolean => [...lifecycleTables].some(model => {
149
- if (live.name.startsWith(`${model}_lifecycle_`)) return true;
150
- const pkTypes = strings(tableById.get(model)?.pk_types);
151
- return live.name === `${model}_restore` && live.argTypes.length === 1 && pkTypes.length === 1
152
- && normalizeArgType(live.argTypes[0]) === normalizeArgType(pkTypes[0]);
153
- });
154
- const manifests = new Map<string, LifecycleManifest>();
155
- /** lifecycle: { deleteWhen }'s predicate per model, from its delete_when function's own manifest. */
156
- const deleteWhenOf = new Map<string, string>();
157
- const groups = new Map<string, { decl: LifecycleDeclaration; from: string }>();
158
- for (const [id, live] of liveFunctions) {
159
- let raw: unknown;
160
- try { raw = typeof live.comment === 'string' ? JSON.parse(live.comment) : undefined; } catch {
161
- if (live.canon.owner === LIFECYCLE_OWNER_ROLE || familyByName(live)) add(id, 'malformed lifecycle manifest');
162
- continue;
163
- }
164
- const family = live.canon.owner === LIFECYCLE_OWNER_ROLE || (record(raw) && raw.everystack === 'lifecycle')
165
- || familyByName(live);
166
- if (!family) continue;
167
- if (!record(raw) || raw.everystack !== 'lifecycle') { add(id, hint); continue; }
168
- if (raw.v !== MANIFEST_VERSION) { add(id, `unsupported lifecycle manifest v${String(raw.v)}; this CLI emits v${MANIFEST_VERSION}; re-apply`); continue; }
169
- if (raw.role === 'restrict' && typeof raw.model === 'string' && lifecycleTables.has(raw.model) && live.name === oldRestrictName(raw.model)) { add(id, OLD_RESTRICT); continue; }
170
- if (typeof raw.model !== 'string' || !declaration(raw.decl) || typeof raw.role !== 'string' || !(LIFECYCLE_ROLES as readonly string[]).includes(raw.role)
171
- || typeof raw.defHash !== 'string' || !/^[a-f0-9]{64}$/.test(raw.defHash) || !raw.decl.tables[raw.model]) { add(id, 'malformed lifecycle manifest'); continue; }
172
- if (lifecycleRole(live.name, raw.model) !== raw.role) { add(id, 'manifest role does not match function name'); continue; }
173
- const manifest = raw as unknown as LifecycleManifest;
174
- if (manifest.role === 'delete_when') {
175
- if (typeof manifest.deleteWhen !== 'string' || manifest.deleteWhen.trim() === '') { add(id, 'malformed lifecycle manifest'); continue; }
176
- deleteWhenOf.set(manifest.model, manifest.deleteWhen);
177
- }
178
- manifests.set(id, manifest);
179
- const prev = groups.get(manifest.model);
180
- if (prev && JSON.stringify(prev.decl) !== JSON.stringify(manifest.decl)) add(id, `conflicting declarations for ${manifest.model}`);
181
- else if (!prev) groups.set(manifest.model, { decl: manifest.decl, from: id });
182
- }
183
- for (const table of lifecycleTables) if (!groups.has(table)) add(table, hint);
184
-
185
- const expectedFunctions = new Map<string, FunctionDescriptor>();
186
- const expectedTriggers = new Map<string, TriggerCanon>();
187
- for (const [model, { decl, from }] of groups) {
188
- // The catalog is an independent input: a self-consistent comment and body cannot erase a unique index.
189
- for (const [id, input] of Object.entries(decl.tables)) {
190
- const actual = tableById.get(id);
191
- if (!actual) { add(id, 'declared table missing from catalog'); continue; }
192
- if (!same(strings(actual.pk_columns), [input.pk.column]) || !same(strings(actual.pk_types).map(typeIdentity), [typeIdentity(input.pk.type)])) add(id, 'primary key differs from catalog');
193
- if (actual.unique !== input.unique) add(id, 'unique flag differs from catalog');
194
- if (!sameDeleteRules(input.auth, actual.delete_policies)) add(id, 'delete rule in the manifest does not match the live policies');
195
- for (const role of Object.keys(input.auth)) if (!governedRoles.includes(role)) notes.add(`ungoverned role ${role} in lifecycle manifest ${model}`);
196
- }
197
- for (const edge of decl.edges) {
198
- const parent = tableById.get(edge.parent);
199
- const child = decl.tables[edge.child] ?? (() => { const { schema, name: table } = parseQualified(edge.child); return { schema, table }; })();
200
- if (!edges.some(actual => actual.parent === edge.parent && actual.child === `${child.schema}.${child.table}` && same(strings(actual.columns), [edge.column])
201
- && same(strings(actual.references), strings(parent?.pk_columns)) && actual.action === 'r')) add(`${edge.child}.${edge.column}`, 'foreign key differs from catalog');
202
- }
203
- let objects;
204
- try { objects = regenerateLifecycle(model, decl, deleteWhenOf.get(model)); } catch { add(from, 'malformed declaration cannot regenerate'); continue; }
205
- for (const object of objects) {
206
- if (object.kind === 'function') expectedFunctions.set(canonOfFunction(object).identity, object);
207
- else { const canon = canonOfTrigger(object); expectedTriggers.set(canon.identity, canon); }
208
- }
209
- }
210
- for (const [id, expected] of expectedFunctions) {
211
- const actual = liveFunctions.get(id);
212
- if (!actual) { add(id, 'missing generated function'); continue; }
213
- const manifest = manifests.get(id);
214
- if (!manifest) { add(id, hint); continue; }
215
- const liveHash = canonHash(actual.canon);
216
- if (manifest.defHash !== liveHash) { add(id, 'drifted, live definition differs from its recorded hash'); continue; }
217
- if (canonHash(canonOfFunction(expected)) !== liveHash) add(id, 'stale, re-apply');
218
- }
219
- for (const [id] of manifests) if (!expectedFunctions.has(id)) add(id, 'unlisted function in lifecycle family');
220
- for (const [id, expected] of expectedTriggers) {
221
- const actual = liveTriggers.get(id);
222
- if (!actual) add(id, 'missing generated trigger');
223
- else if (!same(expected, actual)) add(id, 'generated trigger differs from regeneration (a hand edit, or an older CLI): run db:apply to re-create it');
224
- }
225
- const functionNames = new Set([...expectedFunctions.values()].map(fn => fn.name));
226
- for (const [id, actual] of liveTriggers) {
227
- if (expectedTriggers.has(id)) continue;
228
- const bare = actual.table.slice(actual.table.lastIndexOf('.') + 1);
229
- if (lifecycleTables.has(actual.table) && id === `${actual.table}.${bare}_lifecycle_restrict` && actual.fn === oldRestrictName(actual.table)) { add(id, OLD_RESTRICT); continue; }
230
- if (functionNames.has(actual.fn)) add(id, 'unmanaged lifecycle trigger binding');
231
- else if (lifecycleTables.has(actual.table)) {
232
- const type = triggerTypes.get(id)!;
233
- if ((type & 32) !== 0) add(id, 'author TRUNCATE trigger refused: bypasses lifecycle row removal');
234
- else if ((type & (2 | 8)) === (2 | 8)) add(id, 'author BEFORE DELETE trigger refused: can suppress purge DELETE');
235
- }
236
- }
237
- return { failures: [...problems.values()].sort(), notes: [...notes].sort() };
238
- }