@everystack/cli 0.4.81 → 0.4.83

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.
@@ -49,7 +49,8 @@ import {
49
49
  findDerivedReadGaps, findSecdefExecuteGaps, findMatviewSnapshotWarnings, findPublicExecutableSecdef,
50
50
  findUnpinnedDefiners,
51
51
  } from '../derived-lint';
52
- import { step, success, fail, info, warn } from '../output';
52
+ import { renderCheckVerdict } from '../plain-output';
53
+ import { step, success, fail, info, warn, line as printLine } from '../output';
53
54
 
54
55
  // The role derivation moved to the shared builder (db-build) with brick
55
56
  // 5-remainder; re-exported here because db:check is where it grew up.
@@ -347,6 +348,10 @@ export async function executeEphemeralCompose(
347
348
  const MARK: Record<CheckFinding['level'], string> = { ok: '=', note: '·', warn: '~', fail: '!' };
348
349
 
349
350
  export async function dbCheckCommand(flags: Record<string, string>): Promise<void> {
351
+ // Default output is ONE line: `Up to date.` or `N changes pending. Run db:plan to review.`
352
+ // --verbose is every finding, the ephemeral compose report and the explanatory notes,
353
+ // exactly as they were. --json is unchanged either way: it is the machine channel.
354
+ const verbose = flags.verbose === 'true';
350
355
  const modelsPath = resolveModelsPath(flags.models);
351
356
  let models: ModelDescriptor[] | null = null;
352
357
  let modelsError: string | undefined;
@@ -378,7 +383,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
378
383
  const journal = await readJournal(path.resolve(flags.dir || DEFAULT_MIGRATIONS));
379
384
  const artifactRes = resolveSchemaOut(flags['schema-out'], journal, DEFAULT_SCHEMA_OUT);
380
385
  const artifactPath = artifactRes.path;
381
- if (artifactRes.source === 'recorded') {
386
+ if (artifactRes.source === 'recorded' && verbose) {
382
387
  info(`schema-out: ${artifactPath} (recorded in the journal; override with --schema-out)`);
383
388
  }
384
389
  let artifactSource: string | null = null;
@@ -419,8 +424,12 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
419
424
  moduleModels: declaredDb?.models ?? null,
420
425
  baselineSource,
421
426
  });
422
- for (const f of findings) {
423
- (f.level === 'fail' || f.level === 'warn' ? warn : info)(`${MARK[f.level]} ${f.area}: ${f.message}`);
427
+ // Per-finding detail is the --verbose report. The quiet default answers the one question a
428
+ // human asked — is anything pending? — and names the verb that shows it.
429
+ if (verbose) {
430
+ for (const f of findings) {
431
+ (f.level === 'fail' || f.level === 'warn' ? warn : info)(`${MARK[f.level]} ${f.area}: ${f.message}`);
432
+ }
424
433
  }
425
434
 
426
435
  let ephemeral: EphemeralComposeResult | null = null;
@@ -428,9 +437,9 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
428
437
  const url = flags['database-url'] || process.env.DATABASE_URL;
429
438
  const staticFailed = checkFailed(findings);
430
439
  if (staticFailed) {
431
- warn('ephemeral compose skipped — fix the static failures first.');
440
+ if (verbose) warn('ephemeral compose skipped — fix the static failures first.');
432
441
  } else if (!url) {
433
- 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).');
442
+ if (verbose) 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).');
434
443
  } else {
435
444
  step('Composing the declared state from scratch on an ephemeral database...');
436
445
  try {
@@ -441,10 +450,10 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
441
450
  sequences: declaredDb?.sequences,
442
451
  extensions,
443
452
  });
444
- for (const line of ephemeral.report) info(` ${line}`);
453
+ if (verbose) for (const line of ephemeral.report) info(` ${line}`);
445
454
  } catch (err: any) {
446
455
  ephemeralError = err.message;
447
- warn(`! compose: the declared state does NOT build from scratch: ${ephemeralError}`);
456
+ if (verbose) warn(`! compose: the declared state does NOT build from scratch: ${ephemeralError}`);
448
457
  }
449
458
  }
450
459
 
@@ -465,19 +474,29 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
465
474
  ephemeralRan: ephemeral !== null,
466
475
  failed,
467
476
  }, null, 2));
