@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.
@@ -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('');
@@ -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 = 1;
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 DROP somethingshown red, explicit by construction. */
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
- * Mint the edge from a target's introspected state to the models' declared
68
- * state. Pure: introspection in, plan out. Throws when the edge would hold
69
- * drops back a plan must be complete to have a reachable endpoint.
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 mintEdgePlan(
90
+ export function predictLiveFingerprint(
72
91
  models: ModelDescriptor[],
73
92
  snapshot: SchemaSnapshot,
74
93
  contract: AuthzContract,
75
- opts: MintOptions = {},
76
- ): EdgePlan {
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: fingerprintState(mergedSnapshot, mergedAuthz).hash,
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
- // Data-destructive only: dropping a policy/grant is recoverable metadata
131
- // (the authz phase is data-safe by construction); dropping a table,
132
- // column, or type is not.
133
- destructive: classified.executable.filter((s) => /\bDROP\s+(TABLE|COLUMN|TYPE)\b/.test(s)).length,
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
- lines.push(`! ${plan.destructive} DESTRUCTIVE statement(s) — drops carried explicitly, review them`);
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(', ')}`);