@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.
@@ -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);
@@ -0,0 +1,195 @@
1
+ /**
2
+ * edge-plan — verified edges: a state diff plus the two fingerprints naming
3
+ * its endpoints (brick 4 of the migration-authority design).
4
+ *
5
+ * A plan is minted against a target: `from` is the target's EXACT live
6
+ * fingerprint — that is the lock; apply refuses when the live state moved —
7
+ * and `to` is the PREDICTED live fingerprint after apply: the compiled
8
+ * declared state merged with the target's unmodeled tables as-is. The
9
+ * prediction is exact (proven on real PostgreSQL in the edge-runner
10
+ * integration suite), which buys two properties the tape never had:
11
+ * verify-after needs no models — the plan is self-contained — and it stays
12
+ * exact on brownfield targets where undeclared tables keep the live
13
+ * fingerprint off the models-only one forever.
14
+ *
15
+ * Plans must be COMPLETE: held drops refuse the mint. A reviewed plan
16
+ * either carries its destruction explicitly (`--allow-drops`, counted and
17
+ * shown red) or the models change first — the predicted endpoint is always
18
+ * reachable, so a verify-after mismatch is always a real fault, never
19
+ * "held drops, by design".
20
+ *
21
+ * Plans are EPHEMERAL release artifacts (design decision 6): minted at the
22
+ * deploy boundary, reviewed, applied, reconstructible forever from git —
23
+ * never committed. Their identity is `planHash` — sha256 over the canonical
24
+ * JSON — recorded as `plan_ref` on the schema_log row the apply writes.
25
+ */
26
+
27
+ import { createHash } from 'node:crypto';
28
+ import type { ModelDescriptor } from '@everystack/model';
29
+ import type { SchemaSnapshot } from './schema-introspect.js';
30
+ import type { AuthzContract } from './authz-contract.js';
31
+ import { generateMigrationSql, unmodeledTables } from './migration-generate.js';
32
+ import { classifyGeneratedStatements } from './state-apply.js';
33
+ import { fingerprintLive, fingerprintState, fingerprintModels, stableStringify } from './schema-fingerprint.js';
34
+ import { compileDeclaredState } from './declared-diff.js';
35
+ import { compileTableRenames } from './schema-compile.js';
36
+
37
+ export const PLAN_VERSION = 1;
38
+
39
+ export interface EdgePlan {
40
+ v: number;
41
+ /** The target's exact live fingerprint at mint time — the lock. */
42
+ fromFingerprint: string;
43
+ /** The predicted live fingerprint after apply — exact, unmodeled-aware. */
44
+ toFingerprint: string;
45
+ /** The models-only fingerprint — context for the strong claim. Equals `to` when nothing is unmodeled. */
46
+ declaredFingerprint: string;
47
+ /** The edge, in db:generate's statement grammar (no held drops — mint refuses them). */
48
+ statements: string[];
49
+ executable: number;
50
+ /** Executable statements that DROP something — shown red, explicit by construction. */
51
+ destructive: number;
52
+ notices: number;
53
+ /** Live tables no model declares — untouched by the edge, riding into `to` as-is. */
54
+ unmodeled: string[];
55
+ gitRef: string | null;
56
+ mintedBy: string | null;
57
+ }
58
+
59
+ export interface MintOptions {
60
+ schema?: string;
61
+ allowDrops?: boolean;
62
+ gitRef?: string | null;
63
+ actor?: string | null;
64
+ }
65
+
66
+ /**
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).
78
+ */
79
+ export function predictLiveFingerprint(
80
+ models: ModelDescriptor[],
81
+ snapshot: SchemaSnapshot,
82
+ contract: AuthzContract,
83
+ opts: { schema?: string } = {},
84
+ ): string {
85
+ const declared = compileDeclaredState(models, { schema: opts.schema });
86
+ const declaredTables = new Set(declared.snapshot.tables.map((t) => t.table));
87
+ const currentNames = new Set(snapshot.tables.map((t) => t.table));
88
+ const pendingRenameSources = new Set(
89
+ Object.entries(compileTableRenames(models, { schema: opts.schema }))
90
+ .filter(([to, from]) => !currentNames.has(to) && currentNames.has(from))
91
+ .map(([, from]) => from),
92
+ );
93
+ const ridesThrough = (table: string): boolean =>
94
+ !declaredTables.has(table) && !pendingRenameSources.has(table);
95
+ const declaredEnumList = declared.snapshot.enums ?? [];
96
+ const declaredEnums = new Set(declaredEnumList.map((e) => e.name));
97
+ const mergedSnapshot: SchemaSnapshot = {
98
+ tables: [
99
+ ...declared.snapshot.tables,
100
+ ...snapshot.tables.filter((t) => ridesThrough(t.table)),
101
+ ],
102
+ enums: [
103
+ ...declaredEnumList,
104
+ ...(snapshot.enums ?? []).filter((e) => !declaredEnums.has(e.name)),
105
+ ],
106
+ };
107
+ const mergedAuthz = [
108
+ ...declared.contract.tables,
109
+ ...contract.tables.filter((t) => ridesThrough(t.table)),
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
+ }
137
+
138
+ return {
139
+ v: PLAN_VERSION,
140
+ fromFingerprint: fingerprintLive(snapshot, contract).hash,
141
+ toFingerprint: predictLiveFingerprint(models, snapshot, contract, { schema: opts.schema }),
142
+ declaredFingerprint: fingerprintModels(models, { schema: opts.schema }).hash,
143
+ statements,
144
+ executable: classified.executable.length,
145
+ // Data-destructive only: dropping a policy/grant is recoverable metadata
146
+ // (the authz phase is data-safe by construction); dropping a table,
147
+ // column, or type is not.
148
+ destructive: classified.executable.filter((s) => /\bDROP\s+(TABLE|COLUMN|TYPE)\b/.test(s)).length,
149
+ notices: classified.notices.length,
150
+ unmodeled: unmodeledTables(models, snapshot),
151
+ gitRef: opts.gitRef ?? null,
152
+ mintedBy: opts.actor ?? null,
153
+ };
154
+ }
155
+
156
+ /** The plan's content address — recorded as `plan_ref` on the schema_log row. */
157
+ export function planHash(plan: EdgePlan): string {
158
+ return createHash('sha256').update(stableStringify(plan)).digest('hex');
159
+ }
160
+
161
+ export type PlanPrecondition = { ok: true } | { ok: false; reason: string };
162
+
163
+ /** The lock: apply only when the target is exactly where the plan started. */
164
+ export function checkPlanPrecondition(plan: EdgePlan, liveFingerprint: string): PlanPrecondition {
165
+ if (liveFingerprint === plan.fromFingerprint) return { ok: true };
166
+ return {
167
+ ok: false,
168
+ reason:
169
+ `target is at ${liveFingerprint.slice(0, 12)}, the plan expects ${plan.fromFingerprint.slice(0, 12)} — ` +
170
+ 'the state moved since minting (a concurrent apply or a hand edit). Re-mint with db:plan against current reality.',
171
+ };
172
+ }
173
+
174
+ const short = (hash: string): string => hash.slice(0, 12);
175
+
176
+ /** Human summary of a plan — the review surface. */
177
+ export function buildPlanSummary(plan: EdgePlan): string[] {
178
+ const lines: string[] = [];
179
+ lines.push(`edge: ${short(plan.fromFingerprint)} → ${short(plan.toFingerprint)}`);
180
+ if (plan.statements.length === 0) {
181
+ lines.push('empty — the target already declares this state');
182
+ return lines;
183
+ }
184
+ lines.push(`${plan.executable} executable statement(s), ${plan.notices} notice(s)`);
185
+ if (plan.destructive > 0) {
186
+ lines.push(`! ${plan.destructive} DESTRUCTIVE statement(s) — drops carried explicitly, review them`);
187
+ }
188
+ if (plan.unmodeled.length > 0) {
189
+ lines.push(`· ${plan.unmodeled.length} unmodeled table(s) ride through untouched: ${plan.unmodeled.join(', ')}`);
190
+ }
191
+ if (plan.toFingerprint !== plan.declaredFingerprint) {
192
+ lines.push(`· predicted endpoint differs from the models-only fingerprint (${short(plan.declaredFingerprint)}) — the unmodeled tables above explain it`);
193
+ }
194
+ return lines;
195
+ }
@@ -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
@@ -15,6 +15,9 @@ import { dbReconcileCommand } from './commands/db-reconcile.js';
15
15
  import { dbFingerprintCommand } from './commands/db-fingerprint.js';
