@everystack/cli 0.3.18 → 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 +2 -0
- package/package.json +2 -2
- package/src/cli/commands/db-apply.ts +170 -0
- package/src/cli/commands/db-diff.ts +87 -0
- package/src/cli/commands/db-plan.ts +111 -0
- package/src/cli/declared-diff.ts +89 -0
- package/src/cli/edge-plan.ts +180 -0
- package/src/cli/index.ts +15 -0
- package/src/cli/migration-generate.ts +52 -9
- package/src/cli/schema-compile.ts +17 -0
- package/src/cli/schema-diff.ts +52 -5
package/README.md
CHANGED
|
@@ -219,6 +219,8 @@ everystack db:pull # introspect a live DB → field() Model
|
|
|
219
219
|
everystack db:reconcile # deploy the derived layer (functions/views/matviews) from db/sql — no migrations
|
|
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
|
+
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
|
|
222
224
|
|
|
223
225
|
# Security
|
|
224
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.
|
|
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.
|
|
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,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack db:diff` — the state edge between two declared states, no
|
|
3
|
+
* database anywhere (brick 3's command surface).
|
|
4
|
+
*
|
|
5
|
+
* db:diff --from-models <barrel> [--to-models db/models/index.ts]
|
|
6
|
+
* [--allow-drops] [--check] [--json]
|
|
7
|
+
*
|
|
8
|
+
* Compiles both model sets and prints the SQL edge from one to the other —
|
|
9
|
+
* the same statement grammar db:generate produces (executable DDL, held
|
|
10
|
+
* drops, notices), computed with no connection, no network, no side
|
|
11
|
+
* effects. Deterministic: same two states, same edge, forever.
|
|
12
|
+
*
|
|
13
|
+
* The uses this unlocks:
|
|
14
|
+
* - CI plan preview: materialize the base branch's models, diff to HEAD's,
|
|
15
|
+
* attach the edge to the PR. `--check` exits 1 when the edge is non-empty.
|
|
16
|
+
* - Computed rollback: swap the flags. The reverse edge is derived from the
|
|
17
|
+
* same two states — nobody authors a down migration.
|
|
18
|
+
* - Review: the exact SQL a `db:sync` / `db:generate --apply` will run when
|
|
19
|
+
* the target database sits at the from-state.
|
|
20
|
+
*
|
|
21
|
+
* Between two declared states a removed table is intent (unlike the live
|
|
22
|
+
* diff, where an undeclared table is unmodeled and untouchable) — removals
|
|
23
|
+
* surface as drops, held back unless `--allow-drops`, like everywhere else.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
27
|
+
import { diffDeclaredStates } from '../declared-diff.js';
|
|
28
|
+
import { classifyGeneratedStatements } from '../state-apply.js';
|
|
29
|
+
import { fingerprintModels } from '../schema-fingerprint.js';
|
|
30
|
+
import { resolveModelsPath } from '../models-path.js';
|
|
31
|
+
import { loadModels } from './db-generate.js';
|
|
32
|
+
import { step, success, fail, info, warn } from '../output.js';
|
|
33
|
+
|
|
34
|
+
export async function dbDiffCommand(flags: Record<string, string>): Promise<void> {
|
|
35
|
+
const fromPath = flags['from-models'];
|
|
36
|
+
if (!fromPath) {
|
|
37
|
+
fail('db:diff needs --from-models <barrel> — the declared state to diff FROM (--to-models defaults to the checkout\'s db/models).');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const toPath = resolveModelsPath(flags['to-models']);
|
|
41
|
+
|
|
42
|
+
let from: ModelDescriptor[];
|
|
43
|
+
let to: ModelDescriptor[];
|
|
44
|
+
try {
|
|
45
|
+
step(`Loading FROM models from ${fromPath}...`);
|
|
46
|
+
from = await loadModels(fromPath);
|
|
47
|
+
step(`Loading TO models from ${toPath}...`);
|
|
48
|
+
to = await loadModels(toPath);
|
|
49
|
+
info(`${from.length} → ${to.length} model(s).`);
|
|
50
|
+
} catch (err: any) {
|
|
51
|
+
fail(err.message);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const statements = diffDeclaredStates(from, to, { allowDrops: flags['allow-drops'] === 'true' });
|
|
56
|
+
const classified = classifyGeneratedStatements(statements);
|
|
57
|
+
const fromFingerprint = fingerprintModels(from).hash;
|
|
58
|
+
const toFingerprint = fingerprintModels(to).hash;
|
|
59
|
+
|
|
60
|
+
if (flags.json === 'true') {
|
|
61
|
+
console.log(JSON.stringify({
|
|
62
|
+
fromFingerprint,
|
|
63
|
+
toFingerprint,
|
|
64
|
+
statements,
|
|
65
|
+
executable: classified.executable.length,
|
|
66
|
+
heldDrops: classified.heldDrops.length,
|
|
67
|
+
notices: classified.notices.length,
|
|
68
|
+
empty: statements.length === 0,
|
|
69
|
+
}, null, 2));
|
|
70
|
+
} else {
|
|
71
|
+
console.log('');
|
|
72
|
+
if (statements.length === 0) {
|
|
73
|
+
success('The two states are identical — the edge is empty.');
|
|
74
|
+
} else {
|
|
75
|
+
info(`edge — ${statements.length} statement(s):`);
|
|
76
|
+
console.log('');
|
|
77
|
+
for (const s of statements) console.log(s.endsWith(';') ? s : `${s};`);
|
|
78
|
+
console.log('');
|
|
79
|
+
if (classified.heldDrops.length) warn(`${classified.heldDrops.length} destructive change(s) held back as comments (re-run with --allow-drops to emit them).`);
|
|
80
|
+
if (classified.notices.length) info(`${classified.notices.length} notice(s) — renames / manual follow-ups.`);
|
|
81
|
+
}
|
|
82
|
+
info(`fingerprint: ${fromFingerprint.slice(0, 12)} → ${toFingerprint.slice(0, 12)}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (flags.check === 'true' && statements.length > 0) process.exit(1);
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
@@ -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,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* declared-diff — the state edge between two DECLARED states, no live
|
|
3
|
+
* database anywhere (brick 3 of the migration-authority design).
|
|
4
|
+
*
|
|
5
|
+
* The live diff (db:generate) asks "what does the database need to become
|
|
6
|
+
* the models?" — one side is introspected. This module answers the same
|
|
7
|
+
* question between two model sets: compile the FROM models to the exact
|
|
8
|
+
* snapshot + contract shapes introspection produces, and run the one diff
|
|
9
|
+
* engine over them (`scope: 'full'` — between declared states the from-side
|
|
10
|
+
* is fully known, so a removed table is intent and becomes a drop, held by
|
|
11
|
+
* the same `allowDrops` gate as everywhere else).
|
|
12
|
+
*
|
|
13
|
+
* What this buys, per the design:
|
|
14
|
+
* - CI-pure plan previews: the edge between any two git states is
|
|
15
|
+
* `diffDeclaredStates(modelsAt(A), modelsAt(B))` — no database, no
|
|
16
|
+
* network, deterministic.
|
|
17
|
+
* - Computed rollback: the reverse edge is the same call with the arguments
|
|
18
|
+
* swapped — `diff(B, A)` — derived, never authored. Down migrations stop
|
|
19
|
+
* being a thing anyone writes.
|
|
20
|
+
* - The seed of plan minting (brick 4/7): a plan is this edge plus the two
|
|
21
|
+
* fingerprints naming its endpoints.
|
|
22
|
+
*
|
|
23
|
+
* The identity that makes it trustworthy, proven on real PostgreSQL in the
|
|
24
|
+
* integration suite: build a database at A, apply `diff(A→B)`, and
|
|
25
|
+
* `fingerprintLive` equals `fingerprintModels(B)` exactly — the pure edge
|
|
26
|
+
* and the live edge are the same edge.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
30
|
+
import type { SchemaSnapshot } from './schema-introspect.js';
|
|
31
|
+
import type { AuthzContract } from './authz-contract.js';
|
|
32
|
+
import { compileTableSchema, compileEnums } from './schema-compile.js';
|
|
33
|
+
import { compileTableContract } from './authz-compile.js';
|
|
34
|
+
import { generateMigrationSql } from './migration-generate.js';
|
|
35
|
+
|
|
36
|
+
export interface DeclaredState {
|
|
37
|
+
snapshot: SchemaSnapshot;
|
|
38
|
+
contract: AuthzContract;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DeclaredDiffOptions {
|
|
42
|
+
/** The Postgres schema the Models default to. Default: `public`. */
|
|
43
|
+
schema?: string;
|
|
44
|
+
/** Emit real drops for removals. Default false — held back as comments. */
|
|
45
|
+
allowDrops?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compile a model set to the shapes introspection produces — the same
|
|
50
|
+
* compilation `fingerprintModels` hashes, exposed as a state the diff
|
|
51
|
+
* engine can treat as "current".
|
|
52
|
+
*/
|
|
53
|
+
export function compileDeclaredState(
|
|
54
|
+
models: ModelDescriptor[],
|
|
55
|
+
opts: { schema?: string } = {},
|
|
56
|
+
): DeclaredState {
|
|
57
|
+
const schema = opts.schema ?? 'public';
|
|
58
|
+
return {
|
|
59
|
+
snapshot: {
|
|
60
|
+
tables: models.map((m) => compileTableSchema(m, { schema })),
|
|
61
|
+
enums: compileEnums(models),
|
|
62
|
+
},
|
|
63
|
+
contract: {
|
|
64
|
+
tables: models.map((m) => compileTableContract(m, { schema })),
|
|
65
|
+
functions: [],
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The pure state edge `from → to`: the statement stream `db:generate` would
|
|
72
|
+
* produce against a live database at `from`, computed with no database.
|
|
73
|
+
* Same statement grammar throughout — executable DDL, held drops as
|
|
74
|
+
* comments, notices — so `classifyGeneratedStatements` and the apply path
|
|
75
|
+
* treat it identically. Swap the arguments for the reverse edge.
|
|
76
|
+
*/
|
|
77
|
+
export function diffDeclaredStates(
|
|
78
|
+
from: ModelDescriptor[],
|
|
79
|
+
to: ModelDescriptor[],
|
|
80
|
+
opts: DeclaredDiffOptions = {},
|
|
81
|
+
): string[] {
|
|
82
|
+
const a = compileDeclaredState(from, { schema: opts.schema });
|
|
83
|
+
return generateMigrationSql(to, a.snapshot, {
|
|
84
|
+
schema: opts.schema,
|
|
85
|
+
allowDrops: opts.allowDrops,
|
|
86
|
+
liveAuthz: a.contract,
|
|
87
|
+
scope: 'full',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -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
|
@@ -14,6 +14,9 @@ import { dbPullCommand } from './commands/db-pull.js';
|
|
|
14
14
|
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
|
+
import { dbDiffCommand } from './commands/db-diff.js';
|
|
18
|
+
import { dbPlanCommand } from './commands/db-plan.js';
|
|
19
|
+
import { dbApplyCommand } from './commands/db-apply.js';
|
|
17
20
|
import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
|
|
18
21
|
import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
|
|
19
22
|
import { consoleCommand } from './commands/console.js';
|
|
@@ -184,6 +187,15 @@ async function main() {
|
|
|
184
187
|
case 'db:sync':
|
|
185
188
|
await dbSyncCommand(flags);
|
|
186
189
|
break;
|
|
190
|
+
case 'db:diff':
|
|
191
|
+
await dbDiffCommand(flags);
|
|
192
|
+
break;
|
|
193
|
+
case 'db:plan':
|
|
194
|
+
await dbPlanCommand(flags);
|
|
195
|
+
break;
|
|
196
|
+
case 'db:apply':
|
|
197
|
+
await dbApplyCommand(flags);
|
|
198
|
+
break;
|
|
187
199
|
case 'db:authz:pull':
|
|
188
200
|
await dbAuthzPullCommand(flags);
|
|
189
201
|
break;
|
|
@@ -309,6 +321,9 @@ Usage:
|
|
|
309
321
|
everystack db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json] Content-address the live base schema (tables+constraints+authz) and compare against the models — MATCH/MISMATCH (exit 1), plus the unfingerprinted-objects report
|
|
310
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.
|
|
311
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
|
|
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
|
|
312
327
|
everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
|
|
313
328
|
everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
|
|
314
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';
|
|
@@ -33,6 +33,15 @@ export interface GenerateOptions {
|
|
|
33
33
|
* never silently loses data. Destruction needs intent, the same rule renames are built on.
|
|
34
34
|
*/
|
|
35
35
|
allowDrops?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* How much of `current` the diff may see. `'declared'` (default) scopes it to the
|
|
38
|
+
* tables/enums the Models declare — right against a LIVE database, where an
|
|
39
|
+
* undeclared table is unmodeled (auth, jobs, another team's) and must be invisible,
|
|
40
|
+
* never dropped. `'full'` diffs everything — right between two DECLARED states
|
|
41
|
+
* (declared-diff), where the from-side is fully known and absence is intent: a
|
|
42
|
+
* removed table/enum becomes a drop (still held by the `allowDrops` gate).
|
|
43
|
+
*/
|
|
44
|
+
scope?: 'declared' | 'full';
|
|
36
45
|
/**
|
|
37
46
|
* The live authorization contract (`introspectContract`). When provided, the migration
|
|
38
47
|
* carries the authz layer too — the Models' `abilities` compiled to RLS/policies/grants,
|
|
@@ -73,6 +82,11 @@ function holdDrop(sql: string): string {
|
|
|
73
82
|
export function unmodeledTables(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
|
|
74
83
|
const schema = opts.schema ?? 'public';
|
|
75
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
|
+
}
|
|
76
90
|
return current.tables.map((t) => t.table).filter((t) => !declared.has(t)).sort();
|
|
77
91
|
}
|
|
78
92
|
|
|
@@ -94,13 +108,29 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
|
|
|
94
108
|
const declaredTables = new Set(desired.tables.map((t) => t.table));
|
|
95
109
|
const declaredEnums = new Set(desiredEnums.map((e) => e.name));
|
|
96
110
|
// Scope both tables and enums to what the Models declare — an undeclared table OR enum
|
|
97
|
-
// (auth, jobs, observability ship their own) is invisible to the diff, so it is never
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
111
|
+
// (auth, jobs, observability ship their own) is invisible to the diff, so it is never
|
|
112
|
+
// dropped. `scope: 'full'` (declared-diff, model→model) skips the filter: there the
|
|
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
|
+
);
|
|
126
|
+
const scopedCurrent: SchemaSnapshot = opts.scope === 'full'
|
|
127
|
+
? { tables: current.tables, enums: current.enums ?? [] }
|
|
128
|
+
: {
|
|
129
|
+
tables: current.tables.filter((t) => declaredTables.has(t.table) || pendingRenameSources.has(t.table)),
|
|
130
|
+
enums: (current.enums ?? []).filter((e) => declaredEnums.has(e.name)),
|
|
131
|
+
};
|
|
102
132
|
const renames = compileRenames(models, { schema });
|
|
103
|
-
const changes = diffSchema(desired, scopedCurrent, renames);
|
|
133
|
+
const changes = diffSchema(desired, scopedCurrent, renames, tableRenames);
|
|
104
134
|
|
|
105
135
|
const modelByTable = new Map(models.map((m) => [`${schemaOf(m)}.${m.table}`, m]));
|
|
106
136
|
const emitted = emitSchemaSql(changes, {
|
|
@@ -130,8 +160,21 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
|
|
|
130
160
|
// data DDL just created. Emitted only when the live contract is supplied; the Models'
|
|
131
161
|
// `abilities` compile to the same contract shape introspection reads, so the two diff.
|
|
132
162
|
// Authz changes are data-safe (no row touches), so they are never held by `allowDrops`.
|
|
133
|
-
const
|
|
134
|
-
|
|
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)
|
|
135
178
|
: [];
|
|
136
179
|
|
|
137
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
|
+
}
|
package/src/cli/schema-diff.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
285
|
-
// reuse) and before
|
|
286
|
-
//
|
|
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':
|