@everystack/cli 0.3.19 → 0.3.22

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 CHANGED
@@ -220,6 +220,8 @@ everystack db:reconcile # deploy the derived layer (functions/vi
220
220
  everystack db:fingerprint # content-address the live base schema vs the Models — MATCH/MISMATCH
221
221
  everystack db:sync # make the database match your checkout — state + compute, one verb (dev DBs)
222
222
  everystack db:diff # the state edge between two declared states — no DB, CI-pure; reverse = computed rollback
223
+ everystack db:plan | db:apply # mint a reviewable plan against a target, apply it fingerprint-verified at both ends + the fast-forward rule (checkout must descend from the commit declaring the target's state)
224
+ everystack db:check # the CI gate per PR: merged declared state composes + artifacts match regeneration (+ ephemeral from-scratch build w/ a scratch PG)
223
225
 
224
226
  # Security
225
227
  everystack db:doctor # is the DB least-privilege + RLS-subject?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.3.19",
3
+ "version": "0.3.22",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -78,7 +78,7 @@
78
78
  "structured-headers": "1.0.1",
79
79
  "tsx": "4.21.0",
80
80
  "typescript": "5.9.3",
81
- "@everystack/model": "0.3.4"
81
+ "@everystack/model": "0.3.5"
82
82
  },
