@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 +1 -0
- package/package.json +2 -2
- package/src/cli/commands/db-apply.ts +170 -0
- package/src/cli/commands/db-plan.ts +111 -0
- package/src/cli/edge-plan.ts +180 -0
- package/src/cli/index.ts +10 -0
- package/src/cli/migration-generate.ts +35 -5
- package/src/cli/schema-compile.ts +17 -0
- package/src/cli/schema-diff.ts +52 -5
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.
|
|
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,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
|
|
147
|
-
|
|
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
|
+
}
|
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':
|