@everystack/cli 0.3.19 → 0.3.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +2 -2
- package/src/cli/commands/db-apply.ts +233 -0
- package/src/cli/commands/db-check.ts +306 -0
- package/src/cli/commands/db-plan.ts +123 -0
- package/src/cli/commands/db-sync.ts +1 -1
- package/src/cli/edge-plan.ts +195 -0
- package/src/cli/git-descent.ts +251 -0
- package/src/cli/index.ts +15 -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
|
@@ -212,7 +212,7 @@ export function syncFailed(run: SyncRun): boolean {
|
|
|
212
212
|
// The CLI shell.
|
|
213
213
|
// ---------------------------------------------------------------------------
|
|
214
214
|
|
|
215
|
-
async function readSqlDirIfPresent(dir: string): Promise<SourceFile[] | null> {
|
|
215
|
+
export async function readSqlDirIfPresent(dir: string): Promise<SourceFile[] | null> {
|
|
216
216
|
let entries: string[];
|
|
217
217
|
try {
|
|
218
218
|
entries = await fs.readdir(dir);
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* edge-plan — verified edges: a state diff plus the two fingerprints naming
|
|
3
|
+
* its endpoints (brick 4 of the migration-authority design).
|
|
4
|
+
*
|
|
5
|
+
* A plan is minted against a target: `from` is the target's EXACT live
|
|
6
|
+
* fingerprint — that is the lock; apply refuses when the live state moved —
|
|
7
|
+
* and `to` is the PREDICTED live fingerprint after apply: the compiled
|
|
8
|
+
* declared state merged with the target's unmodeled tables as-is. The
|
|
9
|
+
* prediction is exact (proven on real PostgreSQL in the edge-runner
|
|
10
|
+
* integration suite), which buys two properties the tape never had:
|
|
11
|
+
* verify-after needs no models — the plan is self-contained — and it stays
|
|
12
|
+
* exact on brownfield targets where undeclared tables keep the live
|
|
13
|
+
* fingerprint off the models-only one forever.
|
|
14
|
+
*
|
|
15
|
+
* Plans must be COMPLETE: held drops refuse the mint. A reviewed plan
|
|
16
|
+
* either carries its destruction explicitly (`--allow-drops`, counted and
|
|
17
|
+
* shown red) or the models change first — the predicted endpoint is always
|
|
18
|
+
* reachable, so a verify-after mismatch is always a real fault, never
|
|
19
|
+
* "held drops, by design".
|
|
20
|
+
*
|
|
21
|
+
* Plans are EPHEMERAL release artifacts (design decision 6): minted at the
|
|
22
|
+
* deploy boundary, reviewed, applied, reconstructible forever from git —
|
|
23
|
+
* never committed. Their identity is `planHash` — sha256 over the canonical
|
|
24
|
+
* JSON — recorded as `plan_ref` on the schema_log row the apply writes.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { createHash } from 'node:crypto';
|
|
28
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
29
|
+
import type { SchemaSnapshot } from './schema-introspect.js';
|
|
30
|
+
import type { AuthzContract } from './authz-contract.js';
|
|
31
|
+
import { generateMigrationSql, unmodeledTables } from './migration-generate.js';
|
|
32
|
+
import { classifyGeneratedStatements } from './state-apply.js';
|
|
33
|
+
import { fingerprintLive, fingerprintState, fingerprintModels, stableStringify } from './schema-fingerprint.js';
|
|
34
|
+
import { compileDeclaredState } from './declared-diff.js';
|
|
35
|
+
import { compileTableRenames } from './schema-compile.js';
|
|
36
|
+
|
|
37
|
+
export const PLAN_VERSION = 1;
|
|
38
|
+
|
|
39
|
+
export interface EdgePlan {
|
|
40
|
+
v: number;
|
|
41
|
+
/** The target's exact live fingerprint at mint time — the lock. */
|
|
42
|
+
fromFingerprint: string;
|
|
43
|
+
/** The predicted live fingerprint after apply — exact, unmodeled-aware. */
|
|
44
|
+
toFingerprint: string;
|
|
45
|
+
/** The models-only fingerprint — context for the strong claim. Equals `to` when nothing is unmodeled. */
|
|
46
|
+
declaredFingerprint: string;
|
|
47
|
+
/** The edge, in db:generate's statement grammar (no held drops — mint refuses them). */
|
|
48
|
+
statements: string[];
|
|
49
|
+
executable: number;
|
|
50
|
+
/** Executable statements that DROP something — shown red, explicit by construction. */
|
|
51
|
+
destructive: number;
|
|
52
|
+
notices: number;
|
|
53
|
+
/** Live tables no model declares — untouched by the edge, riding into `to` as-is. */
|
|
54
|
+
unmodeled: string[];
|
|
55
|
+
gitRef: string | null;
|
|
56
|
+
mintedBy: string | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface MintOptions {
|
|
60
|
+
schema?: string;
|
|
61
|
+
allowDrops?: boolean;
|
|
62
|
+
gitRef?: string | null;
|
|
63
|
+
actor?: string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The predicted live fingerprint of a target AFTER the models' edge lands:
|
|
68
|
+
* declared tables on their compiled shapes exactly (that is what
|
|
69
|
+
* apply-and-verify proves), unmodeled tables and enums riding through
|
|
70
|
+
* untouched. A pending table rename's SOURCE does not ride through — the
|
|
71
|
+
* edge renames it away, and it lands as its declared new name.
|
|
72
|
+
* fingerprintState sorts internally, so merge order is irrelevant.
|
|
73
|
+
*
|
|
74
|
+
* Two callers, one meaning: minting uses it as a plan's `to` endpoint, and
|
|
75
|
+
* git-descent uses it as the declares-test — a commit DECLARES a target's
|
|
76
|
+
* live state exactly when this prediction equals the live fingerprint
|
|
77
|
+
* (the edge from the target to that commit's models is empty).
|
|
78
|
+
*/
|
|
79
|
+
export function predictLiveFingerprint(
|
|
80
|
+
models: ModelDescriptor[],
|
|
81
|
+
snapshot: SchemaSnapshot,
|
|
82
|
+
contract: AuthzContract,
|
|
83
|
+
opts: { schema?: string } = {},
|
|
84
|
+
): string {
|
|
85
|
+
const declared = compileDeclaredState(models, { schema: opts.schema });
|
|
86
|
+
const declaredTables = new Set(declared.snapshot.tables.map((t) => t.table));
|
|
87
|
+
const currentNames = new Set(snapshot.tables.map((t) => t.table));
|
|
88
|
+
const pendingRenameSources = new Set(
|
|
89
|
+
Object.entries(compileTableRenames(models, { schema: opts.schema }))
|
|
90
|
+
.filter(([to, from]) => !currentNames.has(to) && currentNames.has(from))
|
|
91
|
+
.map(([, from]) => from),
|
|
92
|
+
);
|
|
93
|
+
const ridesThrough = (table: string): boolean =>
|
|
94
|
+
!declaredTables.has(table) && !pendingRenameSources.has(table);
|
|
95
|
+
const declaredEnumList = declared.snapshot.enums ?? [];
|
|
96
|
+
const declaredEnums = new Set(declaredEnumList.map((e) => e.name));
|
|
97
|
+
const mergedSnapshot: SchemaSnapshot = {
|
|
98
|
+
tables: [
|
|
99
|
+
...declared.snapshot.tables,
|
|
100
|
+
...snapshot.tables.filter((t) => ridesThrough(t.table)),
|
|
101
|
+
],
|
|
102
|
+
enums: [
|
|
103
|
+
...declaredEnumList,
|
|
104
|
+
...(snapshot.enums ?? []).filter((e) => !declaredEnums.has(e.name)),
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
const mergedAuthz = [
|
|
108
|
+
...declared.contract.tables,
|
|
109
|
+
...contract.tables.filter((t) => ridesThrough(t.table)),
|
|
110
|
+
];
|
|
111
|
+
return fingerprintState(mergedSnapshot, mergedAuthz).hash;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Mint the edge from a target's introspected state to the models' declared
|
|
116
|
+
* state. Pure: introspection in, plan out. Throws when the edge would hold
|
|
117
|
+
* drops back — a plan must be complete to have a reachable endpoint.
|
|
118
|
+
*/
|
|
119
|
+
export function mintEdgePlan(
|
|
120
|
+
models: ModelDescriptor[],
|
|
121
|
+
snapshot: SchemaSnapshot,
|
|
122
|
+
contract: AuthzContract,
|
|
123
|
+
opts: MintOptions = {},
|
|
124
|
+
): EdgePlan {
|
|
125
|
+
const statements = generateMigrationSql(models, snapshot, {
|
|
126
|
+
schema: opts.schema,
|
|
127
|
+
allowDrops: opts.allowDrops,
|
|
128
|
+
liveAuthz: contract,
|
|
129
|
+
});
|
|
130
|
+
const classified = classifyGeneratedStatements(statements);
|
|
131
|
+
if (classified.heldDrops.length > 0) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`the edge holds ${classified.heldDrops.length} destructive change(s) back — a plan must be complete. ` +
|
|
134
|
+
'Re-mint with --allow-drops to carry the destruction explicitly, or mark the contraction in the models first and drop later.',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
v: PLAN_VERSION,
|
|
140
|
+
fromFingerprint: fingerprintLive(snapshot, contract).hash,
|
|
141
|
+
toFingerprint: predictLiveFingerprint(models, snapshot, contract, { schema: opts.schema }),
|
|
142
|
+
declaredFingerprint: fingerprintModels(models, { schema: opts.schema }).hash,
|
|
143
|
+
statements,
|
|
144
|
+
executable: classified.executable.length,
|
|
145
|
+
// Data-destructive only: dropping a policy/grant is recoverable metadata
|
|
146
|
+
// (the authz phase is data-safe by construction); dropping a table,
|
|
147
|
+
// column, or type is not.
|
|
148
|
+
destructive: classified.executable.filter((s) => /\bDROP\s+(TABLE|COLUMN|TYPE)\b/.test(s)).length,
|
|
149
|
+
notices: classified.notices.length,
|
|
150
|
+
unmodeled: unmodeledTables(models, snapshot),
|
|
151
|
+
gitRef: opts.gitRef ?? null,
|
|
152
|
+
mintedBy: opts.actor ?? null,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The plan's content address — recorded as `plan_ref` on the schema_log row. */
|
|
157
|
+
export function planHash(plan: EdgePlan): string {
|
|
158
|
+
return createHash('sha256').update(stableStringify(plan)).digest('hex');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type PlanPrecondition = { ok: true } | { ok: false; reason: string };
|
|
162
|
+
|
|
163
|
+
/** The lock: apply only when the target is exactly where the plan started. */
|
|
164
|
+
export function checkPlanPrecondition(plan: EdgePlan, liveFingerprint: string): PlanPrecondition {
|
|
165
|
+
if (liveFingerprint === plan.fromFingerprint) return { ok: true };
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
reason:
|
|
169
|
+
`target is at ${liveFingerprint.slice(0, 12)}, the plan expects ${plan.fromFingerprint.slice(0, 12)} — ` +
|
|
170
|
+
'the state moved since minting (a concurrent apply or a hand edit). Re-mint with db:plan against current reality.',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const short = (hash: string): string => hash.slice(0, 12);
|
|
175
|
+
|
|
176
|
+
/** Human summary of a plan — the review surface. */
|
|
177
|
+
export function buildPlanSummary(plan: EdgePlan): string[] {
|
|
178
|
+
const lines: string[] = [];
|
|
179
|
+
lines.push(`edge: ${short(plan.fromFingerprint)} → ${short(plan.toFingerprint)}`);
|
|
180
|
+
if (plan.statements.length === 0) {
|
|
181
|
+
lines.push('empty — the target already declares this state');
|
|
182
|
+
return lines;
|
|
183
|
+
}
|
|
184
|
+
lines.push(`${plan.executable} executable statement(s), ${plan.notices} notice(s)`);
|
|
185
|
+
if (plan.destructive > 0) {
|
|
186
|
+
lines.push(`! ${plan.destructive} DESTRUCTIVE statement(s) — drops carried explicitly, review them`);
|
|
187
|
+
}
|
|
188
|
+
if (plan.unmodeled.length > 0) {
|
|
189
|
+
lines.push(`· ${plan.unmodeled.length} unmodeled table(s) ride through untouched: ${plan.unmodeled.join(', ')}`);
|
|
190
|
+
}
|
|
191
|
+
if (plan.toFingerprint !== plan.declaredFingerprint) {
|
|
192
|
+
lines.push(`· predicted endpoint differs from the models-only fingerprint (${short(plan.declaredFingerprint)}) — the unmodeled tables above explain it`);
|
|
193
|
+
}
|
|
194
|
+
return lines;
|
|
195
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-descent — a protected database is a git ref (brick 7 of the
|
|
3
|
+
* migration-authority design).
|
|
4
|
+
*
|
|
5
|
+
* The rule git already taught everyone: `db:apply` is a fast-forward push.
|
|
6
|
+
* The target's live fingerprint maps to the git commit whose committed
|
|
7
|
+
* models DECLARE that state; the deploying checkout must be a descendant
|
|
8
|
+
* of such a commit, or the apply refuses — "rebase first". This is what
|
|
9
|
+
* stops the multi-dev disaster case: A merges and deploys, B (branched
|
|
10
|
+
* before A merged) mints against the target — the concurrency lock passes,
|
|
11
|
+
* because B's plan was minted against current reality, but B's edge would
|
|
12
|
+
* REVERT A's merged work. The descent check catches exactly that: the
|
|
13
|
+
* commit declaring the target's state is not in B's history.
|
|
14
|
+
*
|
|
15
|
+
* The search runs over GIT ALONE, never `schema_log` (the ledger is a
|
|
16
|
+
* memoir, not a control surface — design decision 7): enumerate every
|
|
17
|
+
* historical state of the models directory across all refs, compile each
|
|
18
|
+
* unique tree, and ask whether its predicted live endpoint equals the
|
|
19
|
+
* target's live fingerprint (brick 4's merged prediction, so unmodeled
|
|
20
|
+
* brownfield tables ride through the test). A fingerprint no commit
|
|
21
|
+
* declares is a DRIFT report and an explicit operator decision — never a
|
|
22
|
+
* guess. Identity is the models-directory TREE, not the commit: a
|
|
23
|
+
* cherry-picked identical state is the same declared state.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { execFileSync } from 'node:child_process';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { pathToFileURL } from 'node:url';
|
|
31
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
32
|
+
import type { SchemaSnapshot } from './schema-introspect.js';
|
|
33
|
+
import type { AuthzContract } from './authz-contract.js';
|
|
34
|
+
import { fingerprintLive } from './schema-fingerprint.js';
|
|
35
|
+
import { predictLiveFingerprint } from './edge-plan.js';
|
|
36
|
+
|
|
37
|
+
/** Compile a models barrel on disk. The default rides the CLI's runtime (tsx). */
|
|
38
|
+
export type ModelsLoader = (barrel: string) => Promise<ModelDescriptor[]>;
|
|
39
|
+
|
|
40
|
+
export interface LiveState {
|
|
41
|
+
snapshot: SchemaSnapshot;
|
|
42
|
+
contract: AuthzContract;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DescentOptions {
|
|
46
|
+
/** Where git commands run. Default: process.cwd(). */
|
|
47
|
+
cwd?: string;
|
|
48
|
+
/** The Postgres schema the Models default to. Default: `public`. */
|
|
49
|
+
schema?: string;
|
|
50
|
+
loader?: ModelsLoader;
|
|
51
|
+
/**
|
|
52
|
+
* Where historical trees materialize. Default: a fresh temp dir under the
|
|
53
|
+
* repo's node_modules — bare imports (`@everystack/model`) must resolve
|
|
54
|
+
* from the materialized files, and node walks parent directories.
|
|
55
|
+
*/
|
|
56
|
+
materializeRoot?: string;
|
|
57
|
+
/** The ref that must descend from the declaring commit. Default: HEAD. */
|
|
58
|
+
headRef?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type DescentVerdict =
|
|
62
|
+
/** Not a git checkout (or no commits yet) — the rule cannot be verified here. */
|
|
63
|
+
| { status: 'no-git' }
|
|
64
|
+
/** The target has no tables — nothing to protect, first apply bootstraps it. */
|
|
65
|
+
| { status: 'fresh-target' }
|
|
66
|
+
/** Fast-forward: `commit` declares the target's live state and is an ancestor of HEAD. */
|
|
67
|
+
| { status: 'ok'; commit: string; tree: string; scannedTrees: number }
|
|
68
|
+
/** The declaring commits exist but none is an ancestor of HEAD — rebase first. */
|
|
69
|
+
| { status: 'diverged'; commits: string[]; tree: string; reason: string }
|
|
70
|
+
/** No committed models state declares the live fingerprint — operator decision. */
|
|
71
|
+
| { status: 'drift'; scannedTrees: number; compileFailures: string[]; reason: string };
|
|
72
|
+
|
|
73
|
+
export interface ModelTreeCandidate {
|
|
74
|
+
/** The models directory's tree oid — the identity of a declared state. */
|
|
75
|
+
tree: string;
|
|
76
|
+
/** Every enumerated commit carrying that tree, newest first. */
|
|
77
|
+
commits: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const short = (ref: string): string => ref.slice(0, 12);
|
|
81
|
+
|
|
82
|
+
function git(args: string[], cwd: string): string | null {
|
|
83
|
+
try {
|
|
84
|
+
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const defaultLoader: ModelsLoader = async (barrel) => {
|
|
91
|
+
const mod: any = await import(pathToFileURL(path.resolve(barrel)).href);
|
|
92
|
+
const models = mod.models ?? mod.default;
|
|
93
|
+
if (!Array.isArray(models)) throw new Error(`${barrel} does not export a \`models\` array`);
|
|
94
|
+
return models;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Every historical state of the models directory across ALL refs, newest
|
|
99
|
+
* first, grouped by tree oid. `--full-history` keeps every !TREESAME commit
|
|
100
|
+
* (no simplification dropping a state introduced on a side branch); one
|
|
101
|
+
* `cat-file --batch-check` resolves all `<sha>:<dir>` specs in a single
|
|
102
|
+
* child process, in input order.
|
|
103
|
+
*/
|
|
104
|
+
export function enumerateModelTrees(modelsDir: string, cwd: string): ModelTreeCandidate[] {
|
|
105
|
+
const out = git(['rev-list', '--all', '--full-history', '--date-order', '--', modelsDir], cwd);
|
|
106
|
+
if (out === null) return [];
|
|
107
|
+
const shas = out.trim().split('\n').filter(Boolean);
|
|
108
|
+
if (shas.length === 0) return [];
|
|
109
|
+
|
|
110
|
+
const specs = shas.map((sha) => (modelsDir === '.' ? `${sha}^{tree}` : `${sha}:${modelsDir}`));
|
|
111
|
+
let batch: string;
|
|
112
|
+
try {
|
|
113
|
+
batch = execFileSync('git', ['cat-file', '--batch-check'], {
|
|
114
|
+
cwd,
|
|
115
|
+
input: specs.join('\n') + '\n',
|
|
116
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
117
|
+
}).toString();
|
|
118
|
+
} catch {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const byTree = new Map<string, string[]>();
|
|
123
|
+
const order: string[] = [];
|
|
124
|
+
batch.trim().split('\n').forEach((line, i) => {
|
|
125
|
+
const m = line.trim().match(/^([0-9a-f]{40,64}) tree /);
|
|
126
|
+
if (!m) return; // the directory is absent at that commit, or not a tree
|
|
127
|
+
if (!byTree.has(m[1])) {
|
|
128
|
+
byTree.set(m[1], []);
|
|
129
|
+
order.push(m[1]);
|
|
130
|
+
}
|
|
131
|
+
byTree.get(m[1])!.push(shas[i]);
|
|
132
|
+
});
|
|
133
|
+
return order.map((tree) => ({ tree, commits: byTree.get(tree)! }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Extract a tree into `dest` — `git archive` piped to tar, no shell. */
|
|
137
|
+
export function materializeTree(tree: string, dest: string, cwd: string): void {
|
|
138
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
139
|
+
const archive = execFileSync('git', ['archive', '--format=tar', tree], {
|
|
140
|
+
cwd,
|
|
141
|
+
maxBuffer: 512 * 1024 * 1024,
|
|
142
|
+
});
|
|
143
|
+
execFileSync('tar', ['-xf', '-', '-C', dest], { input: archive });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The fast-forward rule. Given the target's live state (already
|
|
148
|
+
* introspected — the apply path has it in hand), find the commit whose
|
|
149
|
+
* committed models declare that state and verify the checkout descends
|
|
150
|
+
* from it. Any declaring ancestor satisfies the rule; `diverged` is
|
|
151
|
+
* reported only when NO tree declaring the state has one.
|
|
152
|
+
*/
|
|
153
|
+
export async function verifyDescent(
|
|
154
|
+
modelsPath: string,
|
|
155
|
+
live: LiveState,
|
|
156
|
+
opts: DescentOptions = {},
|
|
157
|
+
): Promise<DescentVerdict> {
|
|
158
|
+
if (live.snapshot.tables.length === 0) return { status: 'fresh-target' };
|
|
159
|
+
|
|
160
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
161
|
+
const rootOut = git(['rev-parse', '--show-toplevel'], cwd);
|
|
162
|
+
if (rootOut === null) return { status: 'no-git' };
|
|
163
|
+
const repoRoot = rootOut.trim();
|
|
164
|
+
const headOut = git(['rev-parse', opts.headRef ?? 'HEAD'], cwd);
|
|
165
|
+
if (headOut === null) return { status: 'no-git' };
|
|
166
|
+
const head = headOut.trim();
|
|
167
|
+
|
|
168
|
+
// realpath the cwd side: `--show-toplevel` resolves symlinks (macOS tmpdir
|
|
169
|
+
// is /var → /private/var), and a mismatched prefix would walk the models
|
|
170
|
+
// path right out of the repo.
|
|
171
|
+
const modelsAbs = path.resolve(fs.realpathSync(cwd), modelsPath);
|
|
172
|
+
const rel = path.relative(repoRoot, modelsAbs);
|
|
173
|
+
const modelsDir = path.dirname(rel) || '.';
|
|
174
|
+
const barrel = path.basename(modelsAbs);
|
|
175
|
+
|
|
176
|
+
const candidates = enumerateModelTrees(modelsDir, repoRoot);
|
|
177
|
+
const liveFingerprint = fingerprintLive(live.snapshot, live.contract).hash;
|
|
178
|
+
|
|
179
|
+
const nodeModules = path.join(repoRoot, 'node_modules');
|
|
180
|
+
const materializeRoot = opts.materializeRoot
|
|
181
|
+
?? fs.mkdtempSync(path.join(fs.existsSync(nodeModules) ? nodeModules : os.tmpdir(), '.everystack-descent-'));
|
|
182
|
+
const ownsRoot = !opts.materializeRoot;
|
|
183
|
+
const loader = opts.loader ?? defaultLoader;
|
|
184
|
+
|
|
185
|
+
const compileFailures: string[] = [];
|
|
186
|
+
const declaringButDiverged: ModelTreeCandidate[] = [];
|
|
187
|
+
try {
|
|
188
|
+
for (const candidate of candidates) {
|
|
189
|
+
const dest = path.join(materializeRoot, candidate.tree);
|
|
190
|
+
let predicted: string;
|
|
191
|
+
try {
|
|
192
|
+
if (!fs.existsSync(dest)) materializeTree(candidate.tree, dest, repoRoot);
|
|
193
|
+
const models = await loader(path.join(dest, barrel));
|
|
194
|
+
predicted = predictLiveFingerprint(models, live.snapshot, live.contract, { schema: opts.schema });
|
|
195
|
+
} catch (err: any) {
|
|
196
|
+
compileFailures.push(`${short(candidate.tree)} (at ${short(candidate.commits[0])}): ${err.message}`);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (predicted !== liveFingerprint) continue;
|
|
200
|
+
|
|
201
|
+
for (const commit of candidate.commits) {
|
|
202
|
+
try {
|
|
203
|
+
execFileSync('git', ['merge-base', '--is-ancestor', commit, head], {
|
|
204
|
+
cwd: repoRoot,
|
|
205
|
+
stdio: 'ignore',
|
|
206
|
+
});
|
|
207
|
+
return {
|
|
208
|
+
status: 'ok',
|
|
209
|
+
commit,
|
|
210
|
+
tree: candidate.tree,
|
|
211
|
+
scannedTrees: candidates.indexOf(candidate) + 1,
|
|
212
|
+
};
|
|
213
|
+
} catch {
|
|
214
|
+
// not an ancestor — keep looking; an identical tree elsewhere may be
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
declaringButDiverged.push(candidate);
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
if (ownsRoot) fs.rmSync(materializeRoot, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (declaringButDiverged.length > 0) {
|
|
224
|
+
const commits = declaringButDiverged.flatMap((c) => c.commits);
|
|
225
|
+
return {
|
|
226
|
+
status: 'diverged',
|
|
227
|
+
commits,
|
|
228
|
+
tree: declaringButDiverged[0].tree,
|
|
229
|
+
reason:
|
|
230
|
+
`the target's state is declared by ${short(commits[0])}` +
|
|
231
|
+
(commits.length > 1 ? ` (+${commits.length - 1} more)` : '') +
|
|
232
|
+
', which is NOT an ancestor of your checkout — your branch diverged before it. ' +
|
|
233
|
+
'Rebase onto it and re-mint, so your edge moves the target forward instead of reverting merged work.',
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const failureNote = compileFailures.length > 0
|
|
238
|
+
? ` (${compileFailures.length} historical tree(s) no longer compile and were skipped)`
|
|
239
|
+
: '';
|
|
240
|
+
return {
|
|
241
|
+
status: 'drift',
|
|
242
|
+
scannedTrees: candidates.length,
|
|
243
|
+
compileFailures,
|
|
244
|
+
reason:
|
|
245
|
+
`no committed models state declares the target's live fingerprint ${short(liveFingerprint)} — ` +
|
|
246
|
+
`scanned ${candidates.length} historical tree(s) across all refs${failureNote}. ` +
|
|
247
|
+
'Either the database was hand-edited (drift) or the declaring history was rewritten. ' +
|
|
248
|
+
'Align the models with the live database first (db:pull, commit), ' +
|
|
249
|
+
'or force with ceremony: --force-descent <snapshot-ref> --confirm.',
|
|
250
|
+
};
|
|
251
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -15,6 +15,9 @@ import { dbReconcileCommand } from './commands/db-reconcile.js';
|
|
|
15
15
|
import { dbFingerprintCommand } from './commands/db-fingerprint.js';
|
|
16
16
|
import { dbSyncCommand } from './commands/db-sync.js';
|
|
17
17
|
import { dbDiffCommand } from './commands/db-diff.js';
|
|
18
|
+
import { dbPlanCommand } from './commands/db-plan.js';
|
|
19
|
+
import { dbApplyCommand } from './commands/db-apply.js';
|
|
20
|
+
import { dbCheckCommand } from './commands/db-check.js';
|
|
18
21
|
import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
|
|
19
22
|
import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
|
|
20
23
|
import { consoleCommand } from './commands/console.js';
|
|
@@ -188,6 +191,15 @@ async function main() {
|
|
|
188
191
|
case 'db:diff':
|
|
189
192
|
await dbDiffCommand(flags);
|
|
190
193
|
break;
|
|
194
|
+
case 'db:plan':
|
|
195
|
+
await dbPlanCommand(flags);
|
|
196
|
+
break;
|
|
197
|
+
case 'db:apply':
|
|
198
|
+
await dbApplyCommand(flags);
|
|
199
|
+
break;
|
|
200
|
+
case 'db:check':
|
|
201
|
+
await dbCheckCommand(flags);
|
|
202
|
+
break;
|
|
191
203
|
case 'db:authz:pull':
|
|
192
204
|
await dbAuthzPullCommand(flags);
|
|
193
205
|
break;
|
|
@@ -314,6 +326,9 @@ Usage:
|
|
|
314
326
|
everystack db:reconcile [--sql-dir db/sql] [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--overwrite-drift] [--json] Reconcile the derived layer (functions/views/matviews) against db/sql sources: plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (direct connection only) and records provenance + schema_log. Hand-edits are drift (never overwritten silently); first contact with existing objects wants --baseline, once.
|
|
315
327
|
everystack db:sync [--database-url <url>] [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--allow-drops] [--overwrite-drift] [--baseline] [--json] Make the database match your checkout — one verb, both layers: apply the state diff (tables+authz, one transaction, verified by re-diff), reconcile the derived layer against db/sql, report the resulting fingerprint vs the models' declared one. Dev databases only (direct connection required); DROPs held back unless --allow-drops; derived drift refuses unless --overwrite-drift; exit 1 when not converged
|
|
316
328
|
everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
|
|
329
|
+
everystack db:plan [--stage <name> | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only — works via the ops Lambda; plans are ephemeral, never committed
|
|
330
|
+
everystack db:apply --plan <file.plan.json> [--database-url <url>] [--models <barrel>] [--force-descent <snapshot-ref> --confirm] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — "rebase first"; force needs the snapshot you took + --confirm), apply as one transaction (schema_log w/ plan_ref), verify it landed exactly on plan.to. Idempotent when already at the target state; direct connection required
|
|
331
|
+
everystack db:check [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, db/sql parses) and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
|
|
317
332
|
everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
|
|
318
333
|
everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
|
|
319
334
|
everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import type { ModelDescriptor } from '@everystack/model';
|
|
19
19
|
import type { SchemaSnapshot } from './schema-introspect.js';
|
|
20
20
|
import type { AuthzContract } from './authz-contract.js';
|
|
21
|
-
import { compileTableSchema, compileRenames, compileCreateTable, compileEnums } from './schema-compile.js';
|
|
21
|
+
import { compileTableSchema, compileRenames, compileTableRenames, compileCreateTable, compileEnums } from './schema-compile.js';
|
|
22
22
|
import { compileTableContract } from './authz-compile.js';
|
|
23
23
|
import { emitReconcileSql } from './authz-reconcile.js';
|
|
24
24
|
import { diffSchema, emitSchemaSql, type SchemaChange } from './schema-diff.js';
|
|
@@ -82,6 +82,11 @@ function holdDrop(sql: string): string {
|
|
|
82
82
|
export function unmodeledTables(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
|
|
83
83
|
const schema = opts.schema ?? 'public';
|
|
84
84
|
const declared = new Set(models.map((m) => `${schema}.${m.table}`));
|
|
85
|
+
// A pending table rename's source is ours — declared under its new name.
|
|
86
|
+
const currentNames = new Set(current.tables.map((t) => t.table));
|
|
87
|
+
for (const [to, from] of Object.entries(compileTableRenames(models, { schema }))) {
|
|
88
|
+
if (!currentNames.has(to)) declared.add(from);
|
|
89
|
+
}
|
|
85
90
|
return current.tables.map((t) => t.table).filter((t) => !declared.has(t)).sort();
|
|
86
91
|
}
|
|
87
92
|
|
|
@@ -106,14 +111,26 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
|
|
|
106
111
|
// (auth, jobs, observability ship their own) is invisible to the diff, so it is never
|
|
107
112
|
// dropped. `scope: 'full'` (declared-diff, model→model) skips the filter: there the
|
|
108
113
|
// from-side is fully declared too, so absence is intent and removals must surface.
|
|
114
|
+
// Table-level rename intent: a pending rename's SOURCE is ours too — it must
|
|
115
|
+
// survive the declared-scope filter (so the diff's pre-pass can see it) and
|
|
116
|
+
// its authz contract must be read under the NEW name (the policies/grants
|
|
117
|
+
// ride the rename in Postgres; without the rewrite they would re-emit and
|
|
118
|
+
// collide).
|
|
119
|
+
const tableRenames = compileTableRenames(models, { schema });
|
|
120
|
+
const currentNames = new Set(current.tables.map((t) => t.table));
|
|
121
|
+
const pendingRenameSources = new Set(
|
|
122
|
+
Object.entries(tableRenames)
|
|
123
|
+
.filter(([to, from]) => !currentNames.has(to) && currentNames.has(from))
|
|
124
|
+
.map(([, from]) => from),
|
|
125
|
+
);
|
|
109
126
|
const scopedCurrent: SchemaSnapshot = opts.scope === 'full'
|
|
110
127
|
? { tables: current.tables, enums: current.enums ?? [] }
|
|
111
128
|
: {
|
|
112
|
-
tables: current.tables.filter((t) => declaredTables.has(t.table)),
|
|
129
|
+
tables: current.tables.filter((t) => declaredTables.has(t.table) || pendingRenameSources.has(t.table)),
|
|
113
130
|
enums: (current.enums ?? []).filter((e) => declaredEnums.has(e.name)),
|
|
114
131
|
};
|
|
115
132
|
const renames = compileRenames(models, { schema });
|
|
116
|
-
const changes = diffSchema(desired, scopedCurrent, renames);
|
|
133
|
+
const changes = diffSchema(desired, scopedCurrent, renames, tableRenames);
|
|
117
134
|
|
|
118
135
|
const modelByTable = new Map(models.map((m) => [`${schemaOf(m)}.${m.table}`, m]));
|
|
119
136
|
const emitted = emitSchemaSql(changes, {
|
|
@@ -143,8 +160,21 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
|
|
|
143
160
|
// data DDL just created. Emitted only when the live contract is supplied; the Models'
|
|
144
161
|
// `abilities` compile to the same contract shape introspection reads, so the two diff.
|
|
145
162
|
// Authz changes are data-safe (no row touches), so they are never held by `allowDrops`.
|
|
146
|
-
const
|
|
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
|
+
}
|