16
16
  import { dbSyncCommand } from './commands/db-sync.js';
17
17
  import { dbDiffCommand } from './commands/db-diff.js';
18
+ import { dbPlanCommand } from './commands/db-plan.js';
19
+ import { dbApplyCommand } from './commands/db-apply.js';
20
+ import { dbCheckCommand } from './commands/db-check.js';
18
21
  import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
19
22
  import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
20
23
  import { consoleCommand } from './commands/console.js';
@@ -188,6 +191,15 @@ async function main() {
188
191
  case 'db:diff':
189
192
  await dbDiffCommand(flags);
190
193
  break;
194
+ case 'db:plan':
195
+ await dbPlanCommand(flags);
196
+ break;
197
+ case 'db:apply':
198
+ await dbApplyCommand(flags);
199
+ break;
200
+ case 'db:check':
201
+ await dbCheckCommand(flags);
202
+ break;
191
203
  case 'db:authz:pull':
192
204
  await dbAuthzPullCommand(flags);
193
205
  break;
@@ -314,6 +326,9 @@ Usage:
314
326
  everystack db:reconcile [--sql-dir db/sql] [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--overwrite-drift] [--json] Reconcile the derived layer (functions/views/matviews) against db/sql sources: plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (direct connection only) and records provenance + schema_log. Hand-edits are drift (never overwritten silently); first contact with existing objects wants --baseline, once.
315
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
316
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)
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
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
317
332
  everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
