@everystack/cli 0.3.19 → 0.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -220,6 +220,7 @@ 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
224
 
224
225
  # Security
225
226
  everystack db:doctor # is the DB least-privilege + RLS-subject?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.3.19",
3
+ "version": "0.3.21",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -78,7 +78,7 @@
78
78
  "structured-headers": "1.0.1",
79
79
  "tsx": "4.21.0",
80
80
  "typescript": "5.9.3",
81
- "@everystack/model": "0.3.4"
81
+ "@everystack/model": "0.3.5"
82
82
  },
83
83
  "peerDependencies": {
84
84
  "@everystack/server": ">=0.1.0",
@@ -0,0 +1,170 @@
1
+ /**
2
+ * `everystack db:apply` — run a reviewed plan: verify → apply → verify
3
+ * (brick 4's runner).
4
+ *
5
+ * db:apply --plan <file.plan.json> [--database-url <url>]
6
+ *
7
+ * The three moves, in order, none skippable:
8
+ * 1. VERIFY the target is exactly where the plan started — live
9
+ * fingerprint == plan.from, or REFUSE. This is the concurrency lock:
10
+ * two applies racing the same stage serialize on it (the second's
11
+ * parent no longer matches; it re-mints against the new reality). A
12
+ * hand-edited target refuses the same way. A target already at
13
+ * plan.to is done — re-apply is idempotent, not an error.
14
+ * 2. APPLY the edge as one transaction, recorded in schema_log with the
15
+ * plan's content address (`plan_ref`) and both fingerprints.
16
+ * 3. VERIFY the target landed exactly on plan.to. The plan is
17
+ * self-contained — no models needed at apply time — and the endpoint
18
+ * is exact even on brownfield targets (mint predicted it with the
19
+ * unmodeled tables merged in). A mismatch here is a real fault, loud.
20
+ *
21
+ * Needs a direct connection (--database-url / DATABASE_URL): apply writes.
22
+ */
23
+
24
+ import fs from 'node:fs/promises';
25
+ import { introspectContract, type QueryRunner } from '../authz-contract.js';
26
+ import { introspectSchema } from '../schema-introspect.js';
27
+ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
28
+ import { fingerprintLive } from '../schema-fingerprint.js';
29
+ import { applyGeneratedStatements, currentGitRef } from '../state-apply.js';
30
+ import { renderSchemaLogFingerprintUpdate } from '../derived-apply.js';
31
+ import { checkPlanPrecondition, planHash, PLAN_VERSION, type EdgePlan } from '../edge-plan.js';
32
+ import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
33
+ import { step, success, fail, info } from '../output.js';
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // The testable core.
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export interface ApplyPlanOptions {
40
+ actor?: string | null;
41
+ /** Defaults to the plan's own gitRef (the intent that minted it). */
42
+ gitRef?: string | null;
43
+ /** Injectable clock for tests. */
44
+ now?: () => number;
45
+ }
46
+
47
+ export interface ApplyPlanResult {
48
+ status: 'applied' | 'already-applied' | 'refused' | 'verify-failed';
49
+ liveFingerprint: string;
50
+ reason?: string;
51
+ logId?: number;
52
+ }
53
+
54
+ /** Verify → apply → verify. Pure orchestration over an injected QueryRunner. */
55
+ export async function executeApplyPlan(
56
+ runner: QueryRunner,
57
+ plan: EdgePlan,
58
+ options: ApplyPlanOptions = {},
59
+ ): Promise<ApplyPlanResult> {
60
+ const before = await introspectSchema(runner);
61
+ const beforeAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
62
+ const live = fingerprintLive(before, beforeAuthz).hash;
63
+
64
+ if (live === plan.toFingerprint) {
65
+ return { status: 'already-applied', liveFingerprint: live };
66
+ }
67
+ const pre = checkPlanPrecondition(plan, live);
68
+ if (!pre.ok) {
69
+ return { status: 'refused', liveFingerprint: live, reason: pre.reason };
70
+ }
71
+
72
+ const result = await applyGeneratedStatements(runner, plan.statements, {
73
+ actor: options.actor,
74
+ gitRef: options.gitRef ?? plan.gitRef,
75
+ planRef: planHash(plan),
76
+ fromFingerprint: live,
77
+ now: options.now,
78
+ });
79
+
80
+ const after = await introspectSchema(runner);
81
+ const afterAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
82
+ const landed = fingerprintLive(after, afterAuthz).hash;
83
+ if (result.logId !== undefined) {
84
+ await runner(renderSchemaLogFingerprintUpdate(result.logId, landed));
85
+ }
86
+
87
+ if (landed !== plan.toFingerprint) {
88
+ return {
89
+ status: 'verify-failed',
90
+ liveFingerprint: landed,
91
+ reason: `applied, but the target landed on ${landed.slice(0, 12)} — the plan predicted ${plan.toFingerprint.slice(0, 12)}. Investigate before touching this database again (db:fingerprint, db:generate).`,
92
+ ...(result.logId !== undefined ? { logId: result.logId } : {}),
93
+ };
94
+ }
95
+ return {
96
+ status: 'applied',
97
+ liveFingerprint: landed,
98
+ ...(result.logId !== undefined ? { logId: result.logId } : {}),
99
+ };
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // The CLI shell.
104
+ // ---------------------------------------------------------------------------
105
+
106
+ export async function dbApplyCommand(flags: Record<string, string>): Promise<void> {
107
+ const planPath = flags.plan;
108
+ if (!planPath) {
109
+ fail('db:apply needs --plan <file.plan.json> — mint one with db:plan.');
110
+ process.exit(1);
111
+ }
112
+
113
+ let plan: EdgePlan;
114
+ try {
115
+ plan = JSON.parse(await fs.readFile(planPath, 'utf8'));
116
+ } catch (err: any) {
117
+ fail(`Could not read the plan at ${planPath}: ${err.message}`);
118
+ process.exit(1);
119
+ }
120
+ if (plan.v !== PLAN_VERSION) {
121
+ fail(`Plan version ${plan.v} is not this CLI's ${PLAN_VERSION} — re-mint with this version's db:plan.`);
122
+ process.exit(1);
123
+ }
124
+
125
+ let dbSource: DbSource;
126
+ try {
127
+ dbSource = resolveDbSource(flags);
128
+ } catch (err: any) {
129
+ fail(err.message);
130
+ process.exit(1);
131
+ }
132
+ if (dbSource.kind !== 'url') {
133
+ fail('db:apply writes — it needs a direct connection (--database-url or DATABASE_URL).');
134
+ process.exit(1);
135
+ }
136
+
137
+ step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
138
+ const { runner, end } = await createUrlRunner(dbSource.url);
139
+
140
+ try {
141
+ 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` : ''})`);
142
+ step('Verifying the target is where the plan started...');
143
+ const result = await executeApplyPlan(runner, plan, {
144
+ actor: process.env.USER ?? null,
145
+ gitRef: currentGitRef() ?? plan.gitRef,
146
+ });
147
+
148
+ switch (result.status) {
149
+ case 'already-applied':
150
+ success(`Already at ${plan.toFingerprint.slice(0, 12)} — nothing to do (idempotent re-apply).`);
151
+ break;
152
+ case 'refused':
153
+ fail(`REFUSED: ${result.reason}`);
154
+ process.exit(1);
155
+ break;
156
+ case 'verify-failed':
157
+ fail(`Verify FAILED: ${result.reason}`);
158
+ process.exit(1);
159
+ break;
160
+ case 'applied':
161
+ success(`Applied and verified — the target is at ${result.liveFingerprint.slice(0, 12)}, exactly as the plan predicted. Recorded in everystack.schema_log (plan_ref ${planHash(plan).slice(0, 12)}).`);
162
+ break;
163
+ }
164
+ } catch (err: any) {
165
+ fail(`Apply failed and rolled back: ${err.message}`);
166
+ process.exit(1);
167
+ } finally {
168
+ await end?.();
169
+ }
170
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `everystack db:plan` — mint a verified edge against a target database
3
+ * (brick 4's mint surface; the review artifact of the deploy boundary).
4
+ *
5
+ * db:plan [--stage <name> | --database-url <url>] [--models <barrel>]
6
+ * [--allow-drops] [--out db.plan.json | --out -]
7
+ *
8
+ * Minting asks the TARGET its fingerprint (no repo-side release pointer to
9
+ * trust), diffs the checkout's models against it, and writes one reviewable
10
+ * plan: the edge plus both endpoint fingerprints. `from` is the target's
11
+ * exact live fingerprint — db:apply refuses if the state moves before the
12
+ * plan lands. `to` is the predicted endpoint, exact even with unmodeled
13
+ * tables. Held drops refuse the mint: a plan is complete or it is not a
14
+ * plan (--allow-drops carries the destruction explicitly, counted red).
15
+ *
16
+ * Plans are EPHEMERAL — attach them to the release/PR run, never commit
17
+ * them (committing plans would rebuild the tape). Minting is read-only, so
18
+ * it works over the ops Lambda (--stage) as well as a direct connection.
19
+ */
20
+
21
+ import fs from 'node:fs/promises';
22
+ import type { ModelDescriptor } from '@everystack/model';
23
+ import { introspectContract, type QueryRunner } from '../authz-contract.js';
24
+ import { introspectSchema } from '../schema-introspect.js';
25
+ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
26
+ import { mintEdgePlan, planHash, buildPlanSummary } from '../edge-plan.js';
27
+ import { currentGitRef } from '../state-apply.js';
28
+ import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
29
+ import { resolveModelsPath } from '../models-path.js';
30
+ import { resolveConfig, opsFunction } from '../config.js';
31
+ import { invokeAction } from '../aws.js';
32
+ import { loadModels } from './db-generate.js';
33
+ import { step, success, fail, info, warn } from '../output.js';
34
+
35
+ const DEFAULT_OUT = 'db.plan.json';
36
+
37
+ /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
38
+ function lambdaRunner(region: string, fn: string): QueryRunner {
39
+ return async (sql: string) => {
40
+ const result: any = await invokeAction(region, fn, 'db:query', { sql });
41
+ if (result?.error) throw new Error(`Query failed: ${result.error}`);
42
+ return result?.rows ?? [];
43
+ };
44
+ }
45
+
46
+ export async function dbPlanCommand(flags: Record<string, string>): Promise<void> {
47
+ let dbSource: DbSource;
48
+ try {
49
+ dbSource = resolveDbSource(flags);
50
+ } catch (err: any) {
51
+ fail(err.message);
52
+ process.exit(1);
53
+ }
54
+
55
+ const modelsPath = resolveModelsPath(flags.models);
56
+ let models: ModelDescriptor[];
57
+ try {
58
+ step(`Loading models from ${modelsPath}...`);
59
+ models = await loadModels(modelsPath);
60
+ info(`${models.length} model(s).`);
61
+ } catch (err: any) {
62
+ fail(err.message);
63
+ process.exit(1);
64
+ }
65
+
66
+ let runner: QueryRunner;
67
+ let end: (() => Promise<void>) | undefined;
68
+ if (dbSource.kind === 'url') {
69
+ step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
70
+ ({ runner, end } = await createUrlRunner(dbSource.url));
71
+ } else {
72
+ step('Resolving deployed config...');
73
+ const config = await resolveConfig(flags.stage);
74
+ runner = lambdaRunner(config.region, opsFunction(config));
75
+ }
76
+
77
+ try {
78
+ step('Asking the target its fingerprint (introspecting state + authz)...');
79
+ const snapshot = await introspectSchema(runner);
80
+ const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
81
+
82
+ let plan;
83
+ try {
84
+ plan = mintEdgePlan(models, snapshot, contract, {
85
+ allowDrops: flags['allow-drops'] === 'true',
86
+ gitRef: currentGitRef(),
87
+ actor: process.env.USER ?? null,
88
+ });
89
+ } catch (err: any) {
90
+ fail(`Mint refused: ${err.message}`);
91
+ process.exit(1);
92
+ }
93
+
94
+ const out = flags.out || DEFAULT_OUT;
95
+ const body = JSON.stringify(plan, null, 2) + '\n';
96
+ if (out === '-') {
97
+ console.log(body);
98
+ } else {
99
+ await fs.writeFile(out, body, 'utf8');
100
+ }
101
+
102
+ for (const line of buildPlanSummary(plan)) info(line);
103
+ info(`plan_ref: ${planHash(plan).slice(0, 12)}`);
104
+ if (out !== '-') {
105
+ success(`Wrote ${out} — review it, then \`everystack db:apply --plan ${out}\`.`);
106
+ warn('Plans are ephemeral release artifacts — attach to the run, do NOT commit.');
107
+ }
108
+ } finally {
109
+ await end?.();
110
+ }
111
+ }
@@ -0,0 +1,180 @@
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
+ * 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.
70
+ */
71
+ export function mintEdgePlan(
72
+ models: ModelDescriptor[],
73
+ snapshot: SchemaSnapshot,
74
+ 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.
96
+ const declared = compileDeclaredState(models, { schema: opts.schema });
97
+ const declaredTables = new Set(declared.snapshot.tables.map((t) => t.table));
98
+ const currentNames = new Set(snapshot.tables.map((t) => t.table));
99
+ const pendingRenameSources = new Set(
100
+ Object.entries(compileTableRenames(models, { schema: opts.schema }))
101
+ .filter(([to, from]) => !currentNames.has(to) && currentNames.has(from))
102
+ .map(([, from]) => from),
103
+ );
104
+ const ridesThrough = (table: string): boolean =>
105
+ !declaredTables.has(table) && !pendingRenameSources.has(table);
106
+ const declaredEnumList = declared.snapshot.enums ?? [];
107
+ const declaredEnums = new Set(declaredEnumList.map((e) => e.name));
108
+ const mergedSnapshot: SchemaSnapshot = {
109
+ tables: [
110
+ ...declared.snapshot.tables,
111
+ ...snapshot.tables.filter((t) => ridesThrough(t.table)),
112
+ ],
113
+ enums: [
114
+ ...declaredEnumList,
115
+ ...(snapshot.enums ?? []).filter((e) => !declaredEnums.has(e.name)),
116
+ ],
117
+ };
118
+ const mergedAuthz = [
119
+ ...declared.contract.tables,
120
+ ...contract.tables.filter((t) => ridesThrough(t.table)),
121
+ ];
122
+
123
+ return {
124
+ v: PLAN_VERSION,
125
+ fromFingerprint: fingerprintLive(snapshot, contract).hash,
126
+ toFingerprint: fingerprintState(mergedSnapshot, mergedAuthz).hash,
127
+ declaredFingerprint: fingerprintModels(models, { schema: opts.schema }).hash,
128
+ statements,
129
+ executable: classified.executable.length,
130
+ // Data-destructive only: dropping a policy/grant is recoverable metadata
131
+ // (the authz phase is data-safe by construction); dropping a table,
132
+ // column, or type is not.
133
+ destructive: classified.executable.filter((s) => /\bDROP\s+(TABLE|COLUMN|TYPE)\b/.test(s)).length,
134
+ notices: classified.notices.length,
135
+ unmodeled: unmodeledTables(models, snapshot),
136
+ gitRef: opts.gitRef ?? null,
137
+ mintedBy: opts.actor ?? null,
138
+ };
139
+ }
140
+
141
+ /** The plan's content address — recorded as `plan_ref` on the schema_log row. */
142
+ export function planHash(plan: EdgePlan): string {
143
+ return createHash('sha256').update(stableStringify(plan)).digest('hex');
144
+ }
145
+
146
+ export type PlanPrecondition = { ok: true } | { ok: false; reason: string };
147
+
148
+ /** The lock: apply only when the target is exactly where the plan started. */
149
+ export function checkPlanPrecondition(plan: EdgePlan, liveFingerprint: string): PlanPrecondition {
150
+ if (liveFingerprint === plan.fromFingerprint) return { ok: true };
151
+ return {
152
+ ok: false,
153
+ reason:
154
+ `target is at ${liveFingerprint.slice(0, 12)}, the plan expects ${plan.fromFingerprint.slice(0, 12)} — ` +
155
+ 'the state moved since minting (a concurrent apply or a hand edit). Re-mint with db:plan against current reality.',
156
+ };
157
+ }
158
+
159
+ const short = (hash: string): string => hash.slice(0, 12);
160
+
161
+ /** Human summary of a plan — the review surface. */
162
+ export function buildPlanSummary(plan: EdgePlan): string[] {
163
+ const lines: string[] = [];
164
+ lines.push(`edge: ${short(plan.fromFingerprint)} → ${short(plan.toFingerprint)}`);
165
+ if (plan.statements.length === 0) {
166
+ lines.push('empty — the target already declares this state');
167
+ return lines;
168
+ }
169
+ lines.push(`${plan.executable} executable statement(s), ${plan.notices} notice(s)`);
170
+ if (plan.destructive > 0) {
171
+ lines.push(`! ${plan.destructive} DESTRUCTIVE statement(s) — drops carried explicitly, review them`);
172
+ }
173
+ if (plan.unmodeled.length > 0) {
174
+ lines.push(`· ${plan.unmodeled.length} unmodeled table(s) ride through untouched: ${plan.unmodeled.join(', ')}`);
175
+ }
176
+ if (plan.toFingerprint !== plan.declaredFingerprint) {
177
+ lines.push(`· predicted endpoint differs from the models-only fingerprint (${short(plan.declaredFingerprint)}) — the unmodeled tables above explain it`);
178
+ }
179
+ return lines;
180
+ }
package/src/cli/index.ts CHANGED
@@ -15,6 +15,8 @@ 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';
18
20
  import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
19
21
  import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
20
22
  import { consoleCommand } from './commands/console.js';
@@ -188,6 +190,12 @@ async function main() {
188
190
  case 'db:diff':
189
191
  await dbDiffCommand(flags);
190
192
  break;
193
+ case 'db:plan':
194
+ await dbPlanCommand(flags);
195
+ break;
196
+ case 'db:apply':
197
+ await dbApplyCommand(flags);
198
+ break;
191
199
  case 'db:authz:pull':
192
200
  await dbAuthzPullCommand(flags);
193
201
  break;
@@ -314,6 +322,8 @@ Usage:
314
322
  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
323
  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
324
  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
+ 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
317
327
  everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
318
328
  everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
319
329
  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
+ }
@@ -65,6 +65,12 @@ export type SchemaChange =
65
65
  | { kind: 'renameSourceMissing'; table: string; column: string; from: string }
66
66
  /** An unmarked drop+add that *might* be a rename — a hint comment, the SQL still drops+adds. */
67
67
  | { kind: 'renameHint'; table: string; from: string; to: string }
68
+ /** A table-level `renamedFrom` satisfied: old table present, new absent → one RENAME, data preserved. */
69
+ | { kind: 'renameTable'; from: string; to: string }
70
+ /** A table marker whose rename is already applied (new name present) — inert, a notice. */
71
+ | { kind: 'renameTableSatisfied'; table: string; from: string }
72
+ /** A table marker pointing at a table the database doesn't have — fell back to CREATE, a notice. */
73
+ | { kind: 'renameTableSourceMissing'; table: string; from: string }
68
74
  /** A new enum type → `CREATE TYPE … AS ENUM (…)`, emitted before the tables that use it. */
69
75
  | { kind: 'createType'; name: string; values: string[] }
70
76
  /** A value appended to an existing enum → `ALTER TYPE … ADD VALUE` (carries a -- WARNING). */
@@ -86,6 +92,14 @@ export type SchemaChange =
86
92
  */
87
93
  export type RenameMap = Record<string, Record<string, string>>;
88
94
 
95
+ /**
96
+ * Table-level rename intent: qualified new name → qualified old name. Produced
97
+ * by `compileTableRenames` off the Models' `renamedFrom` option; consumed by
98
+ * `diffSchema`'s pre-pass, which rewrites the current snapshot (old table seen
99
+ * as the new name) so every downstream diff lands on the renamed table.
100
+ */
101
+ export type TableRenameMap = Record<string, string>;
102
+
89
103
  // ---------------------------------------------------------------------------
90
104
  // Normalization — so semantically-equal expressions don't read as drift.
91
105
  // ---------------------------------------------------------------------------
@@ -232,8 +246,9 @@ export function normalizeCheck(expr: string): string {
232
246
  * replaced constraint never clashes with its old self and a column is never dropped out
233
247
  * from under a constraint that still references it.
234
248
  */
235
- export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, renames: RenameMap = {}): SchemaChange[] {
249
+ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, renames: RenameMap = {}, tableRenames: TableRenameMap = {}): SchemaChange[] {
236
250
  const creates: SchemaChange[] = [];
251
+ const renameTables: SchemaChange[] = [];
237
252
  const renameColumns: SchemaChange[] = [];
238
253
  const addColumns: SchemaChange[] = [];
239
254
  const alters: SchemaChange[] = [];
@@ -248,6 +263,28 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
248
263
  const currentByName = new Map(current.tables.map((t) => [t.table, t]));
249
264
  const desiredNames = new Set(desired.tables.map((t) => t.table));
250
265
 
266
+ // Table-rename pre-pass: honor the Models' table-level `renamedFrom` before
267
+ // any matching. A satisfied rename rewrites the current snapshot — the old
268
+ // table is seen under its new name — so every downstream diff (columns,
269
+ // constraints, indexes, drops) lands on the renamed table, and the old name
270
+ // never reaches the dropTables sweep. Mirrors the column-rename semantics:
271
+ // new-name-present → inert notice; source missing → CREATE path + notice.
272
+ for (const [to, from] of Object.entries(tableRenames)) {
273
+ if (!desiredNames.has(to)) continue; // the marker's model isn't in this diff
274
+ if (currentByName.has(to)) {
275
+ notices.push({ kind: 'renameTableSatisfied', table: to, from });
276
+ continue;
277
+ }
278
+ const source = currentByName.get(from);
279
+ if (!source) {
280
+ notices.push({ kind: 'renameTableSourceMissing', table: to, from });
281
+ continue;
282
+ }
283
+ renameTables.push({ kind: 'renameTable', from, to });
284
+ currentByName.delete(from);
285
+ currentByName.set(to, { ...source, table: to });
286
+ }
287
+
251
288
  for (const d of desired.tables) {
252
289
  const c = currentByName.get(d.table);
253
290
  if (!c) {
@@ -274,18 +311,21 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
274
311
  diffIndexes(d, c, createIndexes, dropIndexes);
275
312
  }
276
313
 
277
- for (const c of current.tables) {
314
+ // Sweep the POST-RENAME view: a renamed-away old name is not a drop.
315
+ for (const c of currentByName.values()) {
278
316
  if (!desiredNames.has(c.table)) dropTables.push({ kind: 'dropTable', table: c.table });
279
317
  }
280
318
 
281
319
  const e = diffEnums(desired.enums ?? [], current.enums ?? []);
282
320
 
283
321
  // Enum types come first (a table or column may reference one); value adds before the
284
- // columns that use them. Renames run before adds (a rename frees a name an add may
285
- // reuse) and before alters (which target the new name). Enum drops sit with the other
286
- // removals; notices are comments emitted last, after the DDL.
322
+ // columns that use them. Table renames run before creates (a rename frees a name a
323
+ // create may reuse) and before every per-table change (which all target the new name).
324
+ // Column renames run before adds and alters for the same reason. Enum drops sit with
325
+ // the other removals; notices are comments — emitted last, after the DDL.
287
326
  return [
288
327
  ...e.creates, ...e.adds,
328
+ ...renameTables,
289
329
  ...creates, ...renameColumns, ...addColumns, ...alters,
290
330
  ...dropConstraints, ...addConstraints, ...createIndexes,
291
331
  ...dropColumns, ...dropIndexes, ...dropTables,
@@ -613,6 +653,13 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
613
653
  return `-- NOTICE: primary key on ${change.table} changed (${change.from.join(', ')} → ${change.to.join(', ')}); dropping a primary key needs its constraint name — regenerate this change manually.`;
614
654
  case 'renameColumn':
615
655
  return `ALTER TABLE ${change.table} RENAME COLUMN ${quote(change.from)} TO ${quote(change.to)};`;
656
+ case 'renameTable':
657
+ // RENAME TO takes a bare name — the table stays in its schema.
658
+ return `ALTER TABLE ${change.from} RENAME TO ${quote(change.to.split('.').pop()!)};`;
659
+ case 'renameTableSatisfied':
660
+ return `-- NOTICE: the renamedFrom marker on table ${change.table} is satisfied — the rename from "${change.from}" is applied, the marker is now inert and can be removed.`;
661
+ case 'renameTableSourceMissing':
662
+ return `-- NOTICE: table ${change.table} declares renamedFrom "${change.from}", but the database has neither — created fresh; remove the marker once every environment is past it.`;
616
663
  case 'renameSatisfied':
617
664
  return `-- NOTICE: the renamedFrom marker on column ${quote(change.column)} (${change.table}) is satisfied — the rename from "${change.from}" is applied, the marker is now inert and can be removed.`;
618
665
  case 'renameSourceMissing':