@everystack/cli 0.4.100 → 0.4.103
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 +112 -0
- package/package.json +3 -3
- package/src/cli/authz-baseline.ts +8 -4
- package/src/cli/authz-compile.ts +91 -14
- package/src/cli/authz-contract.ts +29 -14
- package/src/cli/authz-derive.ts +284 -30
- package/src/cli/authz-reconcile.ts +6 -4
- package/src/cli/commands/db-check.ts +40 -0
- package/src/cli/commands/db-generate.ts +10 -9
- package/src/cli/commands/db-plan.ts +32 -13
- package/src/cli/commands/db-pull.ts +155 -63
- package/src/cli/commands/db-reconcile.ts +22 -3
- package/src/cli/commands/db-sync.ts +6 -5
- package/src/cli/commands/db.ts +91 -8
- package/src/cli/commands/update.ts +3 -2
- package/src/cli/db-build.ts +1 -0
- package/src/cli/declared-derived.ts +3 -1
- package/src/cli/declared-diff.ts +5 -1
- package/src/cli/derived-apply.ts +116 -10
- package/src/cli/derived-grants.ts +4 -2
- package/src/cli/derived-introspect.ts +23 -3
- package/src/cli/derived-plan.ts +37 -1
- package/src/cli/derived-render.ts +33 -34
- package/src/cli/edge-plan.ts +92 -9
- package/src/cli/generated-artifacts.ts +22 -0
- package/src/cli/generated-names.ts +108 -0
- package/src/cli/host-url.ts +48 -27
- package/src/cli/index-lock.ts +65 -0
- package/src/cli/index.ts +5 -5
- package/src/cli/lifecycle-canon.ts +37 -0
- package/src/cli/lifecycle-derived.ts +787 -0
- package/src/cli/lifecycle-pull.ts +252 -0
- package/src/cli/lifecycle-verify.ts +238 -0
- package/src/cli/migration-compile.ts +11 -5
- package/src/cli/migration-generate.ts +24 -3
- package/src/cli/model-api.ts +9 -0
- package/src/cli/model-map.ts +255 -0
- package/src/cli/model-render.ts +232 -76
- package/src/cli/plain-output.ts +211 -10
- package/src/cli/runbook/model-section.ts +8 -5
- package/src/cli/schema-compile.ts +64 -18
- package/src/cli/schema-diff.ts +166 -31
- package/src/cli/schema-fingerprint.ts +40 -6
- package/src/cli/schema-introspect.ts +97 -4
- package/src/cli/schema-source.ts +40 -9
- package/src/cli/schema-swap.ts +3 -2
- package/src/cli/state-apply.ts +10 -4
- package/src/cli/ts-template.ts +52 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* db:pull's lifecycle fold — reads a lifecycle table back as `lifecycle: true`.
|
|
3
|
+
*
|
|
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
|
|
8
|
+
* objects as derived descriptors (they would be generated twice).
|
|
9
|
+
*
|
|
10
|
+
* So the fold removes all of it from the pull's inputs, and the renderer writes `lifecycle: true`.
|
|
11
|
+
* It folds a table only when the catalog holds EXACTLY what the compiler writes: the four columns
|
|
12
|
+
* as generated, the reserved role's grants and policies equal to a compiled lifecycle model's, the
|
|
13
|
+
* role in no other policy, and the guard at the head of every other SELECT policy. Anything else is left as is, so the pull renders what is there and the model's own
|
|
14
|
+
* refusal names it. The generated objects are removed by name; a hand-edited body still shows,
|
|
15
|
+
* because the derived plan compares definitions.
|
|
16
|
+
*
|
|
17
|
+
* Pure: inputs in, folded copies out.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { defineModel, field, LIFECYCLE_OWNER_ROLE, type ModelDescriptor } from '@everystack/model';
|
|
21
|
+
import type { SchemaSnapshot, TableSchema } from './schema-introspect';
|
|
22
|
+
import type { AuthzContract, PolicyContract, TableContract } from './authz-contract';
|
|
23
|
+
import type { DerivedCatalog } from './derived-introspect';
|
|
24
|
+
import { compileTableContract } from './authz-compile';
|
|
25
|
+
import { lifecycleFkIndexName } from './schema-compile';
|
|
26
|
+
|
|
27
|
+
/** The generated columns as the catalog reports them (`format_type`), nullable, no default. */
|
|
28
|
+
const GENERATED_COLUMNS: Record<string, string> = {
|
|
29
|
+
deleted_at: 'timestamp with time zone',
|
|
30
|
+
deleted_by: 'text',
|
|
31
|
+
deleted_batch: 'uuid',
|
|
32
|
+
deleted_xid: 'bigint',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const GUARD = '(deleted_at IS NULL)';
|
|
36
|
+
const DELETED_ONLY = '(deleted_at IS NOT NULL)';
|
|
37
|
+
|
|
38
|
+
/** The commands the measured effective matrix reports (`effectivePrivileges`). */
|
|
39
|
+
const EFFECTIVE_COMMANDS = new Set(['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
|
|
40
|
+
|
|
41
|
+
export interface LifecycleFold {
|
|
42
|
+
snapshot: SchemaSnapshot;
|
|
43
|
+
contract: AuthzContract;
|
|
44
|
+
catalog: DerivedCatalog;
|
|
45
|
+
/** Schema-qualified tables folded to `lifecycle: true`. */
|
|
46
|
+
tables: Set<string>;
|
|
47
|
+
/** lifecycle: { deleteWhen }'s predicate per folded table, read from its delete_when function's manifest. */
|
|
48
|
+
deleteWhen: Map<string, string>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const split = (qualified: string): { schema: string; name: string } => {
|
|
52
|
+
const dot = qualified.indexOf('.');
|
|
53
|
+
return dot < 0 ? { schema: 'public', name: qualified } : { schema: qualified.slice(0, dot), name: qualified.slice(dot + 1) };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const sorted = (xs: readonly string[] | undefined): string => JSON.stringify([...(xs ?? [])].sort());
|
|
57
|
+
|
|
58
|
+
const policyKey = (p: PolicyContract): string =>
|
|
59
|
+
JSON.stringify([p.name, p.command, [...p.roles].sort(), p.permissive, p.using, p.check]);
|
|
60
|
+
|
|
61
|
+
/** The reserved role's part of a compiled lifecycle model — what an exact table must hold live. */
|
|
62
|
+
function reservedRoleContract(qualified: string): TableContract {
|
|
63
|
+
const { schema, name } = split(qualified);
|
|
64
|
+
const model: ModelDescriptor = defineModel(name, { schema, lifecycle: true, fields: { id: field.integer().primaryKey() } });
|
|
65
|
+
return compileTableContract(model);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The SELECT policy's text with the guard removed (null: nothing else), or undefined when it does
|
|
70
|
+
* not carry the guard as compiled. The compiler puts the guard first in a top-level AND, and
|
|
71
|
+
* PostgreSQL deparses an AND as `((a) AND (b) …)`, so the guard is exactly this prefix.
|
|
72
|
+
*/
|
|
73
|
+
function unguarded(using: string | null): string | null | undefined {
|
|
74
|
+
if (using === GUARD) return null;
|
|
75
|
+
if (using?.startsWith(`(${GUARD} AND `) && using.endsWith(')')) return using.slice(GUARD.length + 6, -1);
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function columnsAreGenerated(table: TableSchema): boolean {
|
|
80
|
+
return Object.entries(GENERATED_COLUMNS).every(([name, type]) => {
|
|
81
|
+
const c = table.columns.find((col) => col.name === name);
|
|
82
|
+
return c !== undefined && c.type === type && !c.notNull && c.default === null;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The lifecycle columns a deleted reader is never granted (compileColumnGrants). */
|
|
87
|
+
const DELETED_READ_HIDDEN = new Set(['deleted_by', 'deleted_batch', 'deleted_xid']);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* can('read', { role, deleted }) with no columns compiles to a column SELECT of every column but
|
|
91
|
+
* deleted_by, deleted_batch and deleted_xid. Fold exactly that grant back to the table SELECT the
|
|
92
|
+
* renderer reads as a plain role read, so the pull renders `deleted` without a columns list; the
|
|
93
|
+
* compile turns it back into the same column grant. Any other column set stays as it is.
|
|
94
|
+
*/
|
|
95
|
+
function foldDeletedReadGrants(folded: TableContract, table: TableSchema): void {
|
|
96
|
+
const every = sorted(table.columns.map((c) => c.name).filter((name) => !DELETED_READ_HIDDEN.has(name)));
|
|
97
|
+
for (const p of folded.policies) {
|
|
98
|
+
if (p.deleted === undefined || p.roles.length !== 1) continue;
|
|
99
|
+
const role = p.roles[0];
|
|
100
|
+
const cols = folded.columnGrants?.[role];
|
|
101
|
+
if (!cols || folded.grants[role]?.includes('SELECT') || sorted(cols.SELECT) !== every) continue;
|
|
102
|
+
folded.grants = { ...folded.grants, [role]: [...(folded.grants[role] ?? []), 'SELECT'].sort() };
|
|
103
|
+
const { SELECT: _select, ...rest } = cols;
|
|
104
|
+
const { [role]: _role, ...others } = folded.columnGrants!;
|
|
105
|
+
folded.columnGrants = Object.keys(rest).length ? { ...others, [role]: rest } : others;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The folded contract for one table, or undefined when the table is not exactly lifecycle. */
|
|
110
|
+
function foldContract(live: TableContract, table: TableSchema): TableContract | undefined {
|
|
111
|
+
const role = LIFECYCLE_OWNER_ROLE;
|
|
112
|
+
const expected = reservedRoleContract(live.table);
|
|
113
|
+
if (sorted(live.grants[role]) !== sorted(expected.grants[role])) return undefined;
|
|
114
|
+
const liveCols = live.columnGrants?.[role] ?? {};
|
|
115
|
+
const expectedCols = expected.columnGrants?.[role] ?? {};
|
|
116
|
+
if (sorted(Object.keys(liveCols)) !== sorted(Object.keys(expectedCols))) return undefined;
|
|
117
|
+
if (Object.keys(expectedCols).some((priv) => sorted(liveCols[priv]) !== sorted(expectedCols[priv]))) return undefined;
|
|
118
|
+
// The direct ACL matching is not enough: the role can hold more through membership or PUBLIC.
|
|
119
|
+
// When the live read measured it, the role must hold exactly the commands its compiled grants
|
|
120
|
+
// imply (a column grant counts as its command). Absent = not measured: the direct check stands.
|
|
121
|
+
// A grant to PUBLIC on the table widens the measured set too, so such a table stays unfolded:
|
|
122
|
+
// the check errs toward not folding, never toward a false fold.
|
|
123
|
+
if (live.effectivePrivileges) {
|
|
124
|
+
const implied = new Set([...(expected.grants[role] ?? []), ...Object.keys(expectedCols)].filter((p) => EFFECTIVE_COMMANDS.has(p)));
|
|
125
|
+
if (sorted(live.effectivePrivileges[role]) !== sorted([...implied])) return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const naming = (p: PolicyContract) => p.roles.includes(role);
|
|
129
|
+
const livePolicies = live.policies.filter(naming);
|
|
130
|
+
const expectedPolicies = expected.policies.filter(naming);
|
|
131
|
+
if (sorted(livePolicies.map(policyKey)) !== sorted(expectedPolicies.map(policyKey))) return undefined;
|
|
132
|
+
|
|
133
|
+
const policies: PolicyContract[] = [];
|
|
134
|
+
for (const p of live.policies) {
|
|
135
|
+
if (naming(p)) continue;
|
|
136
|
+
if (p.command !== 'SELECT') {
|
|
137
|
+
policies.push(p);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const rest = unguarded(p.using);
|
|
141
|
+
if (rest !== undefined) {
|
|
142
|
+
policies.push({ ...p, using: rest ?? 'true' });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
// can('read', { role, deleted }): a role's own read policy compiled without the guard
|
|
146
|
+
// ('include') or with (deleted_at IS NOT NULL) in its place ('only'). Any other SELECT policy
|
|
147
|
+
// without the guard leaves the table unfolded, as before.
|
|
148
|
+
const own = p.roles.length === 1 && !['anon', 'authenticated', 'public'].includes(p.roles[0])
|
|
149
|
+
&& p.name === `${split(live.table).name}_select_${p.roles[0]}`;
|
|
150
|
+
if (!own) return undefined;
|
|
151
|
+
if (p.using === DELETED_ONLY) policies.push({ ...p, using: 'true', deleted: 'only' });
|
|
152
|
+
else if (p.using?.startsWith(`(${DELETED_ONLY} AND `) && p.using.endsWith(')')) {
|
|
153
|
+
policies.push({ ...p, using: p.using.slice(DELETED_ONLY.length + 6, -1), deleted: 'only' });
|
|
154
|
+
} else policies.push({ ...p, deleted: 'include' });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const { [role]: _grant, ...grants } = live.grants;
|
|
158
|
+
const folded: TableContract = { ...live, grants, policies };
|
|
159
|
+
if (live.columnGrants) {
|
|
160
|
+
const { [role]: _cols, ...columnGrants } = live.columnGrants;
|
|
161
|
+
folded.columnGrants = columnGrants;
|
|
162
|
+
}
|
|
163
|
+
if (live.effectivePrivileges) {
|
|
164
|
+
const { [role]: _eff, ...effectivePrivileges } = live.effectivePrivileges;
|
|
165
|
+
folded.effectivePrivileges = effectivePrivileges;
|
|
166
|
+
}
|
|
167
|
+
foldDeletedReadGrants(folded, table);
|
|
168
|
+
return folded;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The five generated objects of one lifecycle table, exactly as the derived catalog keys them. The
|
|
173
|
+
* restore takes the primary key, and a function signature carries no type modifier
|
|
174
|
+
* (`character varying(255)` is `character varying` there). An author's own overload stays.
|
|
175
|
+
*/
|
|
176
|
+
function generatedIdentities(table: TableSchema): string[] {
|
|
177
|
+
const { schema, name } = split(table.table);
|
|
178
|
+
const pk = table.columns.find((c) => c.name === table.primaryKey[0]);
|
|
179
|
+
const arg = (pk?.type ?? '').replace(/\(\d+(,\s*\d+)?\)/g, '');
|
|
180
|
+
return [
|
|
181
|
+
`${schema}.${name}_lifecycle_transition()`,
|
|
182
|
+
`${schema}.${name}_lifecycle_guard()`,
|
|
183
|
+
`${schema}.${name}_restore(${arg})`,
|
|
184
|
+
`${table.table}.${name}_lifecycle_transition`,
|
|
185
|
+
`${table.table}.${name}_lifecycle_guard`,
|
|
186
|
+
`${schema}.${name}_lifecycle_delete_when()`,
|
|
187
|
+
`${table.table}.${name}_lifecycle_delete_when`,
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The generator's lifecycle FK index (lifecycleDependentIndexes): plain btree, exactly the columns of
|
|
193
|
+
* a foreign key into a folded lifecycle table, under exactly the generated name. Folded like the
|
|
194
|
+
* lifecycle columns, so the pull never renders it as an author index. Anything else stays.
|
|
195
|
+
*/
|
|
196
|
+
function withoutGeneratedFkIndexes(t: TableSchema, lifecycleTables: ReadonlySet<string>): TableSchema {
|
|
197
|
+
const name = t.table.slice(t.table.indexOf('.') + 1);
|
|
198
|
+
const indexes = t.indexes.filter((ix) => !(
|
|
199
|
+
!ix.unique && !ix.where && !ix.using && !ix.include?.length
|
|
200
|
+
&& ix.name === lifecycleFkIndexName(name, ix.columns)
|
|
201
|
+
&& t.foreignKeys.some((fk) => lifecycleTables.has(fk.refTable.includes('.') ? fk.refTable : `public.${fk.refTable}`)
|
|
202
|
+
&& JSON.stringify(fk.columns) === JSON.stringify(ix.columns))));
|
|
203
|
+
return indexes.length === t.indexes.length ? t : { ...t, indexes };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function foldLifecycle(snapshot: SchemaSnapshot, contract: AuthzContract, catalog: DerivedCatalog): LifecycleFold {
|
|
207
|
+
const tables = new Set<string>();
|
|
208
|
+
const foldedContracts = new Map<string, TableContract>();
|
|
209
|
+
for (const table of snapshot.tables) {
|
|
210
|
+
if (table.primaryKey.length !== 1 || !columnsAreGenerated(table)) continue;
|
|
211
|
+
const live = contract.tables.find((t) => t.table === table.table);
|
|
212
|
+
const folded = live && live.rls.enabled ? foldContract(live, table) : undefined;
|
|
213
|
+
if (!folded) continue;
|
|
214
|
+
tables.add(table.table);
|
|
215
|
+
foldedContracts.set(table.table, folded);
|
|
216
|
+
}
|
|
217
|
+
if (tables.size === 0) return { snapshot, contract, catalog, tables, deleteWhen: new Map() };
|
|
218
|
+
|
|
219
|
+
// A guarded table's predicate is the delete_when function's manifest input. The pull must render
|
|
220
|
+
// exactly that text, or the next plan would rebuild the guard; without it, refuse rather than drop
|
|
221
|
+
// the guard or render it as an author function.
|
|
222
|
+
const deleteWhen = new Map<string, string>();
|
|
223
|
+
for (const table of tables) {
|
|
224
|
+
const { schema, name } = split(table);
|
|
225
|
+
const fn = catalog.objects.find((o) => o.kind === 'function' && o.identity === `${schema}.${name}_lifecycle_delete_when()`);
|
|
226
|
+
if (!fn) continue;
|
|
227
|
+
let manifest: { role?: unknown; model?: unknown; deleteWhen?: unknown } | undefined;
|
|
228
|
+
try { manifest = typeof fn.comment === 'string' ? JSON.parse(fn.comment) : undefined; } catch { manifest = undefined; }
|
|
229
|
+
if (!manifest || manifest.role !== 'delete_when' || manifest.model !== table || typeof manifest.deleteWhen !== 'string' || manifest.deleteWhen.trim() === '') {
|
|
230
|
+
throw new Error(`db:pull: ${fn.identity} carries no readable deleteWhen in its lifecycle manifest, so ${table}'s lifecycle: { deleteWhen } `
|
|
231
|
+
+ `cannot be rendered exactly. Run db:apply from the checkout that declared it (it rewrites the manifest), then pull again.`);
|
|
232
|
+
}
|
|
233
|
+
deleteWhen.set(table, manifest.deleteWhen);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const generated = new Set(snapshot.tables.filter((t) => tables.has(t.table)).flatMap(generatedIdentities));
|
|
237
|
+
return {
|
|
238
|
+
snapshot: {
|
|
239
|
+
...snapshot,
|
|
240
|
+
tables: snapshot.tables.map((t) => withoutGeneratedFkIndexes(
|
|
241
|
+
tables.has(t.table) ? { ...t, columns: t.columns.filter((c) => !Object.hasOwn(GENERATED_COLUMNS, c.name)) } : t, tables)),
|
|
242
|
+
},
|
|
243
|
+
contract: { ...contract, tables: contract.tables.map((t) => foldedContracts.get(t.table) ?? t) },
|
|
244
|
+
catalog: {
|
|
245
|
+
objects: catalog.objects.filter((o) => !generated.has(o.identity)),
|
|
246
|
+
edges: catalog.edges.filter((e) => !generated.has(e.dependent) && !generated.has(e.referenced)),
|
|
247
|
+
provenance: catalog.provenance.filter((p) => !generated.has(p.identity)),
|
|
248
|
+
},
|
|
249
|
+
tables,
|
|
250
|
+
deleteWhen,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
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
|
+
}
|
|
@@ -19,13 +19,14 @@
|
|
|
19
19
|
* lands with whole-DB introspection. The emission is identical either way.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import type
|
|
22
|
+
import { lifecycleDependentIndexes, relationDeleteActions, type ModelDescriptor, type Module, type SequenceDescriptor } from '@everystack/model';
|
|
23
23
|
import type { AuthzContract, TableContract } from './authz-contract';
|
|
24
|
-
import { compileCreateTable, compileEnums, compileSequences, sequenceCreateSql, foreignKeySql, modelConstraintSpecs, qualifiedTable, toSnakeCase, type CompileTableOptions } from './schema-compile';
|
|
24
|
+
import { compileCreateTable, compileEnums, compileSequences, sequenceCreateSql, foreignKeySql, modelConstraintSpecs, dependentIndexSpecs, qualifiedTable, toSnakeCase, type CompileTableOptions } from './schema-compile';
|
|
25
25
|
import { compileTableContract } from './authz-compile';
|
|
26
26
|
import { emitReconcileSql } from './authz-reconcile';
|
|
27
27
|
import { emitSchemaSql, nextvalSequence, type SchemaChange } from './schema-diff';
|
|
28
28
|
import { compileDerived } from './derived-compile';
|
|
29
|
+
import { lifecycleDerived } from './lifecycle-derived';
|
|
29
30
|
import { renderEnsureObjectSchemas } from './derived-apply';
|
|
30
31
|
|
|
31
32
|
/** The empty (not-yet-created) authz state for a table — the greenfield baseline. */
|
|
@@ -44,6 +45,10 @@ export function compileMigration(
|
|
|
44
45
|
): string[] {
|
|
45
46
|
const sql: string[] = [];
|
|
46
47
|
const schemaOf = (m: ModelDescriptor): string => m.schema ?? 'public';
|
|
48
|
+
// A parent's hasMany `dependent` sets a child FK's ON DELETE; resolved over the whole set
|
|
49
|
+
// from THIS set, before any FK is compiled.
|
|
50
|
+
const dependents = relationDeleteActions(models);
|
|
51
|
+
const dependentIndexes = lifecycleDependentIndexes(models);
|
|
47
52
|
|
|
48
53
|
// 0. Extensions — the BOOTSTRAP, before any table that might use one of their types.
|
|
49
54
|
// compileModuleMigration has always emitted these; compileMigration had no way to,
|
|
@@ -103,13 +108,13 @@ export function compileMigration(
|
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
// 2. Foreign keys — after every table exists.
|
|
106
|
-
for (const model of models) sql.push(...foreignKeySql(model));
|
|
111
|
+
for (const model of models) sql.push(...foreignKeySql(model, dependents));
|
|
107
112
|
|
|
108
113
|
// 2b. Standalone indexes — after the columns they cover exist. The ON clause is the
|
|
109
114
|
// schema-qualified table, so a non-public index targets the right table.
|
|
110
115
|
const indexCreates: SchemaChange[] = [];
|
|
111
116
|
for (const model of models) {
|
|
112
|
-
for (const ix of modelConstraintSpecs(model).indexes) {
|
|
117
|
+
for (const ix of [...modelConstraintSpecs(model).indexes, ...dependentIndexSpecs(model, dependentIndexes)]) {
|
|
113
118
|
indexCreates.push({
|
|
114
119
|
kind: 'createIndex', table: qualifiedTable(model), name: ix.name, columns: ix.columns, unique: ix.unique,
|
|
115
120
|
...(ix.where ? { where: ix.where } : {}),
|
|
@@ -169,7 +174,8 @@ export function compileModuleMigration(modules: Module[], opts: CompileTableOpti
|
|
|
169
174
|
// objects — compiled in topological order, after every table. Greenfield = one
|
|
170
175
|
// complete script: state + compute; from then on the layer deploys via db:reconcile.
|
|
171
176
|
const models = modules.flatMap((m) => m.models);
|
|
172
|
-
|
|
177
|
+
// The lifecycle triggers are generated from the models, exactly as composeDeclaredDerived does.
|
|
178
|
+
const derivedObjects = compileDerived(models, [...modules.flatMap((m) => m.derived), ...lifecycleDerived(models)]);
|
|
173
179
|
|
|
174
180
|
// 4a. A schema that ONLY derived objects live in has no model to create it, so phase 2
|
|
175
181
|
// never does — and the first CREATE VIEW in it fails with `schema … does not exist`.
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
|
|
19
|
+
import { assertExternalTargets, lifecycleDependentIndexes, relationDeleteActions } from '@everystack/model';
|
|
19
20
|
import type { SchemaSnapshot, ColumnSchema } from './schema-introspect';
|
|
20
21
|
import type { AuthzContract, SequenceGrantContract } from './authz-contract';
|
|
21
22
|
import { compileTableSchema, compileRenames, compileTableRenames, compileTableMoves, compileCreateTable, compileEnums, compileSequences } from './schema-compile';
|
|
@@ -80,7 +81,8 @@ function isRemoval(change: SchemaChange, replacedConstraints: Set<string>): bool
|
|
|
80
81
|
// DROP IDENTITY destroys the backing sequence and its counter — a removal, not a change.
|
|
81
82
|
// ADD/SET GENERATED are not: they create or retune the auto-value and lose nothing.
|
|
82
83
|
if (change.kind === 'setIdentity' && change.identity == null) return true;
|
|
83
|
-
|
|
84
|
+
// A replaced primary key's drop is paired with the declared key's add in the same edge.
|
|
85
|
+
if (change.kind === 'dropConstraint') return !change.replacesPrimaryKey && !replacedConstraints.has(`${change.table}::${change.name}`);
|
|
84
86
|
return false;
|
|
85
87
|
}
|
|
86
88
|
|
|
@@ -124,12 +126,31 @@ export function unmodeledTables(models: ModelDescriptor[], current: SchemaSnapsh
|
|
|
124
126
|
* the database (auth, jobs, observability) is left to its own migrations.
|
|
125
127
|
*/
|
|
126
128
|
export function generateMigrationSql(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
|
|
129
|
+
return generateMigration(models, current, opts).statements;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* {@link generateMigrationSql}, with the structural change set the data phase was emitted from.
|
|
134
|
+
* For a caller that must decide on what a statement IS (db:plan refusing a change it can only
|
|
135
|
+
* hold as a notice) rather than read it back out of the SQL text.
|
|
136
|
+
*/
|
|
137
|
+
export function generateMigration(
|
|
138
|
+
models: ModelDescriptor[],
|
|
139
|
+
current: SchemaSnapshot,
|
|
140
|
+
opts: GenerateOptions = {},
|
|
141
|
+
): { statements: string[]; changes: SchemaChange[] } {
|
|
142
|
+
// An external FK target that this set models is an authoring error (use the model). linkModels
|
|
143
|
+
// checks it at load; checked again here because a caller can hand a set in without linking.
|
|
144
|
+
assertExternalTargets(models);
|
|
145
|
+
// hasMany `dependent` → the child FK's ON DELETE, derived from THIS set (and checked, for the same reason).
|
|
146
|
+
const dependents = relationDeleteActions(models);
|
|
147
|
+
const dependentIndexes = lifecycleDependentIndexes(models);
|
|
127
148
|
const schema = opts.schema ?? 'public';
|
|
128
149
|
// A table's schema comes from the model first (package tables live in dist/ops/auth),
|
|
129
150
|
// then the call default — so one run spans schemas.
|
|
130
151
|
const schemaOf = (m: ModelDescriptor): string => m.schema ?? schema;
|
|
131
152
|
const desiredEnums = compileEnums(models);
|
|
132
|
-
const desired: SchemaSnapshot = { tables: models.map((m) => compileTableSchema(m, { schema })), enums: desiredEnums, ...(opts.sequences?.length ? { sequences: compileSequences(opts.sequences) } : {}) };
|
|
153
|
+
const desired: SchemaSnapshot = { tables: models.map((m) => compileTableSchema(m, { schema, dependents, dependentIndexes })), enums: desiredEnums, ...(opts.sequences?.length ? { sequences: compileSequences(opts.sequences) } : {}) };
|
|
133
154
|
const declaredTables = new Set(desired.tables.map((t) => t.table));
|
|
134
155
|
const declaredEnums = new Set(desiredEnums.map((e) => e.name));
|
|
135
156
|
// Scope both tables and enums to what the Models declare — an undeclared table OR enum
|
|
@@ -305,7 +326,7 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
|
|
|
305
326
|
})
|
|
306
327
|
: [];
|
|
307
328
|
|
|
308
|
-
return [...extensionPhase, ...schemaPhase, ...usagePhase, ...dataPhase, ...authzPhase];
|
|
329
|
+
return { statements: [...extensionPhase, ...schemaPhase, ...usagePhase, ...dataPhase, ...authzPhase], changes };
|
|
309
330
|
}
|
|
310
331
|
|
|
311
332
|
/**
|
package/src/cli/model-api.ts
CHANGED
|
@@ -23,6 +23,15 @@ export * from './model-render';
|
|
|
23
23
|
export * from './migration-compile';
|
|
24
24
|
export * from './migration-generate';
|
|
25
25
|
|
|
26
|
+
// --- the DECLARED state of a model set, as introspection would see it -------
|
|
27
|
+
// `compileDeclaredState(models, { schema })` → `{ snapshot, contract }`: the same compilation
|
|
28
|
+
// db:plan, db:check and the fingerprint use. Diff `.snapshot` against `introspectSchema(...)`
|
|
29
|
+
// to verify a database built from the Models. Prefer it to mapping `compileTableSchema` over
|
|
30
|
+
// the set yourself: the set derives table parts no single Model carries (the hasMany ON DELETE,
|
|
31
|
+
// the generated `<table>_<column>_lifecycle_fk_idx` indexes), and a hand-rolled loop that misses
|
|
32
|
+
// one reads the live object as a drop.
|
|
33
|
+
export { compileDeclaredState, type DeclaredState } from './declared-diff';
|
|
34
|
+
|
|
26
35
|
// --- authz layer: contract compile/introspect/diff/reconcile ---------------
|
|
27
36
|
export * from './authz-compile';
|
|
28
37
|
export * from './authz-contract';
|