468
- } else if (failed) {
469
- fail('db:check FAILED — the merged declared state is not shippable as-is (findings above).');
470
- } else if (ephemeral !== null) {
471
- success(`db:check passed — the declared state composes from scratch and lands on its own fingerprint (${ephemeral.fingerprint.slice(0, 12)}).`);
472
- // Named because it kept being read as the stronger claim (a consumer, 2026-08-07): this
473
- // ring proves the checkout against ITSELF, on an empty database. Whether an existing
474
- // database IS this state is a different question with a different verb.
475
- info('This proves the checkout is self-consistent and buildable — not that any existing database matches it. For that claim, run db:fingerprint against the database.');
476
- // The ephemeral database is DROPPED. Saying so here is what stops this line being read
477
- // as "you now have a database" — a consumer read exactly that into it, from our own docs.
478
- info('The database this composed on was ephemeral and has been dropped. To build one you KEEP: everystack db:build --database-url <url>.');
477
+ } else if (verbose) {
478
+ if (failed) {
479
+ fail('db:check FAILED — the merged declared state is not shippable as-is (findings above).');
480
+ } else if (ephemeral !== null) {
481
+ success(`db:check passed — the declared state composes from scratch and lands on its own fingerprint (${ephemeral.fingerprint.slice(0, 12)}).`);
482
+ // Named because it kept being read as the stronger claim (a consumer, 2026-08-07): this
483
+ // ring proves the checkout against ITSELF, on an empty database. Whether an existing
484
+ // database IS this state is a different question with a different verb.
485
+ info('This proves the checkout is self-consistent and buildable — not that any existing database matches it. For that claim, run db:fingerprint against the database.');
486
+ // The ephemeral database is DROPPED. Saying so here is what stops this line being read
487
+ // as "you now have a database" — a consumer read exactly that into it, from our own docs.
488
+ info('The database this composed on was ephemeral and has been dropped. To build one you KEEP: everystack db:build --database-url <url>.');
489
+ } else {
490
+ success('db:check passed (static ring only — no database provided for the ephemeral compose).');
491
+ }
479
492
  } else {
480
- success('db:check passed (static ring only — no database provided for the ephemeral compose).');
493
+ // THE QUIET DEFAULT — one line, always. What is "pending" is what this gate found wrong:
494
+ // every failing finding, plus the from-scratch build when it did not land where the models
495
+ // say it should. `db:plan` is the verb that shows each one against a real target, so it is
496
+ // the only command the line names. Re-run with --verbose to read the findings themselves.
497
+ for (const text of renderCheckVerdict(
498
+ findings.filter((f) => f.level === 'fail').length + (ephemeralFailed ? 1 : 0),
499
+ )) printLine(text);
481
500
  }
482
501
 
483
502
  if (failed) process.exit(1);
@@ -49,14 +49,14 @@ import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport } fr
49
49
  import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
50
50
  import { readStageStateTwice } from '../stage-read-consistency';
51
51
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
52
- import { mintEdgePlan, planHash, buildPlanSummary, explainCarriedBy } from '../edge-plan';
52
+ import { mintEdgePlan, planHash, buildPlanSummary, explainCarriedBy, declaredGranteesOf } from '../edge-plan';
53
53
  import { proveDescent, describeDriftOrigin, proveLineageLost, realLineageProbe, type ProveVerdict, type LineageProof } from '../git-descent';
54
54
  import { fingerprintLive, divergentSurfaces } from '../schema-fingerprint';
55
55
  import { predictLiveState } from '../edge-plan';
56
56
  import { readDescentLedger } from '../apply-execute';
57
57
  import { planBackfills, readBackfillLog } from '../backfill';
58
58
  import { readSqlDirIfPresent } from './db-sync';
59
- import { currentGitRef } from '../state-apply';
59
+ import { currentGitRef, classifyGeneratedStatements, classifyDestructive } from '../state-apply';
60
60
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source';
61
61
  import { borrowedSessionRunner, type SessionRunner } from '../session';
62
62
  import { resolveModelsPath } from '../models-path';
@@ -70,7 +70,8 @@ import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../auth
70
70
  import { alterTypeStatementTargets, findAlterTypeDependents, renderAlterTypeRefusal } from '../alter-type-dependents';
71
71
  import { compileTableContract } from '../authz-compile';
72
72
  import { reportPipelineLastRun } from './pipeline-run';
73
- import { step, success, fail, info, warn, reserveStdoutForData } from '../output';
73
+ import { renderPlanReport } from '../plain-output';
74
+ import { step, success, fail, info, warn, line as printLine, reserveStdoutForData } from '../output';
74
75
 
