@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.3.21",
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>",
@@ -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);
@@ -64,35 +64,24 @@ export interface MintOptions {
64
64
  }
65
65
 
66
66
  /**
67
- * Mint the edge from a target's introspected state to the models' declared
68
- * state. Pure: introspection in, plan out. Throws when the edge would hold
69
- * drops back a plan must be complete to have a reachable endpoint.
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 mintEdgePlan(
79
+ export function predictLiveFingerprint(
72
80
  models: ModelDescriptor[],
73
81
  snapshot: SchemaSnapshot,
74
82
  contract: AuthzContract,
75
- opts: MintOptions = {},
76
- ): EdgePlan {
77
- const statements = generateMigrationSql(models, snapshot, {
78
- schema: opts.schema,
79
- allowDrops: opts.allowDrops,
80
- liveAuthz: contract,
81
- });
82
- const classified = classifyGeneratedStatements(statements);
83
- if (classified.heldDrops.length > 0) {
84
- throw new Error(
85
- `the edge holds ${classified.heldDrops.length} destructive change(s) back — a plan must be complete. ` +
86
- 'Re-mint with --allow-drops to carry the destruction explicitly, or mark the contraction in the models first and drop later.',
87
- );
88
- }
89
-
90
- // The predicted endpoint: declared tables land exactly on their compiled
91
- // shapes (that is what apply-and-verify proves), unmodeled tables and
92
- // enums ride through untouched. A pending table rename's SOURCE does not
93
- // ride through — the edge renames it away, and it lands as its declared
94
- // new name. fingerprintState sorts internally, so merge order is
95
- // irrelevant.
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: fingerprintState(mergedSnapshot, mergedAuthz).hash,
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)