@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.
- package/CHANGELOG.md +54 -1
- package/package.json +3 -3
- package/src/cli/authz-compile.ts +15 -1
- package/src/cli/commands/db-apply.ts +38 -10
- package/src/cli/commands/db-backup.ts +62 -26
- package/src/cli/commands/db-build.ts +3 -1
- package/src/cli/commands/db-fork.ts +2 -1
- package/src/cli/commands/db-plan.ts +4 -0
- package/src/cli/commands/db-pull.ts +46 -12
- package/src/cli/commands/db-reconcile.ts +34 -5
- package/src/cli/commands/db-sync.ts +36 -3
- package/src/cli/commands/db.ts +46 -21
- package/src/cli/db-build.ts +20 -1
- package/src/cli/derived-apply.ts +17 -2
- package/src/cli/derived-catchup.ts +31 -6
- package/src/cli/derived-compile.ts +2 -1
- package/src/cli/derived-introspect.ts +22 -1
- package/src/cli/derived-plan.ts +64 -0
- package/src/cli/derived-render.ts +1 -0
- package/src/cli/index.ts +11 -1
- package/src/cli/lifecycle-canon.ts +2 -1
- package/src/cli/lifecycle-derived.ts +264 -41
- package/src/cli/lifecycle-preflight.ts +617 -0
- package/src/cli/lifecycle-pull.ts +37 -4
- package/src/cli/model-render.ts +73 -7
- package/src/cli/schema-compile.ts +3 -64
- package/src/cli/state-function-deps.ts +2 -1
- package/src/cli/lifecycle-verify.ts +0 -238
|
@@ -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,
|
|
6
|
-
*
|
|
7
|
-
* authored ability and the columns as authored fields (defineModel refuses both), and the
|
|
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
|
|
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
|
package/src/cli/model-render.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|