75
76
  /**
76
77
  * What db:plan says about descent at mint — SYMMETRIC with what db:apply will do, because a mint
@@ -181,6 +182,10 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
181
182
  // the adoption report and the ownership report land on stderr and the plan JSON is the
182
183
  // only thing on stdout — otherwise `db:plan --out - | jq` reads a report as its input.
183
184
  if (out === '-') reserveStdoutForData();
185
+ // --verbose is where the FULL internal report lives, unchanged: the edge summary, the kind
186
+ // histogram, the adoption and ownership reports, the plan ref. Default output is the plain
187
+ // change list (plain-output.ts) and nothing else — see that module for why.
188
+ const verbose = flags.verbose === 'true';
184
189
 
185
190
  let dbSource: DbSource;
186
191
  try {
@@ -345,36 +350,43 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
345
350
  // divergence. The foreign grants are ABSORBED into `to`, so they are NOT the cause; naming
346
351
  // them would misdirect. Name the divergent surface of the FULL prediction vs live instead.
347
352
  const emptyEdge = plan.statements.length === 0;
348
- const carriedBy = !emptyEdge
349
- ? undefined
350
- : plan.fromFingerprint === plan.toFingerprint
351
- ? explainCarriedBy(exemptions, [])
352
- : divergentSurfaces(
353
- predictLiveState(models, snapshot, contract, { governedRoles: governedRoleSet(declaredAuthz, declaredGovernedRoles), carryUngoverned: true }).canonical,
354
- fingerprintLive(snapshot, contract).canonical,
355
- );
356
- for (const line of buildPlanSummary(plan, carriedBy ? { carriedBy } : {})) info(line);
357
- info(`plan_ref: ${planHash(plan).slice(0, 12)}`);
353
+ if (verbose) {
354
+ const carriedBy = !emptyEdge
355
+ ? undefined
356
+ : plan.fromFingerprint === plan.toFingerprint
357
+ ? explainCarriedBy(exemptions, [])
358
+ : divergentSurfaces(
359
+ predictLiveState(models, snapshot, contract, { governedRoles: governedRoleSet(declaredAuthz, declaredGovernedRoles), carryUngoverned: true }).canonical,
360
+ fingerprintLive(snapshot, contract).canonical,
361
+ );
362
+ for (const line of buildPlanSummary(plan, carriedBy ? { carriedBy } : {})) info(line);
363
+ info(`plan_ref: ${planHash(plan).slice(0, 12)}`);
364
+
365
+ // WHY does each authorization statement exist? — the same report db:generate prints,
366
+ // on the artifact a human actually reviews before applying.
367
+ //
368
+ // Re-DERIVED from live every mint, never stored: a recorded claim about what the models
369
+ // fail to capture goes stale the moment someone closes the gap, and a stale note is worse
370
+ // than none. `capability` is the count that says "this plan removes something the model
371
+ // cannot express" — the operator reading this is the last one who can catch it.
372
+ const adoptionTotal = Object.values(adoption.counts).reduce((a, b) => a + b, 0);
373
+ if (adoptionTotal > 0) {
374
+ info(`${adoptionTotal} authorization statement(s), by why they exist:`);
375
+ for (const line of renderAdoptionReport(adoption.counts, adoption.statements)) info(line);
376
+ }
358
377
 
359
- // WHY does each authorization statement exist? — the same report db:generate prints,
360
- // on the artifact a human actually reviews before applying.
361
- //
362
- // Re-DERIVED from live every mint, never stored: a recorded claim about what the models
363
- // fail to capture goes stale the moment someone closes the gap, and a stale note is worse
364
- // than none. `capability` is the count that says "this plan removes something the model
365
- // cannot express" — the operator reading this is the last one who can catch it.
366
- const adoptionTotal = Object.values(adoption.counts).reduce((a, b) => a + b, 0);
367
- if (adoptionTotal > 0) {
368
- info(`${adoptionTotal} authorization statement(s), by why they exist:`);
369
- for (const line of renderAdoptionReport(adoption.counts, adoption.statements)) info(line);
370
378
  }
371
379
 
372
380
  // WHO owns the tables this plan authorizes, and does that owner obey the policies it
373
381
  // is about to write? Re-derived live every mint, never stored — the owner is a fact
374
382
  // about THIS target, and the target is the one thing a committed artifact cannot carry.
383
+ //
384
+ // Its `!` lines are PROBLEMS (an owner that bypasses the policies this plan writes), so they
385
+ // survive the quiet default — "show only problems" is the rule, not "show only the list".
386
+ // The rest of the report is description and moves behind --verbose.
375
387
  for (const line of renderOwnershipReport(buildOwnershipReport(owners, contract, { models }))) {
376
388
  if (line.trimStart().startsWith('!')) warn(line.trim());
377
- else info(line);
389
+ else if (verbose) info(line);
378
390
  }
379
391
 
380
392
  // The fast-forward rule, warn-only at mint time (db:apply enforces it):
@@ -416,12 +428,34 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
416
428
  }
417
429
 
418
430
  await reportPipelineLastRun(runner);
431
+
432
+ // THE DEFAULT REPORT — the only thing a reader sees when nothing is wrong.
433
+ //
434
+ // `executable` is what the apply will really run (notices and held drops are comments by
435
+ // construction). The destructive subset is recomputed with the SAME classifier and the SAME
436
+ // declared-grantee set the mint used for `plan.destructive`, so the ⚠ lines and the --confirm
437
+ // gate cannot disagree about what destroys. Nothing here decides anything; it only prints.
438
+ const executable = classifyGeneratedStatements(plan.statements).executable;
439
+ const { drops, narrowings, strips } = classifyDestructive(executable, {
440
+ declaredGrantees: declaredGranteesOf(models),
441
+ });
442
+ for (const text of renderPlanReport({
443
+ executable,
444
+ destructive: [...drops, ...narrowings, ...strips],
445
+ target: stageName,
446
+ stage: flags.stage ?? null,
447
+ planPath: out === '-' ? null : out,
448
+ })) printLine(text);
449
+
419
450
  if (out !== '-') {
420
- success(`Wrote ${out} — review it, then \`everystack db:apply --plan ${out}\`.`);
421
- warn('Plans are ephemeral release artifacts — attach to the run, do NOT commit.');
451
+ if (verbose) {
452
+ success(`Wrote ${out} — review it, then \`everystack db:apply --plan ${out}\`.`);
453
+ warn('Plans are ephemeral release artifacts — attach to the run, do NOT commit.');
454
+ }
422
455
  // "Do NOT commit" is advice; git is what enforces it. The default lands in the app
423
456
  // root, so an adopter following the happy path gets an untracked, unignored artifact
424
457
  // sitting next to their source with only a log line between it and a `git add .`.
458
+ // A problem, so it stays in the quiet default.
425
459
  if (!isGitIgnored(out)) {
426
460
  warn(`${out} is NOT gitignored — add it, or the next \`git add .\` commits the plan: echo '${out}' >> .gitignore`);
427
461
  }
@@ -2,7 +2,7 @@
2
2
  * `everystack db:pull` — generate `field()` Models from a live database (the brownfield on-ramp).
3
3
  *
4
4
  * db:pull [--stage <name>] [--database-url <url>] [--schema public] [--out <dir | file.ts>]
5
- * [--derived-out <file.ts>] [--abilities live|public-read]
5
+ * [--derived-out <file.ts | dir>] [--abilities live|public-read]
6
6
  *
7
7
  * `--stage` and `--database-url` COMPOSE: the URL picks the connection, the stage names the
8
8
  * baseline entry (`db/authz-baseline.json` is per-stage — a local adoption pulls with
@@ -29,6 +29,16 @@
29
29
  * the contract kebab files, `export const derived/sequences` arrays — instead of riding
30
30
  * the models render. Alone, it leaves the models untouched (the hand-maintained-barrel
31
31
  * case); with `--out`, the models render omits the now-external derived layer.
32
+ *
33
+ * `--derived-out <dir>` (anything not ending in `.ts`) splits that same layer by kind and
34
+ * schema instead of one module: `views.ts`, one `functions-<schema>.ts` per schema, `triggers.ts`,
35
+ * and an `index.ts` barrel that imports every split file plus the model files and re-exports
36
+ * exactly what the single-file form exports. Model files are assumed to sit in the PARENT of
37
+ * the derived directory — the same co-location the single-file form already relies on
38
+ * (`db/models/derived.ts` beside `db/models/*.ts` becomes `db/models/derived/` inside it).
39
+ * The import graph between split files is the RENDERED-ref graph, not the catalog graph — a
40
+ * cycle there is fatal at import time (TDZ), so it is detected and refused before anything is
41
+ * written, never shipped as a barrel nobody can import.
32
42
  */