83
83
  "peerDependencies": {
84
84
  "@everystack/server": ">=0.1.0",
@@ -0,0 +1,233 @@
1
+ /**
2
+ * `everystack db:apply` — run a reviewed plan: verify → apply → verify
3
+ * (brick 4's runner).
4
+ *
5
+ * db:apply --plan <file.plan.json> [--database-url <url>]
6
+ *
7
+ * The three moves, in order, none skippable:
8
+ * 1. VERIFY the target is exactly where the plan started — live
9
+ * fingerprint == plan.from, or REFUSE. This is the concurrency lock:
10
+ * two applies racing the same stage serialize on it (the second's
11
+ * parent no longer matches; it re-mints against the new reality). A
12
+ * hand-edited target refuses the same way. A target already at
13
+ * plan.to is done — re-apply is idempotent, not an error.
14
+ * 2. APPLY the edge as one transaction, recorded in schema_log with the
15
+ * plan's content address (`plan_ref`) and both fingerprints.
16
+ * 3. VERIFY the target landed exactly on plan.to. The plan is
17
+ * self-contained — no models needed at apply time — and the endpoint
18
+ * is exact even on brownfield targets (mint predicted it with the
19
+ * unmodeled tables merged in). A mismatch here is a real fault, loud.
20
+ *
21
+ * Needs a direct connection (--database-url / DATABASE_URL): apply writes.
22
+ */
23
+
24
+ import fs from 'node:fs/promises';
25
+ import { introspectContract, type QueryRunner, type AuthzContract } from '../authz-contract.js';
26
+ import { introspectSchema, type SchemaSnapshot } from '../schema-introspect.js';
27
+ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
28
+ import { fingerprintLive } from '../schema-fingerprint.js';
29
+ import { applyGeneratedStatements, currentGitRef } from '../state-apply.js';
30
+ import { renderSchemaLogFingerprintUpdate } from '../derived-apply.js';
31
+ import { checkPlanPrecondition, planHash, PLAN_VERSION, type EdgePlan } from '../edge-plan.js';
32
+ import { verifyDescent, type LiveState } from '../git-descent.js';
33
+ import { resolveModelsPath } from '../models-path.js';
34
+ import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
35
+ import { step, success, fail, info, warn } from '../output.js';
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // The testable core.
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export interface ApplyPlanOptions {
42
+ actor?: string | null;
43
+ /** Defaults to the plan's own gitRef (the intent that minted it). */
44
+ gitRef?: string | null;
45
+ /**
46
+ * The fast-forward rule (brick 7): called with the target's live state
47
+ * after the concurrency lock passes, BEFORE anything executes. Return
48
+ * `ok: false` to refuse — the checkout does not descend from the commit
49
+ * declaring the target's state. Omitted = no descent enforcement (tests,
50
+ * or an explicit, confirmed, snapshotted force).
51
+ */
52
+ verifyDescent?: (live: {
53
+ snapshot: SchemaSnapshot;
54
+ contract: AuthzContract;
55
+ fingerprint: string;
56
+ }) => Promise<{ ok: true } | { ok: false; reason: string }>;
57
+ /** Injectable clock for tests. */
58
+ now?: () => number;
59
+ }
60
+
61
+ export interface ApplyPlanResult {
62
+ status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'verify-failed';
63
+ liveFingerprint: string;
64
+ reason?: string;
65
+ logId?: number;
66
+ }
67
+
68
+ /** Verify → apply → verify. Pure orchestration over an injected QueryRunner. */
69
+ export async function executeApplyPlan(
70
+ runner: QueryRunner,
71
+ plan: EdgePlan,
72
+ options: ApplyPlanOptions = {},
73
+ ): Promise<ApplyPlanResult> {
74
+ const before = await introspectSchema(runner);
75
+ const beforeAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
76
+ const live = fingerprintLive(before, beforeAuthz).hash;
77
+
78
+ if (live === plan.toFingerprint) {
79
+ return { status: 'already-applied', liveFingerprint: live };
80
+ }
81
+ const pre = checkPlanPrecondition(plan, live);
82
+ if (!pre.ok) {
83
+ return { status: 'refused', liveFingerprint: live, reason: pre.reason };
84
+ }
85
+
86
+ if (options.verifyDescent) {
87
+ const descent = await options.verifyDescent({ snapshot: before, contract: beforeAuthz, fingerprint: live });
88
+ if (!descent.ok) {
89
+ return { status: 'descent-refused', liveFingerprint: live, reason: descent.reason };
90
+ }
91
+ }
92
+
93
+ const result = await applyGeneratedStatements(runner, plan.statements, {
94
+ actor: options.actor,
95
+ gitRef: options.gitRef ?? plan.gitRef,
96
+ planRef: planHash(plan),
97
+ fromFingerprint: live,
98
+ now: options.now,
99
+ });
100
+
101
+ const after = await introspectSchema(runner);
102
+ const afterAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
103
+ const landed = fingerprintLive(after, afterAuthz).hash;
104
+ if (result.logId !== undefined) {
105
+ await runner(renderSchemaLogFingerprintUpdate(result.logId, landed));
106
+ }
107
+
108
+ if (landed !== plan.toFingerprint) {
109
+ return {
110
+ status: 'verify-failed',
111
+ liveFingerprint: landed,
112
+ reason: `applied, but the target landed on ${landed.slice(0, 12)} — the plan predicted ${plan.toFingerprint.slice(0, 12)}. Investigate before touching this database again (db:fingerprint, db:generate).`,
113
+ ...(result.logId !== undefined ? { logId: result.logId } : {}),
114
+ };
115
+ }
116
+ return {
117
+ status: 'applied',
118
+ liveFingerprint: landed,
119
+ ...(result.logId !== undefined ? { logId: result.logId } : {}),
120
+ };
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // The CLI shell.
125
+ // ---------------------------------------------------------------------------
126
+
127
+ export async function dbApplyCommand(flags: Record<string, string>): Promise<void> {
128
+ const planPath = flags.plan;
129
+ if (!planPath) {
130
+ fail('db:apply needs --plan <file.plan.json> — mint one with db:plan.');
131
+ process.exit(1);
132
+ }
133
+
134
+ let plan: EdgePlan;
135
+ try {
136
+ plan = JSON.parse(await fs.readFile(planPath, 'utf8'));
137
+ } catch (err: any) {
138
+ fail(`Could not read the plan at ${planPath}: ${err.message}`);
139
+ process.exit(1);
140
+ }
141
+ if (plan.v !== PLAN_VERSION) {
142
+ fail(`Plan version ${plan.v} is not this CLI's ${PLAN_VERSION} — re-mint with this version's db:plan.`);
143
+ process.exit(1);
144
+ }
145
+
146
+ let dbSource: DbSource;
147
+ try {
148
+ dbSource = resolveDbSource(flags);
149
+ } catch (err: any) {
150
+ fail(err.message);
151
+ process.exit(1);
152
+ }
153
+ if (dbSource.kind !== 'url') {
154
+ fail('db:apply writes — it needs a direct connection (--database-url or DATABASE_URL).');
155
+ process.exit(1);
156
+ }
157
+
158
+ // The fast-forward rule's force ceremony (design decision 9): explicit
159
+ // (--force-descent), snapshotted (its value NAMES the snapshot you took),
160
+ // and confirmed (--confirm). All three or no bypass.
161
+ const forceDescent = flags['force-descent'];
162
+ if (forceDescent !== undefined) {
163
+ if (forceDescent === 'true') {
164
+ fail('--force-descent needs the snapshot you took as its value: --force-descent <snapshot-ref> (take one first: db:snapshot or db:backup).');
165
+ process.exit(1);
166
+ }
167
+ if (flags.confirm !== 'true') {
168
+ fail('--force-descent bypasses the fast-forward rule — confirm explicitly with --confirm.');
169
+ process.exit(1);
170
+ }
171
+ }
172
+
173
+ step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
174
+ const { runner, end } = await createUrlRunner(dbSource.url);
175
+
176
+ try {
177
+ info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
178
+ if (forceDescent !== undefined) {
179
+ warn(`DESCENT FORCED — the fast-forward rule is bypassed for this apply. Snapshot on record: ${forceDescent}.`);
180
+ }
181
+ step('Verifying the target is where the plan started...');
182
+ const result = await executeApplyPlan(runner, plan, {
183
+ actor: process.env.USER ?? null,
184
+ gitRef: currentGitRef() ?? plan.gitRef,
185
+ ...(forceDescent === undefined ? {
186
+ verifyDescent: async (live: LiveState & { fingerprint: string }) => {
187
+ step('Descent: searching git for the commit that declares the target\'s state...');
188
+ const verdict = await verifyDescent(resolveModelsPath(flags.models), live, {});
189
+ switch (verdict.status) {
190
+ case 'ok':
191
+ info(`descent: ${verdict.commit.slice(0, 12)} declares the target's state and is an ancestor of HEAD — fast-forward.`);
192
+ return { ok: true };
193
+ case 'fresh-target':
194
+ info('descent: fresh target (no tables) — nothing to protect, bootstrapping.');
195
+ return { ok: true };
196
+ case 'no-git':
197
+ warn('descent: not a git checkout — the fast-forward rule cannot be verified here.');
198
+ return { ok: true };
199
+ case 'diverged':
200
+ case 'drift':
201
+ return { ok: false, reason: verdict.reason };
202
+ }
203
+ },
204
+ } : {}),
205
+ });
206
+
207
+ switch (result.status) {
208
+ case 'already-applied':
209
+ success(`Already at ${plan.toFingerprint.slice(0, 12)} — nothing to do (idempotent re-apply).`);
210
+ break;
211
+ case 'refused':
212
+ fail(`REFUSED: ${result.reason}`);
213
+ process.exit(1);
214
+ break;
215
+ case 'descent-refused':
216
+ fail(`REFUSED (fast-forward rule): ${result.reason}`);
217
+ process.exit(1);
218
+ break;
219
+ case 'verify-failed':
220
+ fail(`Verify FAILED: ${result.reason}`);
221
+ process.exit(1);
222
+ break;
223
+ case 'applied':
224
+ success(`Applied and verified — the target is at ${result.liveFingerprint.slice(0, 12)}, exactly as the plan predicted. Recorded in everystack.schema_log (plan_ref ${planHash(plan).slice(0, 12)}).`);
225
+ break;
226
+ }
227
+ } catch (err: any) {
228
+ fail(`Apply failed and rolled back: ${err.message}`);
229
+ process.exit(1);
230
+ } finally {
231
+ await end?.();
232
+ }
233
+ }
@@ -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
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * `everystack db:plan` — mint a verified edge against a target database
3
+ * (brick 4's mint surface; the review artifact of the deploy boundary).
4
+ *
5
+ * db:plan [--stage <name> | --database-url <url>] [--models <barrel>]
6
+ * [--allow-drops] [--out db.plan.json | --out -]
7
+ *
8
+ * Minting asks the TARGET its fingerprint (no repo-side release pointer to
9
+ * trust), diffs the checkout's models against it, and writes one reviewable
10
+ * plan: the edge plus both endpoint fingerprints. `from` is the target's
11
+ * exact live fingerprint — db:apply refuses if the state moves before the
12
+ * plan lands. `to` is the predicted endpoint, exact even with unmodeled
13
+ * tables. Held drops refuse the mint: a plan is complete or it is not a
14
+ * plan (--allow-drops carries the destruction explicitly, counted red).
15
+ *
16
+ * Plans are EPHEMERAL — attach them to the release/PR run, never commit
17
+ * them (committing plans would rebuild the tape). Minting is read-only, so
18
+ * it works over the ops Lambda (--stage) as well as a direct connection.
19
+ */
20
+
21
+ import fs from 'node:fs/promises';
22
+ import type { ModelDescriptor } from '@everystack/model';
23
+ import { introspectContract, type QueryRunner } from '../authz-contract.js';
24
+ import { introspectSchema } from '../schema-introspect.js';
25
+ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
26
+ import { mintEdgePlan, planHash, buildPlanSummary } from '../edge-plan.js';
27
+ import { verifyDescent } from '../git-descent.js';
28
+ import { currentGitRef } from '../state-apply.js';
29
+ import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
30
+ import { resolveModelsPath } from '../models-path.js';
31
+ import { resolveConfig, opsFunction } from '../config.js';
32
+ import { invokeAction } from '../aws.js';
33
+ import { loadModels } from './db-generate.js';
34
+ import { step, success, fail, info, warn } from '../output.js';
35
+
36
+ const DEFAULT_OUT = 'db.plan.json';
37
+
38
+ /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
39
+ function lambdaRunner(region: string, fn: string): QueryRunner {
40
+ return async (sql: string) => {
41
+ const result: any = await invokeAction(region, fn, 'db:query', { sql });
42
+ if (result?.error) throw new Error(`Query failed: ${result.error}`);
43
+ return result?.rows ?? [];
44
+ };
45
+ }
46
+
47
+ export async function dbPlanCommand(flags: Record<string, string>): Promise<void> {
48
+ let dbSource: DbSource;
49
+ try {
50
+ dbSource = resolveDbSource(flags);
51
+ } catch (err: any) {
52
+ fail(err.message);
53
+ process.exit(1);
54
+ }
55
+
56
+ const modelsPath = resolveModelsPath(flags.models);
57
+ let models: ModelDescriptor[];
58
+ try {
59
+ step(`Loading models from ${modelsPath}...`);
60
+ models = await loadModels(modelsPath);
61
+ info(`${models.length} model(s).`);
62
+ } catch (err: any) {
63
+ fail(err.message);
64
+ process.exit(1);
65
+ }
66
+
67
+ let runner: QueryRunner;
68
+ let end: (() => Promise<void>) | undefined;
69
+ if (dbSource.kind === 'url') {
70
+ step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
71
+ ({ runner, end } = await createUrlRunner(dbSource.url));
72
+ } else {
73
+ step('Resolving deployed config...');
74
+ const config = await resolveConfig(flags.stage);
75
+ runner = lambdaRunner(config.region, opsFunction(config));
76
+ }
77
+
78
+ try {
79
+ step('Asking the target its fingerprint (introspecting state + authz)...');
80
+ const snapshot = await introspectSchema(runner);
81
+ const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
82
+
83
+ let plan;
84
+ try {
85
+ plan = mintEdgePlan(models, snapshot, contract, {
86
+ allowDrops: flags['allow-drops'] === 'true',
87
+ gitRef: currentGitRef(),
88
+ actor: process.env.USER ?? null,
89
+ });
90
+ } catch (err: any) {
91
+ fail(`Mint refused: ${err.message}`);
92
+ process.exit(1);
93
+ }
94
+
95
+ const out = flags.out || DEFAULT_OUT;
96
+ const body = JSON.stringify(plan, null, 2) + '\n';
97
+ if (out === '-') {
98
+ console.log(body);
99
+ } else {
100
+ await fs.writeFile(out, body, 'utf8');
101
+ }
102
+
103
+ for (const line of buildPlanSummary(plan)) info(line);
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
+ }
116
+ if (out !== '-') {
117
+ success(`Wrote ${out} — review it, then \`everystack db:apply --plan ${out}\`.`);
118
+ warn('Plans are ephemeral release artifacts — attach to the run, do NOT commit.');
119
+ }
120
+ } finally {
121
+ await end?.();
122
+ }
123
+ }