@everystack/cli 0.3.21 → 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 +2 -1
- package/package.json +1 -1
- package/src/cli/commands/db-apply.ts +67 -4
- package/src/cli/commands/db-check.ts +306 -0
- package/src/cli/commands/db-plan.ts +12 -0
- package/src/cli/commands/db-sync.ts +1 -1
- package/src/cli/edge-plan.ts +41 -26
- package/src/cli/git-descent.ts +251 -0
- package/src/cli/index.ts +6 -1
package/README.md
CHANGED
|
@@ -220,7 +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
|
|
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)
|
|
224
225
|
|
|
225
226
|
# Security
|
|
226
227
|
everystack db:doctor # is the DB least-privilege + RLS-subject?
|
package/package.json
CHANGED
|
@@ -22,15 +22,17 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import fs from 'node:fs/promises';
|
|
25
|
-
import { introspectContract, type QueryRunner } from '../authz-contract.js';
|
|
26
|
-
import { introspectSchema } from '../schema-introspect.js';
|
|
25
|
+
import { introspectContract, type QueryRunner, type AuthzContract } from '../authz-contract.js';
|
|
26
|
+
import { introspectSchema, type SchemaSnapshot } from '../schema-introspect.js';
|
|
27
27
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
28
28
|
import { fingerprintLive } from '../schema-fingerprint.js';
|
|
29
29
|
import { applyGeneratedStatements, currentGitRef } from '../state-apply.js';
|
|
30
30
|
import { renderSchemaLogFingerprintUpdate } from '../derived-apply.js';
|
|
31
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';
|
|
32
34
|
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
33
|
-
import { step, success, fail, info } from '../output.js';
|
|
35
|
+
import { step, success, fail, info, warn } from '../output.js';
|
|
34
36
|
|
|
35
37
|
// ---------------------------------------------------------------------------
|
|
36
38
|
// The testable core.
|
|
@@ -40,12 +42,24 @@ export interface ApplyPlanOptions {
|
|
|
40
42
|
actor?: string | null;
|
|
41
43
|
/** Defaults to the plan's own gitRef (the intent that minted it). */
|
|
42
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 }>;
|
|
43
57
|
/** Injectable clock for tests. */
|
|
44
58
|
now?: () => number;
|
|
45
59
|
}
|
|
46
60
|
|
|
47
61
|
export interface ApplyPlanResult {
|
|
48
|
-
status: 'applied' | 'already-applied' | 'refused' | 'verify-failed';
|
|
62
|
+
status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'verify-failed';
|
|
49
63
|
liveFingerprint: string;
|
|
50
64
|
reason?: string;
|
|
51
65
|
logId?: number;
|
|
@@ -69,6 +83,13 @@ export async function executeApplyPlan(
|
|
|
69
83
|
return { status: 'refused', liveFingerprint: live, reason: pre.reason };
|
|
70
84
|
}
|
|
71
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
|
+
|
|
72
93
|
const result = await applyGeneratedStatements(runner, plan.statements, {
|
|
73
94
|
actor: options.actor,
|
|
74
95
|
gitRef: options.gitRef ?? plan.gitRef,
|
|
@@ -134,15 +155,53 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
134
155
|
process.exit(1);
|
|
135
156
|
}
|
|
136
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
|
+
|
|
137
173
|
step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
|
|
138
174
|
const { runner, end } = await createUrlRunner(dbSource.url);
|
|
139
175
|
|
|
140
176
|
try {
|
|
141
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
|
+
}
|
|
142
181
|
step('Verifying the target is where the plan started...');
|
|
143
182
|
const result = await executeApplyPlan(runner, plan, {
|
|
144
183
|
actor: process.env.USER ?? null,
|
|
145
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
|
+
} : {}),
|
|
146
205
|
});
|
|
147
206
|
|
|
148
207
|
switch (result.status) {
|
|
@@ -153,6 +212,10 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
153
212
|
fail(`REFUSED: ${result.reason}`);
|
|
154
213
|
process.exit(1);
|
|
155
214
|
break;
|
|
215
|
+
case 'descent-refused':
|
|
216
|
+
fail(`REFUSED (fast-forward rule): ${result.reason}`);
|
|
217
|
+
process.exit(1);
|
|
218
|
+
break;
|
|
156
219
|
case 'verify-failed':
|
|
157
220
|
fail(`Verify FAILED: ${result.reason}`);
|
|
158
221
|
process.exit(1);
|
|
@@ -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.');
|
|
@@ -212,7 +212,7 @@ export function syncFailed(run: SyncRun): boolean {
|
|
|
212
212
|
// The CLI shell.
|
|
213
213
|
// ---------------------------------------------------------------------------
|
|
214
214
|
|
|
215
|
-
async function readSqlDirIfPresent(dir: string): Promise<SourceFile[] | null> {
|
|
215
|
+
export async function readSqlDirIfPresent(dir: string): Promise<SourceFile[] | null> {
|
|
216
216
|
let entries: string[];
|
|
217
217
|
try {
|
|
218
218
|
entries = await fs.readdir(dir);
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -64,35 +64,24 @@ export interface MintOptions {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
67
|
+
* The predicted live fingerprint of a target AFTER the models' edge lands:
|
|
68
|
+
* declared tables on their compiled shapes exactly (that is what
|
|
69
|
+
* apply-and-verify proves), unmodeled tables and enums riding through
|
|
70
|
+
* untouched. A pending table rename's SOURCE does not ride through — the
|
|
71
|
+
* edge renames it away, and it lands as its declared new name.
|
|
72
|
+
* fingerprintState sorts internally, so merge order is irrelevant.
|
|
73
|
+
*
|
|
74
|
+
* Two callers, one meaning: minting uses it as a plan's `to` endpoint, and
|
|
75
|
+
* git-descent uses it as the declares-test — a commit DECLARES a target's
|
|
76
|
+
* live state exactly when this prediction equals the live fingerprint
|
|
77
|
+
* (the edge from the target to that commit's models is empty).
|
|
70
78
|
*/
|
|
71
|
-
export function
|
|
79
|
+
export function predictLiveFingerprint(
|
|
72
80
|
models: ModelDescriptor[],
|
|
73
81
|
snapshot: SchemaSnapshot,
|
|
74
82
|
contract: AuthzContract,
|
|
75
|
-
opts:
|
|
76
|
-
):
|
|
77
|
-
const statements = generateMigrationSql(models, snapshot, {
|
|
78
|
-
schema: opts.schema,
|
|
79
|
-
allowDrops: opts.allowDrops,
|
|
80
|
-
liveAuthz: contract,
|
|
81
|
-
});
|
|
82
|
-
const classified = classifyGeneratedStatements(statements);
|
|
83
|
-
if (classified.heldDrops.length > 0) {
|
|
84
|
-
throw new Error(
|
|
85
|
-
`the edge holds ${classified.heldDrops.length} destructive change(s) back — a plan must be complete. ` +
|
|
86
|
-
'Re-mint with --allow-drops to carry the destruction explicitly, or mark the contraction in the models first and drop later.',
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// The predicted endpoint: declared tables land exactly on their compiled
|
|
91
|
-
// shapes (that is what apply-and-verify proves), unmodeled tables and
|
|
92
|
-
// enums ride through untouched. A pending table rename's SOURCE does not
|
|
93
|
-
// ride through — the edge renames it away, and it lands as its declared
|
|
94
|
-
// new name. fingerprintState sorts internally, so merge order is
|
|
95
|
-
// irrelevant.
|
|
83
|
+
opts: { schema?: string } = {},
|
|
84
|
+
): string {
|
|
96
85
|
const declared = compileDeclaredState(models, { schema: opts.schema });
|
|
97
86
|
const declaredTables = new Set(declared.snapshot.tables.map((t) => t.table));
|
|
98
87
|
const currentNames = new Set(snapshot.tables.map((t) => t.table));
|
|
@@ -119,11 +108,37 @@ export function mintEdgePlan(
|
|
|
119
108
|
...declared.contract.tables,
|
|
120
109
|
...contract.tables.filter((t) => ridesThrough(t.table)),
|
|
121
110
|
];
|
|
111
|
+
return fingerprintState(mergedSnapshot, mergedAuthz).hash;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Mint the edge from a target's introspected state to the models' declared
|
|
116
|
+
* state. Pure: introspection in, plan out. Throws when the edge would hold
|
|
117
|
+
* drops back — a plan must be complete to have a reachable endpoint.
|
|
118
|
+
*/
|
|
119
|
+
export function mintEdgePlan(
|
|
120
|
+
models: ModelDescriptor[],
|
|
121
|
+
snapshot: SchemaSnapshot,
|
|
122
|
+
contract: AuthzContract,
|
|
123
|
+
opts: MintOptions = {},
|
|
124
|
+
): EdgePlan {
|
|
125
|
+
const statements = generateMigrationSql(models, snapshot, {
|
|
126
|
+
schema: opts.schema,
|
|
127
|
+
allowDrops: opts.allowDrops,
|
|
128
|
+
liveAuthz: contract,
|
|
129
|
+
});
|
|
130
|
+
const classified = classifyGeneratedStatements(statements);
|
|
131
|
+
if (classified.heldDrops.length > 0) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`the edge holds ${classified.heldDrops.length} destructive change(s) back — a plan must be complete. ` +
|
|
134
|
+
'Re-mint with --allow-drops to carry the destruction explicitly, or mark the contraction in the models first and drop later.',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
122
137
|
|
|
123
138
|
return {
|
|
124
139
|
v: PLAN_VERSION,
|
|
125
140
|
fromFingerprint: fingerprintLive(snapshot, contract).hash,
|
|
126
|
-
toFingerprint:
|
|
141
|
+
toFingerprint: predictLiveFingerprint(models, snapshot, contract, { schema: opts.schema }),
|
|
127
142
|
declaredFingerprint: fingerprintModels(models, { schema: opts.schema }).hash,
|
|
128
143
|
statements,
|
|
129
144
|
executable: classified.executable.length,
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-descent — a protected database is a git ref (brick 7 of the
|
|
3
|
+
* migration-authority design).
|
|
4
|
+
*
|
|
5
|
+
* The rule git already taught everyone: `db:apply` is a fast-forward push.
|
|
6
|
+
* The target's live fingerprint maps to the git commit whose committed
|
|
7
|
+
* models DECLARE that state; the deploying checkout must be a descendant
|
|
8
|
+
* of such a commit, or the apply refuses — "rebase first". This is what
|
|
9
|
+
* stops the multi-dev disaster case: A merges and deploys, B (branched
|
|
10
|
+
* before A merged) mints against the target — the concurrency lock passes,
|
|
11
|
+
* because B's plan was minted against current reality, but B's edge would
|
|
12
|
+
* REVERT A's merged work. The descent check catches exactly that: the
|
|
13
|
+
* commit declaring the target's state is not in B's history.
|
|
14
|
+
*
|
|
15
|
+
* The search runs over GIT ALONE, never `schema_log` (the ledger is a
|
|
16
|
+
* memoir, not a control surface — design decision 7): enumerate every
|
|
17
|
+
* historical state of the models directory across all refs, compile each
|
|
18
|
+
* unique tree, and ask whether its predicted live endpoint equals the
|
|
19
|
+
* target's live fingerprint (brick 4's merged prediction, so unmodeled
|
|
20
|
+
* brownfield tables ride through the test). A fingerprint no commit
|
|
21
|
+
* declares is a DRIFT report and an explicit operator decision — never a
|
|
22
|
+
* guess. Identity is the models-directory TREE, not the commit: a
|
|
23
|
+
* cherry-picked identical state is the same declared state.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { execFileSync } from 'node:child_process';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { pathToFileURL } from 'node:url';
|
|
31
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
32
|
+
import type { SchemaSnapshot } from './schema-introspect.js';
|
|
33
|
+
import type { AuthzContract } from './authz-contract.js';
|
|
34
|
+
import { fingerprintLive } from './schema-fingerprint.js';
|
|
35
|
+
import { predictLiveFingerprint } from './edge-plan.js';
|
|
36
|
+
|
|
37
|
+
/** Compile a models barrel on disk. The default rides the CLI's runtime (tsx). */
|
|
38
|
+
export type ModelsLoader = (barrel: string) => Promise<ModelDescriptor[]>;
|
|
39
|
+
|
|
40
|
+
export interface LiveState {
|
|
41
|
+
snapshot: SchemaSnapshot;
|
|
42
|
+
contract: AuthzContract;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DescentOptions {
|
|
46
|
+
/** Where git commands run. Default: process.cwd(). */
|
|
47
|
+
cwd?: string;
|
|
48
|
+
/** The Postgres schema the Models default to. Default: `public`. */
|
|
49
|
+
schema?: string;
|
|
50
|
+
loader?: ModelsLoader;
|
|
51
|
+
/**
|
|
52
|
+
* Where historical trees materialize. Default: a fresh temp dir under the
|
|
53
|
+
* repo's node_modules — bare imports (`@everystack/model`) must resolve
|
|
54
|
+
* from the materialized files, and node walks parent directories.
|
|
55
|
+
*/
|
|
56
|
+
materializeRoot?: string;
|
|
57
|
+
/** The ref that must descend from the declaring commit. Default: HEAD. */
|
|
58
|
+
headRef?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type DescentVerdict =
|
|
62
|
+
/** Not a git checkout (or no commits yet) — the rule cannot be verified here. */
|
|
63
|
+
| { status: 'no-git' }
|
|
64
|
+
/** The target has no tables — nothing to protect, first apply bootstraps it. */
|
|
65
|
+
| { status: 'fresh-target' }
|
|
66
|
+
/** Fast-forward: `commit` declares the target's live state and is an ancestor of HEAD. */
|
|
67
|
+
| { status: 'ok'; commit: string; tree: string; scannedTrees: number }
|
|
68
|
+
/** The declaring commits exist but none is an ancestor of HEAD — rebase first. */
|
|
69
|
+
| { status: 'diverged'; commits: string[]; tree: string; reason: string }
|
|
70
|
+
/** No committed models state declares the live fingerprint — operator decision. */
|
|
71
|
+
| { status: 'drift'; scannedTrees: number; compileFailures: string[]; reason: string };
|
|
72
|
+
|
|
73
|
+
export interface ModelTreeCandidate {
|
|
74
|
+
/** The models directory's tree oid — the identity of a declared state. */
|
|
75
|
+
tree: string;
|
|
76
|
+
/** Every enumerated commit carrying that tree, newest first. */
|
|
77
|
+
commits: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const short = (ref: string): string => ref.slice(0, 12);
|
|
81
|
+
|
|
82
|
+
function git(args: string[], cwd: string): string | null {
|
|
83
|
+
try {
|
|
84
|
+
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const defaultLoader: ModelsLoader = async (barrel) => {
|
|
91
|
+
const mod: any = await import(pathToFileURL(path.resolve(barrel)).href);
|
|
92
|
+
const models = mod.models ?? mod.default;
|
|
93
|
+
if (!Array.isArray(models)) throw new Error(`${barrel} does not export a \`models\` array`);
|
|
94
|
+
return models;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Every historical state of the models directory across ALL refs, newest
|
|
99
|
+
* first, grouped by tree oid. `--full-history` keeps every !TREESAME commit
|
|
100
|
+
* (no simplification dropping a state introduced on a side branch); one
|
|
101
|
+
* `cat-file --batch-check` resolves all `<sha>:<dir>` specs in a single
|
|
102
|
+
* child process, in input order.
|
|
103
|
+
*/
|
|
104
|
+
export function enumerateModelTrees(modelsDir: string, cwd: string): ModelTreeCandidate[] {
|
|
105
|
+
const out = git(['rev-list', '--all', '--full-history', '--date-order', '--', modelsDir], cwd);
|
|
106
|
+
if (out === null) return [];
|
|
107
|
+
const shas = out.trim().split('\n').filter(Boolean);
|
|
108
|
+
if (shas.length === 0) return [];
|
|
109
|
+
|
|
110
|
+
const specs = shas.map((sha) => (modelsDir === '.' ? `${sha}^{tree}` : `${sha}:${modelsDir}`));
|
|
111
|
+
let batch: string;
|
|
112
|
+
try {
|
|
113
|
+
batch = execFileSync('git', ['cat-file', '--batch-check'], {
|
|
114
|
+
cwd,
|
|
115
|
+
input: specs.join('\n') + '\n',
|
|
116
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
117
|
+
}).toString();
|
|
118
|
+
} catch {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const byTree = new Map<string, string[]>();
|
|
123
|
+
const order: string[] = [];
|
|
124
|
+
batch.trim().split('\n').forEach((line, i) => {
|
|
125
|
+
const m = line.trim().match(/^([0-9a-f]{40,64}) tree /);
|
|
126
|
+
if (!m) return; // the directory is absent at that commit, or not a tree
|
|
127
|
+
if (!byTree.has(m[1])) {
|
|
128
|
+
byTree.set(m[1], []);
|
|
129
|
+
order.push(m[1]);
|
|
130
|
+
}
|
|
131
|
+
byTree.get(m[1])!.push(shas[i]);
|
|
132
|
+
});
|
|
133
|
+
return order.map((tree) => ({ tree, commits: byTree.get(tree)! }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Extract a tree into `dest` — `git archive` piped to tar, no shell. */
|
|
137
|
+
export function materializeTree(tree: string, dest: string, cwd: string): void {
|
|
138
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
139
|
+
const archive = execFileSync('git', ['archive', '--format=tar', tree], {
|
|
140
|
+
cwd,
|
|
141
|
+
maxBuffer: 512 * 1024 * 1024,
|
|
142
|
+
});
|
|
143
|
+
execFileSync('tar', ['-xf', '-', '-C', dest], { input: archive });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The fast-forward rule. Given the target's live state (already
|
|
148
|
+
* introspected — the apply path has it in hand), find the commit whose
|
|
149
|
+
* committed models declare that state and verify the checkout descends
|
|
150
|
+
* from it. Any declaring ancestor satisfies the rule; `diverged` is
|
|
151
|
+
* reported only when NO tree declaring the state has one.
|
|
152
|
+
*/
|
|
153
|
+
export async function verifyDescent(
|
|
154
|
+
modelsPath: string,
|
|
155
|
+
live: LiveState,
|
|
156
|
+
opts: DescentOptions = {},
|
|
157
|
+
): Promise<DescentVerdict> {
|
|
158
|
+
if (live.snapshot.tables.length === 0) return { status: 'fresh-target' };
|
|
159
|
+
|
|
160
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
161
|
+
const rootOut = git(['rev-parse', '--show-toplevel'], cwd);
|
|
162
|
+
if (rootOut === null) return { status: 'no-git' };
|
|
163
|
+
const repoRoot = rootOut.trim();
|
|
164
|
+
const headOut = git(['rev-parse', opts.headRef ?? 'HEAD'], cwd);
|
|
165
|
+
if (headOut === null) return { status: 'no-git' };
|
|
166
|
+
const head = headOut.trim();
|
|
167
|
+
|
|
168
|
+
// realpath the cwd side: `--show-toplevel` resolves symlinks (macOS tmpdir
|
|
169
|
+
// is /var → /private/var), and a mismatched prefix would walk the models
|
|
170
|
+
// path right out of the repo.
|
|
171
|
+
const modelsAbs = path.resolve(fs.realpathSync(cwd), modelsPath);
|
|
172
|
+
const rel = path.relative(repoRoot, modelsAbs);
|
|
173
|
+
const modelsDir = path.dirname(rel) || '.';
|
|
174
|
+
const barrel = path.basename(modelsAbs);
|
|
175
|
+
|
|
176
|
+
const candidates = enumerateModelTrees(modelsDir, repoRoot);
|
|
177
|
+
const liveFingerprint = fingerprintLive(live.snapshot, live.contract).hash;
|
|
178
|
+
|
|
179
|
+
const nodeModules = path.join(repoRoot, 'node_modules');
|
|
180
|
+
const materializeRoot = opts.materializeRoot
|
|
181
|
+
?? fs.mkdtempSync(path.join(fs.existsSync(nodeModules) ? nodeModules : os.tmpdir(), '.everystack-descent-'));
|
|
182
|
+
const ownsRoot = !opts.materializeRoot;
|
|
183
|
+
const loader = opts.loader ?? defaultLoader;
|
|
184
|
+
|
|
185
|
+
const compileFailures: string[] = [];
|
|
186
|
+
const declaringButDiverged: ModelTreeCandidate[] = [];
|
|
187
|
+
try {
|
|
188
|
+
for (const candidate of candidates) {
|
|
189
|
+
const dest = path.join(materializeRoot, candidate.tree);
|
|
190
|
+
let predicted: string;
|
|
191
|
+
try {
|
|
192
|
+
if (!fs.existsSync(dest)) materializeTree(candidate.tree, dest, repoRoot);
|
|
193
|
+
const models = await loader(path.join(dest, barrel));
|
|
194
|
+
predicted = predictLiveFingerprint(models, live.snapshot, live.contract, { schema: opts.schema });
|
|
195
|
+
} catch (err: any) {
|
|
196
|
+
compileFailures.push(`${short(candidate.tree)} (at ${short(candidate.commits[0])}): ${err.message}`);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (predicted !== liveFingerprint) continue;
|
|
200
|
+
|
|
201
|
+
for (const commit of candidate.commits) {
|
|
202
|
+
try {
|
|
203
|
+
execFileSync('git', ['merge-base', '--is-ancestor', commit, head], {
|
|
204
|
+
cwd: repoRoot,
|
|
205
|
+
stdio: 'ignore',
|
|
206
|
+
});
|
|
207
|
+
return {
|
|
208
|
+
status: 'ok',
|
|
209
|
+
commit,
|
|
210
|
+
tree: candidate.tree,
|
|
211
|
+
scannedTrees: candidates.indexOf(candidate) + 1,
|
|
212
|
+
};
|
|
213
|
+
} catch {
|
|
214
|
+
// not an ancestor — keep looking; an identical tree elsewhere may be
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
declaringButDiverged.push(candidate);
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
if (ownsRoot) fs.rmSync(materializeRoot, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (declaringButDiverged.length > 0) {
|
|
224
|
+
const commits = declaringButDiverged.flatMap((c) => c.commits);
|
|
225
|
+
return {
|
|
226
|
+
status: 'diverged',
|
|
227
|
+
commits,
|
|
228
|
+
tree: declaringButDiverged[0].tree,
|
|
229
|
+
reason:
|
|
230
|
+
`the target's state is declared by ${short(commits[0])}` +
|
|
231
|
+
(commits.length > 1 ? ` (+${commits.length - 1} more)` : '') +
|
|
232
|
+
', which is NOT an ancestor of your checkout — your branch diverged before it. ' +
|
|
233
|
+
'Rebase onto it and re-mint, so your edge moves the target forward instead of reverting merged work.',
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const failureNote = compileFailures.length > 0
|
|
238
|
+
? ` (${compileFailures.length} historical tree(s) no longer compile and were skipped)`
|
|
239
|
+
: '';
|
|
240
|
+
return {
|
|
241
|
+
status: 'drift',
|
|
242
|
+
scannedTrees: candidates.length,
|
|
243
|
+
compileFailures,
|
|
244
|
+
reason:
|
|
245
|
+
`no committed models state declares the target's live fingerprint ${short(liveFingerprint)} — ` +
|
|
246
|
+
`scanned ${candidates.length} historical tree(s) across all refs${failureNote}. ` +
|
|
247
|
+
'Either the database was hand-edited (drift) or the declaring history was rewritten. ' +
|
|
248
|
+
'Align the models with the live database first (db:pull, commit), ' +
|
|
249
|
+
'or force with ceremony: --force-descent <snapshot-ref> --confirm.',
|
|
250
|
+
};
|
|
251
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { dbSyncCommand } from './commands/db-sync.js';
|
|
|
17
17
|
import { dbDiffCommand } from './commands/db-diff.js';
|
|
18
18
|
import { dbPlanCommand } from './commands/db-plan.js';
|
|
19
19
|
import { dbApplyCommand } from './commands/db-apply.js';
|
|
20
|
+
import { dbCheckCommand } from './commands/db-check.js';
|
|
20
21
|
import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
|
|
21
22
|
import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
|
|
22
23
|
import { consoleCommand } from './commands/console.js';
|
|
@@ -196,6 +197,9 @@ async function main() {
|
|
|
196
197
|
case 'db:apply':
|
|
197
198
|
await dbApplyCommand(flags);
|
|
198
199
|
break;
|
|
200
|
+
case 'db:check':
|
|
201
|
+
await dbCheckCommand(flags);
|
|
202
|
+
break;
|
|
199
203
|
case 'db:authz:pull':
|
|
200
204
|
await dbAuthzPullCommand(flags);
|
|
201
205
|
break;
|
|
@@ -323,7 +327,8 @@ Usage:
|
|
|
323
327
|
everystack db:sync [--database-url <url>] [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--allow-drops] [--overwrite-drift] [--baseline] [--json] Make the database match your checkout — one verb, both layers: apply the state diff (tables+authz, one transaction, verified by re-diff), reconcile the derived layer against db/sql, report the resulting fingerprint vs the models' declared one. Dev databases only (direct connection required); DROPs held back unless --allow-drops; derived drift refuses unless --overwrite-drift; exit 1 when not converged
|
|
324
328
|
everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
|
|
325
329
|
everystack db:plan [--stage <name> | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only — works via the ops Lambda; plans are ephemeral, never committed
|
|
326
|
-
everystack db:apply --plan <file.plan.json> [--database-url <url>] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), apply as one transaction (schema_log w/ plan_ref), verify it landed exactly on plan.to. Idempotent when already at the target state; direct connection required
|
|
330
|
+
everystack db:apply --plan <file.plan.json> [--database-url <url>] [--models <barrel>] [--force-descent <snapshot-ref> --confirm] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — "rebase first"; force needs the snapshot you took + --confirm), apply as one transaction (schema_log w/ plan_ref), verify it landed exactly on plan.to. Idempotent when already at the target state; direct connection required
|
|
331
|
+
everystack db:check [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, db/sql parses) and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
|
|
327
332
|
everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
|
|
328
333
|
everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
|
|
329
334
|
everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
|