@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.
- package/CHANGELOG.md +41 -0
- package/package.json +9 -1
- package/src/cli/authz-compile.ts +126 -6
- package/src/cli/authz-contract.ts +5 -3
- package/src/cli/authz-derive.ts +31 -6
- package/src/cli/authz-reconcile.ts +18 -15
- package/src/cli/aws.ts +17 -1
- package/src/cli/commands/db-apply.ts +445 -60
- package/src/cli/commands/db-check.ts +39 -20
- package/src/cli/commands/db-plan.ts +61 -27
- package/src/cli/commands/db-pull.ts +67 -11
- package/src/cli/commands/db-reconcile.ts +22 -1
- package/src/cli/commands/db.ts +137 -19
- package/src/cli/db-source.ts +8 -1
- package/src/cli/derived-catchup.ts +154 -0
- package/src/cli/derived-render.ts +531 -245
- package/src/cli/derived-source.ts +25 -90
- package/src/cli/edge-plan.ts +76 -14
- package/src/cli/index.ts +7 -7
- package/src/cli/model-wire-lane.ts +143 -0
- package/src/cli/output.ts +12 -0
- package/src/cli/plain-output.ts +521 -0
- package/src/cli/schema-diff.ts +6 -0
- package/src/cli/schema-fingerprint-v11.ts +234 -0
- package/src/cli/schema-fingerprint.ts +20 -6
- package/src/cli/sql-statement.ts +98 -0
- package/src/cli/state-apply.ts +46 -24
- package/src/wire.ts +30 -0
|
@@ -49,7 +49,8 @@ import {
|
|
|
49
49
|
findDerivedReadGaps, findSecdefExecuteGaps, findMatviewSnapshotWarnings, findPublicExecutableSecdef,
|
|
50
50
|
findUnpinnedDefiners,
|
|
51
51
|
} from '../derived-lint';
|
|
52
|
-
import {
|
|
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
|
-
|
|
423
|
-
|
|
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 (
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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.
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
379
|
-
// layer — it lives here, and the module wires both:
|
|
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
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))) {
|