33
43
 
34
44
  import fs from 'node:fs/promises';
@@ -71,7 +81,7 @@ export function pullOnRampRunbook(opts: { stage?: string; out?: string; abilitie
71
81
  import { introspectContract, type TableContract, type AuthzContract } from '../authz-contract';
72
82
  import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, type TableOwner } from '../authz-ownership';
73
83
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
74
- import { renderDerivedSource, renderDerivedFile, type DerivedRenderResult } from '../derived-render';
84
+ import { renderDerivedSource, renderDerivedFile, renderDerivedDirectory, type DerivedRenderResult } from '../derived-render';
75
85
  import { renderModelSource, renderModelFiles, pullableTables, skippedInfrastructureTables, modelVarName, ABILITY_PRESETS } from '../model-render';
76
86
  import { deriveAbilities } from '../authz-derive';
77
87
  import type { QueryRunner } from '../authz-contract';
@@ -148,12 +158,32 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
148
158
 
149
159
  // --derived-out: the brownfield splice, first-class. A consumer with an existing
150
160
  // hand-maintained barrel wants the derived layer as its own file — not codemodded
151
- // out of a rendered monolith. Validated before any introspection.
161
+ // out of a rendered monolith. `--derived-out <file.ts>` is the single module (unchanged);
162
+ // anything not ending in `.ts` is a DIRECTORY — split by kind and schema (views.ts,
163
+ // functions-<schema>.ts, triggers.ts, index.ts barrel) for a real schema's derived layer,
164
+ // which otherwise renders as one unreviewable multi-thousand-line file.
165
+ //
166
+ // TWO shapes, and only two: a `.ts` file, or a directory (no extension, trailing slash
167
+ // optional). A name carrying any OTHER extension is a typo, never a directory anyone
168
+ // meant — `--derived-out db/models/derived.tsx` used to fail loudly under the old
169
+ // `.ts`-only check and would otherwise now create a directory literally called
170
+ // `derived.tsx/`, silently, with the whole derived layer inside it.
152
171
  const derivedOut = flags['derived-out'];
153
- if (derivedOut && !derivedOut.endsWith('.ts')) {
154
- fail(`--derived-out must name a .ts file (got '${derivedOut}') — e.g. --derived-out db/models/derived.ts.`);
155
- process.exit(1);
172
+ if (derivedOut) {
173
+ // A trailing separator is the operator SAYING directory — it settles the shape on its
174
+ // own, so the extension question never arises.
175
+ const saysDirectory = /[\\/]$/.test(derivedOut);
176
+ const base = path.basename(derivedOut);
177
+ if (!saysDirectory && /\.[a-zA-Z0-9]+$/.test(base) && !base.endsWith('.ts')) {
178
+ fail(
179
+ `--derived-out ${derivedOut}: '${base}' carries a file extension that is not .ts. `
180
+ + `Name a .ts file for the single-module form (--derived-out db/models/derived.ts), or a directory `
181
+ + `for the split form (--derived-out db/models/derived) — nothing else is a shape this writes.`,
182
+ );
183
+ process.exit(1);
184
+ }
156
185
  }
186
+ const derivedOutIsDir = Boolean(derivedOut) && !derivedOut.endsWith('.ts');
157
187
 
158
188
  // The read-model scaffold: default 'commented' surfaces the authz decision in every
159
189
  // model; a preset stamps it. Validated up front so a typo fails before introspection.
@@ -375,19 +405,45 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
375
405
  caution('Matviews carry no PK/NOT NULL — the rendered fields are nullable and unkeyed; tighten (.primaryKey()/.notNull()) as you review. Unique matview indexes render as index().unique() constraints.');
376
406
  }
377
407
 
378
- // The standalone derived file. The models output (below) then omits the derived
379
- // layer — it lives here, and the module wires both: defineModule({ models, sequences, derived }).
408
+ // The standalone derived file (or directory). The models output (below) then omits the
409
+ // derived layer — it lives here, and the module wires both:
410
+ // defineModule({ models, sequences, derived }).
380
411
  if (derivedOut) {
381
412
  const outPath = path.resolve(derivedOut);
413
+ let fileCount = 1;
382
414
  try {
383
- await fs.mkdir(path.dirname(outPath), { recursive: true });
384
- await fs.writeFile(outPath, renderDerivedFile(derived, knownTables), 'utf8');
415
+ if (derivedOutIsDir) {
416
+ // A cycle between split files is fatal at import time (TDZ) — refused here, before
417
+ // anything is written, rather than shipping a barrel nobody can import.
418
+ const files = renderDerivedDirectory(derived, knownTables, outPath);
419
+ await fs.mkdir(outPath, { recursive: true });
420
+ // The split set SHRINKS: drop the last function in `auth` and this pull no longer
421
+ // writes functions-auth.ts, but the previous pull's copy is still on disk declaring
422
+ // objects the database no longer has — and the regenerated index.ts no longer imports
423
+ // it, so nothing else will ever mention it again. Named, never deleted: same posture
424
+ // as the --out <dir> model writer below, which leaves stray files alone and says so.
425
+ const written = new Set(files.map((f) => f.file));
426
+ const stale = (await fs.readdir(outPath)).filter((f) => f.endsWith('.ts') && !written.has(f));
427
+ for (const f of files) await fs.writeFile(path.join(outPath, f.file), f.source, 'utf8');
428
+ fileCount = files.length;
429
+ if (stale.length) {
430
+ caution(
431
+ `${stale.length} existing .ts file(s) in ${path.relative(process.cwd(), outPath)} were not part of this pull `
432
+ + `and were left untouched: ${stale.join(', ')}. The new index.ts does not import them — delete them, or they `
433
+ + `stay as a declaration of objects this pull did not find.`,
434
+ );
435
+ }
436
+ } else {
437
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
438
+ await fs.writeFile(outPath, renderDerivedFile(derived, knownTables), 'utf8');
439
+ }
385
440
  } catch (err: any) {
386
441
  fail(`Could not write ${derivedOut}: ${err.message}`);
387
442
  process.exit(1);
388
443
  }
389
444
  const mtPart = derived.materializedTableNames.length ? ` + ${derived.materializedTableNames.length} materialized table(s)` : '';
390
- ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${derived.names.length} descriptor(s) + ${derived.sequenceNames.length} sequence(s)${mtPart}, self-contained.`);
445
+ const shape = derivedOutIsDir ? `${fileCount} file(s)` : 'self-contained';
446
+ ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${derived.names.length} descriptor(s) + ${derived.sequenceNames.length} sequence(s)${mtPart}, ${shape}.`);
391
447
  note(`Wire it on your module — defineModule({ models${derived.materializedTableNames.length ? ': [...models, ...materializedTables]' : ''}${derived.sequenceNames.length ? ', sequences' : ''}, derived }) — then adopt with db:reconcile --apply --baseline (--rebaseline over db/sql-era provenance).`);
392
448
  if (!flags.out) {
393
449
  // The brownfield case: the barrel is hand-maintained; leave the models alone.
@@ -58,6 +58,8 @@ import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, t
58
58
  import { currentGitRef } from '../state-apply';
59
59
  import { resolveConfig, opsFunction } from '../config';
60
60
  import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws';
61
+ import { modelContractPreflight } from '../model-wire-lane';
62
+ import { MODEL_WIRE_CONTRACT, type ReconcileWirePayload } from '../../wire';
61
63
  import { step, success, fail, info, warn } from '../output';
62
64
 
63
65
  // ---------------------------------------------------------------------------
@@ -661,7 +663,26 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
661
663
  if (apply && dbSource.kind !== 'url') {
662
664
  step('Resolving deployed config...');
663
665
  const config = await resolveConfig(flags.stage);
664
- const result: any = await invokeAction(config.region, opsFunction(config), 'db:reconcile', reconcileOptions);
666
+
667
+ // The CLI half of the Model-derived wire handshake — same runner, same wording as
668
+ // db:provision's. `declared` is the derived layer this checkout compiled, and the ops Lambda
669
+ // CREATES AND REPLACES database objects from it, so verify the stage understands the
670
+ // descriptor shape BEFORE sending it.
671
+ await modelContractPreflight({
672
+ region: config.region,
673
+ fn: opsFunction(config),
674
+ directCommand: `everystack db:reconcile --apply --stage ${flags.stage ?? '<stage>'} --direct`,
675
+ // Nothing has been written at this point, so the refusal is the whole story: it names
676
+ // the skew, says nothing was sent, and offers --direct. Print it and exit.
677
+ onRefusal: 'exit',
678
+ })('db:reconcile');
679
+
680
+ const result: any = await invokeAction(
681
+ config.region,
682
+ opsFunction(config),
683
+ 'db:reconcile',
684
+ { ...reconcileOptions, modelContract: MODEL_WIRE_CONTRACT } satisfies ReconcileWirePayload,
685
+ );
665
686
  if (result?.error) {
666
687
  fail(`db:reconcile failed: ${result.error}`);
667
688
  if (/Unknown action/i.test(String(result.error))) {