@everystack/cli 0.3.21 → 0.3.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/package.json +9 -4
- package/src/cli/apply-authority.ts +150 -0
- package/src/cli/commands/db-apply.ts +183 -5
- package/src/cli/commands/db-approvers.ts +71 -0
- package/src/cli/commands/db-check.ts +306 -0
- package/src/cli/commands/db-plan.ts +12 -0
- package/src/cli/commands/db-sync.ts +6 -1
- package/src/cli/edge-plan.ts +76 -34
- package/src/cli/git-descent.ts +251 -0
- package/src/cli/index.ts +11 -1
- package/src/cli/schema-source.ts +5 -0
- package/src/cli/state-apply.ts +30 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack db:check` — the CI regenerate-and-match gate (brick 7's PR
|
|
3
|
+
* invariant: the merged declared state must compose, and generated
|
|
4
|
+
* artifacts must match regeneration).
|
|
5
|
+
*
|
|
6
|
+
* db:check [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>]
|
|
7
|
+
* [--database-url <url>] [--json]
|
|
8
|
+
*
|
|
9
|
+
* Two rings, both from the checkout alone (no target database is ever
|
|
10
|
+
* touched — the gate runs on every PR):
|
|
11
|
+
*
|
|
12
|
+
* STATIC — the models load and compose (a semantic merge conflict that
|
|
13
|
+
* git can't see, like two branches declaring the same table, fails
|
|
14
|
+
* here); the generated drizzle schema matches a byte-for-byte
|
|
15
|
+
* regeneration (hand-edited artifacts and forgotten `db:generate` runs
|
|
16
|
+
* fail loudly); the derived sources in db/sql parse.
|
|
17
|
+
*
|
|
18
|
+
* EPHEMERAL COMPOSE — with a scratch PostgreSQL (`--database-url` /
|
|
19
|
+
* DATABASE_URL), build the merged declared state FROM SCRATCH on an
|
|
20
|
+
* ephemeral database (created and dropped inside the run): state, authz,
|
|
21
|
+
* and the derived layer. Cross-layer breakage both layers hid from the
|
|
22
|
+
* static ring — a matview referencing a dropped column — fails here,
|
|
23
|
+
* mechanically. The bar is the brick-2 identity: the fresh database must
|
|
24
|
+
* land EXACTLY on the models' fingerprint (MATCH), which is
|
|
25
|
+
* regenerate-and-match at full strength.
|
|
26
|
+
*
|
|
27
|
+
* Exit 1 on any failure; nothing is ever skipped silently.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import fs from 'node:fs/promises';
|
|
31
|
+
import postgres from 'postgres';
|
|
32
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
33
|
+
import type { TableContract } from '../authz-contract.js';
|
|
34
|
+
import { compileDeclaredState } from '../declared-diff.js';
|
|
35
|
+
import { compileMigration } from '../migration-compile.js';
|
|
36
|
+
import { compileDrizzleSource } from '../schema-source.js';
|
|
37
|
+
import { parseSqlSources, type SourceFile } from '../derived-source.js';
|
|
38
|
+
import { currentGitRef } from '../state-apply.js';
|
|
39
|
+
import { createUrlRunner } from '../db-source.js';
|
|
40
|
+
import { resolveModelsPath } from '../models-path.js';
|
|
41
|
+
import { executeSync, buildSyncReport, readSqlDirIfPresent } from './db-sync.js';
|
|
42
|
+
import { loadModels } from './db-generate.js';
|
|
43
|
+
import { step, success, fail, info, warn } from '../output.js';
|
|
44
|
+
|
|
45
|
+
const DEFAULT_SQL_DIR = 'db/sql';
|
|
46
|
+
const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// The static ring (pure).
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
export interface CheckFinding {
|
|
53
|
+
level: 'ok' | 'note' | 'fail';
|
|
54
|
+
area: 'models' | 'compose' | 'artifact' | 'compute';
|
|
55
|
+
message: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface StaticCheckInput {
|
|
59
|
+
modelsPath: string;
|
|
60
|
+
/** null = the barrel did not load — the gate's first failure. */
|
|
61
|
+
models: ModelDescriptor[] | null;
|
|
62
|
+
modelsError?: string;
|
|
63
|
+
artifactPath: string;
|
|
64
|
+
/** null = no generated schema artifact on disk. */
|
|
65
|
+
artifactSource: string | null;
|
|
66
|
+
/** null = no db/sql directory. */
|
|
67
|
+
sources: SourceFile[] | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
71
|
+
const findings: CheckFinding[] = [];
|
|
72
|
+
|
|
73
|
+
if (input.models === null) {
|
|
74
|
+
findings.push({
|
|
75
|
+
level: 'fail',
|
|
76
|
+
area: 'models',
|
|
77
|
+
message: `models did not load from ${input.modelsPath}: ${input.modelsError ?? 'unknown error'}`,
|
|
78
|
+
});
|
|
79
|
+
return findings;
|
|
80
|
+
}
|
|
81
|
+
findings.push({ level: 'ok', area: 'models', message: `${input.models.length} model(s) loaded from ${input.modelsPath}` });
|
|
82
|
+
|
|
83
|
+
// The semantic merge conflict git can't see: two branches declaring the
|
|
84
|
+
// same table both merge cleanly — the merged state must still compose.
|
|
85
|
+
const seen = new Map<string, number>();
|
|
86
|
+
for (const m of input.models) seen.set(`${m.schema}.${m.table}`, (seen.get(`${m.schema}.${m.table}`) ?? 0) + 1);
|
|
87
|
+
const duplicates = [...seen.entries()].filter(([, n]) => n > 1).map(([t]) => t);
|
|
88
|
+
if (duplicates.length > 0) {
|
|
89
|
+
findings.push({
|
|
90
|
+
level: 'fail',
|
|
91
|
+
area: 'compose',
|
|
92
|
+
message: `the merged declared state does NOT compose: table(s) declared more than once — ${duplicates.join(', ')}. A merge landed two declarations of the same table; resolve to one.`,
|
|
93
|
+
});
|
|
94
|
+
return findings;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
compileDeclaredState(input.models);
|
|
98
|
+
const statements = compileMigration(input.models);
|
|
99
|
+
findings.push({ level: 'ok', area: 'compose', message: `declared state composes — ${statements.length} statement(s) from scratch` });
|
|
100
|
+
} catch (err: any) {
|
|
101
|
+
findings.push({ level: 'fail', area: 'compose', message: `the merged declared state does NOT compose: ${err.message}` });
|
|
102
|
+
return findings;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (input.artifactSource === null) {
|
|
106
|
+
findings.push({
|
|
107
|
+
level: 'note',
|
|
108
|
+
area: 'artifact',
|
|
109
|
+
message: `no generated schema at ${input.artifactPath} — nothing to match (db:generate writes it)`,
|
|
110
|
+
});
|
|
111
|
+
} else if (input.artifactSource === compileDrizzleSource(input.models)) {
|
|
112
|
+
findings.push({ level: 'ok', area: 'artifact', message: `${input.artifactPath} matches regeneration byte-for-byte` });
|
|
113
|
+
} else {
|
|
114
|
+
findings.push({
|
|
115
|
+
level: 'fail',
|
|
116
|
+
area: 'artifact',
|
|
117
|
+
message: `${input.artifactPath} does NOT match regeneration — the models moved without db:generate, or the artifact was hand-edited (generated artifacts are never edited). Regenerate and commit.`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (input.sources === null) {
|
|
122
|
+
findings.push({ level: 'note', area: 'compute', message: 'no db/sql directory — derived layer not declared, nothing to parse' });
|
|
123
|
+
} else {
|
|
124
|
+
try {
|
|
125
|
+
const parsed = parseSqlSources(input.sources);
|
|
126
|
+
findings.push({
|
|
127
|
+
level: 'ok',
|
|
128
|
+
area: 'compute',
|
|
129
|
+
message: `${parsed.objects.length} derived object(s) parsed from ${input.sources.length} db/sql file(s)`,
|
|
130
|
+
});
|
|
131
|
+
for (const w of parsed.warnings) findings.push({ level: 'note', area: 'compute', message: w });
|
|
132
|
+
} catch (err: any) {
|
|
133
|
+
findings.push({ level: 'fail', area: 'compute', message: `db/sql does not parse: ${err.message}` });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return findings;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function checkFailed(findings: CheckFinding[]): boolean {
|
|
141
|
+
return findings.some((f) => f.level === 'fail');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// The ephemeral compose ring.
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/** Every role the contract grants to or names in a policy — the compose DB needs them. */
|
|
149
|
+
export function rolesFromContract(tables: TableContract[]): string[] {
|
|
150
|
+
const roles = new Set<string>();
|
|
151
|
+
for (const t of tables) {
|
|
152
|
+
for (const grantee of Object.keys(t.grants)) roles.add(grantee);
|
|
153
|
+
for (const grantee of Object.keys(t.columnGrants ?? {})) roles.add(grantee);
|
|
154
|
+
for (const p of t.policies) for (const r of p.roles) roles.add(r);
|
|
155
|
+
}
|
|
156
|
+
roles.delete('PUBLIC');
|
|
157
|
+
roles.delete('public');
|
|
158
|
+
return [...roles].sort();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface EphemeralComposeResult {
|
|
162
|
+
database: string;
|
|
163
|
+
createdRoles: string[];
|
|
164
|
+
converged: boolean;
|
|
165
|
+
fingerprintMatch: boolean;
|
|
166
|
+
fingerprint: string;
|
|
167
|
+
report: string[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Build the declared state from scratch on an ephemeral database: CREATE
|
|
172
|
+
* DATABASE, roles the contract references (NOLOGIN, cluster-level, created
|
|
173
|
+
* only if absent), one `executeSync` for both layers, DROP DATABASE —
|
|
174
|
+
* always, error or not. The bar is `converged && fingerprintMatch`: a
|
|
175
|
+
* fresh database has nothing unmodeled and nothing to drop, so anything
|
|
176
|
+
* short of MATCH is a real compose fault.
|
|
177
|
+
*/
|
|
178
|
+
export async function executeEphemeralCompose(
|
|
179
|
+
adminUrl: string,
|
|
180
|
+
models: ModelDescriptor[],
|
|
181
|
+
sources: SourceFile[] | null,
|
|
182
|
+
opts: { actor?: string | null; gitRef?: string | null } = {},
|
|
183
|
+
): Promise<EphemeralComposeResult> {
|
|
184
|
+
const dbName = `escheck_${process.pid}_${Date.now()}`;
|
|
185
|
+
const admin = postgres(adminUrl, { max: 1 });
|
|
186
|
+
try {
|
|
187
|
+
await admin.unsafe(`CREATE DATABASE "${dbName}"`);
|
|
188
|
+
} finally {
|
|
189
|
+
await admin.end();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const { runner, end } = await createUrlRunner(adminUrl.replace(/\/[^/?]+(\?|$)/, `/${dbName}$1`));
|
|
193
|
+
try {
|
|
194
|
+
const roles = rolesFromContract(compileDeclaredState(models).contract.tables);
|
|
195
|
+
for (const role of roles) {
|
|
196
|
+
if (!/^[a-z_][a-z0-9_$]*$/i.test(role)) {
|
|
197
|
+
throw new Error(`contract references a role name that is not a plain identifier: ${JSON.stringify(role)}`);
|
|
198
|
+
}
|
|
199
|
+
await runner(
|
|
200
|
+
`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN CREATE ROLE "${role}" NOLOGIN; END IF; END $$`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const run = await executeSync(runner, models, sources, {
|
|
205
|
+
actor: opts.actor ?? 'db:check',
|
|
206
|
+
gitRef: opts.gitRef ?? null,
|
|
207
|
+
});
|
|
208
|
+
return {
|
|
209
|
+
database: dbName,
|
|
210
|
+
createdRoles: roles,
|
|
211
|
+
converged: run.converged,
|
|
212
|
+
fingerprintMatch: run.fingerprintMatch,
|
|
213
|
+
fingerprint: run.state.toFingerprint,
|
|
214
|
+
report: buildSyncReport(run),
|
|
215
|
+
};
|
|
216
|
+
} finally {
|
|
217
|
+
await end?.();
|
|
218
|
+
const reaper = postgres(adminUrl, { max: 1 });
|
|
219
|
+
try {
|
|
220
|
+
await reaper.unsafe(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`);
|
|
221
|
+
} finally {
|
|
222
|
+
await reaper.end();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// The CLI shell.
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
const MARK: Record<CheckFinding['level'], string> = { ok: '=', note: '·', fail: '!' };
|
|
232
|
+
|
|
233
|
+
export async function dbCheckCommand(flags: Record<string, string>): Promise<void> {
|
|
234
|
+
const modelsPath = resolveModelsPath(flags.models);
|
|
235
|
+
let models: ModelDescriptor[] | null = null;
|
|
236
|
+
let modelsError: string | undefined;
|
|
237
|
+
try {
|
|
238
|
+
step(`Loading models from ${modelsPath}...`);
|
|
239
|
+
models = await loadModels(modelsPath);
|
|
240
|
+
} catch (err: any) {
|
|
241
|
+
modelsError = err.message;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const artifactPath = flags['schema-out'] || DEFAULT_SCHEMA_OUT;
|
|
245
|
+
let artifactSource: string | null = null;
|
|
246
|
+
try {
|
|
247
|
+
artifactSource = await fs.readFile(artifactPath, 'utf8');
|
|
248
|
+
} catch {
|
|
249
|
+
artifactSource = null;
|
|
250
|
+
}
|
|
251
|
+
const sources = await readSqlDirIfPresent(flags['sql-dir'] || DEFAULT_SQL_DIR);
|
|
252
|
+
|
|
253
|
+
const findings = runStaticChecks({ modelsPath, models, modelsError, artifactPath, artifactSource, sources });
|
|
254
|
+
for (const f of findings) {
|
|
255
|
+
(f.level === 'fail' ? warn : info)(`${MARK[f.level]} ${f.area}: ${f.message}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let ephemeral: EphemeralComposeResult | null = null;
|
|
259
|
+
let ephemeralError: string | null = null;
|
|
260
|
+
const url = flags['database-url'] || process.env.DATABASE_URL;
|
|
261
|
+
const staticFailed = checkFailed(findings);
|
|
262
|
+
if (staticFailed) {
|
|
263
|
+
warn('ephemeral compose skipped — fix the static failures first.');
|
|
264
|
+
} else if (!url) {
|
|
265
|
+
warn('ephemeral compose SKIPPED — no --database-url / DATABASE_URL. Point db:check at a scratch PostgreSQL for the full gate (it creates and drops an ephemeral database).');
|
|
266
|
+
} else {
|
|
267
|
+
step('Composing the declared state from scratch on an ephemeral database...');
|
|
268
|
+
try {
|
|
269
|
+
ephemeral = await executeEphemeralCompose(url, models!, sources, {
|
|
270
|
+
actor: process.env.USER ?? 'db:check',
|
|
271
|
+
gitRef: currentGitRef(),
|
|
272
|
+
});
|
|
273
|
+
for (const line of ephemeral.report) info(` ${line}`);
|
|
274
|
+
} catch (err: any) {
|
|
275
|
+
ephemeralError = err.message;
|
|
276
|
+
warn(`! compose: the declared state does NOT build from scratch: ${ephemeralError}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const ephemeralFailed = ephemeralError !== null
|
|
281
|
+
|| (ephemeral !== null && !(ephemeral.converged && ephemeral.fingerprintMatch));
|
|
282
|
+
const failed = staticFailed || ephemeralFailed;
|
|
283
|
+
|
|
284
|
+
if (flags.json === 'true') {
|
|
285
|
+
console.log(JSON.stringify({
|
|
286
|
+
findings,
|
|
287
|
+
ephemeral: ephemeral === null ? null : {
|
|
288
|
+
converged: ephemeral.converged,
|
|
289
|
+
fingerprintMatch: ephemeral.fingerprintMatch,
|
|
290
|
+
fingerprint: ephemeral.fingerprint,
|
|
291
|
+
createdRoles: ephemeral.createdRoles,
|
|
292
|
+
},
|
|
293
|
+
ephemeralError,
|
|
294
|
+
ephemeralRan: ephemeral !== null,
|
|
295
|
+
failed,
|
|
296
|
+
}, null, 2));
|
|
297
|
+
} else if (failed) {
|
|
298
|
+
fail('db:check FAILED — the merged declared state is not shippable as-is (findings above).');
|
|
299
|
+
} else if (ephemeral !== null) {
|
|
300
|
+
success(`db:check passed — declared state composes from scratch and lands MATCH at ${ephemeral.fingerprint.slice(0, 12)}.`);
|
|
301
|
+
} else {
|
|
302
|
+
success('db:check passed (static ring only — no database provided for the ephemeral compose).');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (failed) process.exit(1);
|
|
306
|
+
}
|
|
@@ -24,6 +24,7 @@ import { introspectContract, type QueryRunner } from '../authz-contract.js';
|
|
|
24
24
|
import { introspectSchema } from '../schema-introspect.js';
|
|
25
25
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
26
26
|
import { mintEdgePlan, planHash, buildPlanSummary } from '../edge-plan.js';
|
|
27
|
+
import { verifyDescent } from '../git-descent.js';
|
|
27
28
|
import { currentGitRef } from '../state-apply.js';
|
|
28
29
|
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
29
30
|
import { resolveModelsPath } from '../models-path.js';
|
|
@@ -101,6 +102,17 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
101
102
|
|
|
102
103
|
for (const line of buildPlanSummary(plan)) info(line);
|
|
103
104
|
info(`plan_ref: ${planHash(plan).slice(0, 12)}`);
|
|
105
|
+
|
|
106
|
+
// The fast-forward rule, warn-only at mint time (db:apply enforces it):
|
|
107
|
+
// a stale checkout mints a legal-looking plan whose edge REVERTS merged
|
|
108
|
+
// work — say so on the review surface, where a human still reads it.
|
|
109
|
+
if (plan.statements.length > 0) {
|
|
110
|
+
const verdict = await verifyDescent(modelsPath, { snapshot, contract }, {});
|
|
111
|
+
if (verdict.status === 'diverged' || verdict.status === 'drift') {
|
|
112
|
+
warn(`descent: ${verdict.reason}`);
|
|
113
|
+
warn('db:apply from this checkout will refuse this plan — rebase first, then re-mint.');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
104
116
|
if (out !== '-') {
|
|
105
117
|
success(`Wrote ${out} — review it, then \`everystack db:apply --plan ${out}\`.`);
|
|
106
118
|
warn('Plans are ephemeral release artifacts — attach to the run, do NOT commit.');
|
|
@@ -37,6 +37,7 @@ import { generateMigrationSql, unmodeledTables } from '../migration-generate.js'
|
|
|
37
37
|
import {
|
|
38
38
|
applyStateAndVerify,
|
|
39
39
|
classifyGeneratedStatements,
|
|
40
|
+
classifyDestructive,
|
|
40
41
|
currentGitRef,
|
|
41
42
|
type ClassifiedStatements,
|
|
42
43
|
type StateSyncOutcome,
|
|
@@ -212,7 +213,7 @@ export function syncFailed(run: SyncRun): boolean {
|
|
|
212
213
|
// The CLI shell.
|
|
213
214
|
// ---------------------------------------------------------------------------
|
|
214
215
|
|
|
215
|
-
async function readSqlDirIfPresent(dir: string): Promise<SourceFile[] | null> {
|
|
216
|
+
export async function readSqlDirIfPresent(dir: string): Promise<SourceFile[] | null> {
|
|
216
217
|
let entries: string[];
|
|
217
218
|
try {
|
|
218
219
|
entries = await fs.readdir(dir);
|
|
@@ -277,6 +278,10 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
|
|
|
277
278
|
onStatePlan: (statements, classified) => {
|
|
278
279
|
if (classified.executable.length > 0) {
|
|
279
280
|
info(`state plan — ${classified.executable.length} statement(s):`);
|
|
281
|
+
const { drops, narrowings } = classifyDestructive(classified.executable);
|
|
282
|
+
if (drops.length + narrowings.length > 0) {
|
|
283
|
+
warn(`${drops.length + narrowings.length} DESTRUCTIVE — ${drops.length} drop(s), ${narrowings.length} narrowing type change(s). Dev sync runs them; protected stages gate them (db:plan → db:apply).`);
|
|
284
|
+
}
|
|
280
285
|
console.log('');
|
|
281
286
|
for (const s of statements) console.log(s.endsWith(';') ? s : `${s};`);
|
|
282
287
|
console.log('');
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -29,12 +29,22 @@ import type { ModelDescriptor } from '@everystack/model';
|
|
|
29
29
|
import type { SchemaSnapshot } from './schema-introspect.js';
|
|
30
30
|
import type { AuthzContract } from './authz-contract.js';
|
|
31
31
|
import { generateMigrationSql, unmodeledTables } from './migration-generate.js';
|
|
32
|
-
import { classifyGeneratedStatements } from './state-apply.js';
|
|
32
|
+
import { classifyGeneratedStatements, classifyDestructive } from './state-apply.js';
|
|
33
33
|
import { fingerprintLive, fingerprintState, fingerprintModels, stableStringify } from './schema-fingerprint.js';
|
|
34
34
|
import { compileDeclaredState } from './declared-diff.js';
|
|
35
35
|
import { compileTableRenames } from './schema-compile.js';
|
|
36
36
|
|
|
37
|
-
export const PLAN_VERSION =
|
|
37
|
+
export const PLAN_VERSION = 2;
|
|
38
|
+
|
|
39
|
+
/** Classification counts (brick 9, decision 11): destructive = drops + narrowings. */
|
|
40
|
+
export interface PlanClassification {
|
|
41
|
+
/** Executable statements that lose no data. */
|
|
42
|
+
additive: number;
|
|
43
|
+
/** `DROP TABLE/COLUMN/TYPE` — the data is gone. */
|
|
44
|
+
drops: number;
|
|
45
|
+
/** Lossy/risky `ALTER COLUMN … TYPE` — data loss wearing an ALTER. */
|
|
46
|
+
narrowings: number;
|
|
47
|
+
}
|
|
38
48
|
|
|
39
49
|
export interface EdgePlan {
|
|
40
50
|
v: number;
|
|
@@ -47,8 +57,9 @@ export interface EdgePlan {
|
|
|
47
57
|
/** The edge, in db:generate's statement grammar (no held drops — mint refuses them). */
|
|
48
58
|
statements: string[];
|
|
49
59
|
executable: number;
|
|
50
|
-
/** Executable statements that
|
|
60
|
+
/** Executable statements that LOSE DATA (drops + narrowing type changes) — red, gated at apply. */
|
|
51
61
|
destructive: number;
|
|
62
|
+
classification: PlanClassification;
|
|
52
63
|
notices: number;
|
|
53
64
|
/** Live tables no model declares — untouched by the edge, riding into `to` as-is. */
|
|
54
65
|
unmodeled: string[];
|
|
@@ -64,35 +75,24 @@ export interface MintOptions {
|
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
78
|
+
* The predicted live fingerprint of a target AFTER the models' edge lands:
|
|
79
|
+
* declared tables on their compiled shapes exactly (that is what
|
|
80
|
+
* apply-and-verify proves), unmodeled tables and enums riding through
|
|
81
|
+
* untouched. A pending table rename's SOURCE does not ride through — the
|
|
82
|
+
* edge renames it away, and it lands as its declared new name.
|
|
83
|
+
* fingerprintState sorts internally, so merge order is irrelevant.
|
|
84
|
+
*
|
|
85
|
+
* Two callers, one meaning: minting uses it as a plan's `to` endpoint, and
|
|
86
|
+
* git-descent uses it as the declares-test — a commit DECLARES a target's
|
|
87
|
+
* live state exactly when this prediction equals the live fingerprint
|
|
88
|
+
* (the edge from the target to that commit's models is empty).
|
|
70
89
|
*/
|
|
71
|
-
export function
|
|
90
|
+
export function predictLiveFingerprint(
|
|
72
91
|
models: ModelDescriptor[],
|
|
73
92
|
snapshot: SchemaSnapshot,
|
|
74
93
|
contract: AuthzContract,
|
|
75
|
-
opts:
|
|
76
|
-
):
|
|
77
|
-
const statements = generateMigrationSql(models, snapshot, {
|
|
78
|
-
schema: opts.schema,
|
|
79
|
-
allowDrops: opts.allowDrops,
|
|
80
|
-
liveAuthz: contract,
|
|
81
|
-
});
|
|
82
|
-
const classified = classifyGeneratedStatements(statements);
|
|
83
|
-
if (classified.heldDrops.length > 0) {
|
|
84
|
-
throw new Error(
|
|
85
|
-
`the edge holds ${classified.heldDrops.length} destructive change(s) back — a plan must be complete. ` +
|
|
86
|
-
'Re-mint with --allow-drops to carry the destruction explicitly, or mark the contraction in the models first and drop later.',
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// The predicted endpoint: declared tables land exactly on their compiled
|
|
91
|
-
// shapes (that is what apply-and-verify proves), unmodeled tables and
|
|
92
|
-
// enums ride through untouched. A pending table rename's SOURCE does not
|
|
93
|
-
// ride through — the edge renames it away, and it lands as its declared
|
|
94
|
-
// new name. fingerprintState sorts internally, so merge order is
|
|
95
|
-
// irrelevant.
|
|
94
|
+
opts: { schema?: string } = {},
|
|
95
|
+
): string {
|
|
96
96
|
const declared = compileDeclaredState(models, { schema: opts.schema });
|
|
97
97
|
const declaredTables = new Set(declared.snapshot.tables.map((t) => t.table));
|
|
98
98
|
const currentNames = new Set(snapshot.tables.map((t) => t.table));
|
|
@@ -119,18 +119,53 @@ export function mintEdgePlan(
|
|
|
119
119
|
...declared.contract.tables,
|
|
120
120
|
...contract.tables.filter((t) => ridesThrough(t.table)),
|
|
121
121
|
];
|
|
122
|
+
return fingerprintState(mergedSnapshot, mergedAuthz).hash;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Mint the edge from a target's introspected state to the models' declared
|
|
127
|
+
* state. Pure: introspection in, plan out. Throws when the edge would hold
|
|
128
|
+
* drops back — a plan must be complete to have a reachable endpoint.
|
|
129
|
+
*/
|
|
130
|
+
export function mintEdgePlan(
|
|
131
|
+
models: ModelDescriptor[],
|
|
132
|
+
snapshot: SchemaSnapshot,
|
|
133
|
+
contract: AuthzContract,
|
|
134
|
+
opts: MintOptions = {},
|
|
135
|
+
): EdgePlan {
|
|
136
|
+
const statements = generateMigrationSql(models, snapshot, {
|
|
137
|
+
schema: opts.schema,
|
|
138
|
+
allowDrops: opts.allowDrops,
|
|
139
|
+
liveAuthz: contract,
|
|
140
|
+
});
|
|
141
|
+
const classified = classifyGeneratedStatements(statements);
|
|
142
|
+
if (classified.heldDrops.length > 0) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`the edge holds ${classified.heldDrops.length} destructive change(s) back — a plan must be complete. ` +
|
|
145
|
+
'Re-mint with --allow-drops to carry the destruction explicitly, or mark the contraction in the models first and drop later.',
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Data-destructive only: dropping a policy/grant is recoverable metadata
|
|
150
|
+
// (the authz phase is data-safe by construction); dropping a table,
|
|
151
|
+
// column, or type is not — and neither is a narrowing type change, which
|
|
152
|
+
// is data loss wearing an ALTER.
|
|
153
|
+
const breakdown = classifyDestructive(classified.executable);
|
|
154
|
+
const destructive = breakdown.drops.length + breakdown.narrowings.length;
|
|
122
155
|
|
|
123
156
|
return {
|
|
124
157
|
v: PLAN_VERSION,
|
|
125
158
|
fromFingerprint: fingerprintLive(snapshot, contract).hash,
|
|
126
|
-
toFingerprint:
|
|
159
|
+
toFingerprint: predictLiveFingerprint(models, snapshot, contract, { schema: opts.schema }),
|
|
127
160
|
declaredFingerprint: fingerprintModels(models, { schema: opts.schema }).hash,
|
|
128
161
|
statements,
|
|
129
162
|
executable: classified.executable.length,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
163
|
+
destructive,
|
|
164
|
+
classification: {
|
|
165
|
+
additive: classified.executable.length - destructive,
|
|
166
|
+
drops: breakdown.drops.length,
|
|
167
|
+
narrowings: breakdown.narrowings.length,
|
|
168
|
+
},
|
|
134
169
|
notices: classified.notices.length,
|
|
135
170
|
unmodeled: unmodeledTables(models, snapshot),
|
|
136
171
|
gitRef: opts.gitRef ?? null,
|
|
@@ -168,7 +203,14 @@ export function buildPlanSummary(plan: EdgePlan): string[] {
|
|
|
168
203
|
}
|
|
169
204
|
lines.push(`${plan.executable} executable statement(s), ${plan.notices} notice(s)`);
|
|
170
205
|
if (plan.destructive > 0) {
|
|
171
|
-
|
|
206
|
+
const { drops, narrowings } = classifyDestructive(classifyGeneratedStatements(plan.statements).executable);
|
|
207
|
+
lines.push(
|
|
208
|
+
`! ${plan.destructive} DESTRUCTIVE statement(s) — ${drops.length} drop(s), ${narrowings.length} narrowing type change(s). Data loss carried explicitly; the apply is gated (--confirm + snapshot, approver set when declared):`,
|
|
209
|
+
);
|
|
210
|
+
for (const statement of [...drops, ...narrowings]) {
|
|
211
|
+
const ddl = statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? statement;
|
|
212
|
+
lines.push(`! ${ddl.trim()}`);
|
|
213
|
+
}
|
|
172
214
|
}
|
|
173
215
|
if (plan.unmodeled.length > 0) {
|
|
174
216
|
lines.push(`· ${plan.unmodeled.length} unmodeled table(s) ride through untouched: ${plan.unmodeled.join(', ')}`);
|