318
333
  everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
319
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)
@@ -18,7 +18,7 @@
18
18
  import type { ModelDescriptor } from '@everystack/model';
19
19
  import type { SchemaSnapshot } from './schema-introspect.js';
20
20
  import type { AuthzContract } from './authz-contract.js';
21
- import { compileTableSchema, compileRenames, compileCreateTable, compileEnums } from './schema-compile.js';
21
+ import { compileTableSchema, compileRenames, compileTableRenames, compileCreateTable, compileEnums } from './schema-compile.js';
22
22
  import { compileTableContract } from './authz-compile.js';
23
23
  import { emitReconcileSql } from './authz-reconcile.js';
24
24
  import { diffSchema, emitSchemaSql, type SchemaChange } from './schema-diff.js';
@@ -82,6 +82,11 @@ function holdDrop(sql: string): string {
82
82
  export function unmodeledTables(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
83
83
  const schema = opts.schema ?? 'public';
84
84
  const declared = new Set(models.map((m) => `${schema}.${m.table}`));
85
+ // A pending table rename's source is ours — declared under its new name.
86
+ const currentNames = new Set(current.tables.map((t) => t.table));
87
+ for (const [to, from] of Object.entries(compileTableRenames(models, { schema }))) {
88
+ if (!currentNames.has(to)) declared.add(from);
89
+ }
85
90
  return current.tables.map((t) => t.table).filter((t) => !declared.has(t)).sort();
86
91
  }
87
92
 
@@ -106,14 +111,26 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
106
111
  // (auth, jobs, observability ship their own) is invisible to the diff, so it is never
107
112
  // dropped. `scope: 'full'` (declared-diff, model→model) skips the filter: there the
108
113
  // from-side is fully declared too, so absence is intent and removals must surface.
114
+ // Table-level rename intent: a pending rename's SOURCE is ours too — it must
115
+ // survive the declared-scope filter (so the diff's pre-pass can see it) and
116
+ // its authz contract must be read under the NEW name (the policies/grants
117
+ // ride the rename in Postgres; without the rewrite they would re-emit and
118
+ // collide).
119
+ const tableRenames = compileTableRenames(models, { schema });
120
+ const currentNames = new Set(current.tables.map((t) => t.table));
121
+ const pendingRenameSources = new Set(
122
+ Object.entries(tableRenames)
123
+ .filter(([to, from]) => !currentNames.has(to) && currentNames.has(from))
124
+ .map(([, from]) => from),
125
+ );
109
126
  const scopedCurrent: SchemaSnapshot = opts.scope === 'full'
110
127
  ? { tables: current.tables, enums: current.enums ?? [] }
111
128
  : {
112
- tables: current.tables.filter((t) => declaredTables.has(t.table)),
129
+ tables: current.tables.filter((t) => declaredTables.has(t.table) || pendingRenameSources.has(t.table)),
113
130
  enums: (current.enums ?? []).filter((e) => declaredEnums.has(e.name)),
114
131
  };
115
132
  const renames = compileRenames(models, { schema });
116
- const changes = diffSchema(desired, scopedCurrent, renames);
133
+ const changes = diffSchema(desired, scopedCurrent, renames, tableRenames);
117
134
 
118
135
  const modelByTable = new Map(models.map((m) => [`${schemaOf(m)}.${m.table}`, m]));
119
136
  const emitted = emitSchemaSql(changes, {
@@ -143,8 +160,21 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
143
160
  // data DDL just created. Emitted only when the live contract is supplied; the Models'
144
161
  // `abilities` compile to the same contract shape introspection reads, so the two diff.
145
162
  // Authz changes are data-safe (no row touches), so they are never held by `allowDrops`.
146
- const authzPhase = opts.liveAuthz
147
- ? emitReconcileSql({ tables: models.map((m) => compileTableContract(m, { schema })), functions: [] }, opts.liveAuthz)
163
+ const pendingByFrom = new Map(
164
+ Object.entries(tableRenames)
165
+ .filter(([, from]) => pendingRenameSources.has(from))
166
+ .map(([to, from]) => [from, to] as const),
167
+ );
168
+ const liveAuthzRenamed = opts.liveAuthz && pendingByFrom.size > 0
169
+ ? {
170
+ ...opts.liveAuthz,
171
+ tables: opts.liveAuthz.tables.map((t) =>
172
+ pendingByFrom.has(t.table) ? { ...t, table: pendingByFrom.get(t.table)! } : t,
173
+ ),
174
+ }
175
+ : opts.liveAuthz;
176
+ const authzPhase = liveAuthzRenamed
177
+ ? emitReconcileSql({ tables: models.map((m) => compileTableContract(m, { schema })), functions: [] }, liveAuthzRenamed)
148
178
  : [];
149
179
 
150
180
  return [...dataPhase, ...authzPhase];
@@ -526,3 +526,20 @@ export function compileRenames(models: ModelDescriptor[], opts: { schema?: strin
526
526
  }
527
527
  return map;
528
528
  }
529
+
530
+ /**
531
+ * Table-level rename intent: qualified new name → qualified old name (same
532
+ * schema — a rename never moves schemas). Produced off the Models'
533
+ * `renamedFrom` option, consumed by `diffSchema`'s pre-pass, which turns a
534
+ * create+drop into one `ALTER TABLE … RENAME TO …`.
535
+ */
536
+ export function compileTableRenames(models: ModelDescriptor[], opts: { schema?: string } = {}): Record<string, string> {
537
+ const map: Record<string, string> = {};
538
+ for (const model of models) {
539
+ if (!model.renamedFrom) continue;
540
+ const schema = model.schema ?? opts.schema ?? 'public';
541
+ // Table names are literal Postgres names (like `model.table` itself) — no case mapping.
542
+ map[`${schema}.${model.table}`] = `${schema}.${model.renamedFrom}`;
543
+ }
544
+ return map;
545
+ }