@everystack/cli 0.3.28 → 0.4.0
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 -1
- package/package.json +6 -2
- package/src/cli/apply-execute.ts +185 -0
- package/src/cli/authz-compile.ts +5 -2
- package/src/cli/authz-lint.ts +48 -0
- package/src/cli/aws.ts +102 -4
- package/src/cli/backfill.ts +1 -1
- package/src/cli/commands/db-apply.ts +159 -169
- package/src/cli/commands/db-backfill.ts +2 -2
- package/src/cli/commands/db-backup.ts +34 -0
- package/src/cli/commands/db-branch.ts +28 -4
- package/src/cli/commands/db-check.ts +97 -34
- package/src/cli/commands/db-fingerprint.ts +9 -4
- package/src/cli/commands/db-generate.ts +73 -22
- package/src/cli/commands/db-plan.ts +7 -13
- package/src/cli/commands/db-pull.ts +39 -7
- package/src/cli/commands/db-reconcile.ts +219 -66
- package/src/cli/commands/db-sync.ts +58 -21
- package/src/cli/commands/deploy.ts +4 -0
- package/src/cli/commands/pipeline-run.ts +269 -0
- package/src/cli/db-build.ts +9 -3
- package/src/cli/db-source.ts +83 -7
- package/src/cli/declared-derived.ts +136 -0
- package/src/cli/derived-apply.ts +40 -6
- package/src/cli/derived-compile.ts +327 -0
- package/src/cli/derived-grants.ts +96 -0
- package/src/cli/derived-introspect.ts +258 -21
- package/src/cli/derived-lint.ts +261 -0
- package/src/cli/derived-plan.ts +194 -12
- package/src/cli/derived-render.ts +320 -0
- package/src/cli/derived-source.ts +53 -125
- package/src/cli/discover.ts +16 -0
- package/src/cli/git-descent.ts +15 -2
- package/src/cli/index.ts +26 -6
- package/src/cli/migration-compile.ts +38 -7
- package/src/cli/migration-generate.ts +43 -4
- package/src/cli/model-render.ts +106 -13
- package/src/cli/models-path.ts +1 -1
- package/src/cli/ops-diagnostics.ts +71 -0
- package/src/cli/pipeline-loader.ts +68 -0
- package/src/cli/pipeline-path.ts +25 -0
- package/src/cli/schema-compile.ts +41 -10
- package/src/cli/schema-diff.ts +123 -17
- package/src/cli/schema-fingerprint.ts +28 -18
- package/src/cli/schema-introspect.ts +175 -8
- package/src/cli/schema-source.ts +75 -6
- package/src/cli/state-apply.ts +4 -1
|
@@ -22,171 +22,181 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import fs from 'node:fs/promises';
|
|
25
|
-
import {
|
|
26
|
-
import { introspectSchema
|
|
25
|
+
import { type QueryRunner } from '../authz-contract.js';
|
|
26
|
+
import { introspectSchema } from '../schema-introspect.js';
|
|
27
|
+
import { introspectContract } from '../authz-contract.js';
|
|
27
28
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import { verifyDescent, type LiveState } from '../git-descent.js';
|
|
33
|
-
import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity } from '../apply-authority.js';
|
|
29
|
+
import { currentGitRef } from '../state-apply.js';
|
|
30
|
+
import { planHash, PLAN_VERSION, type EdgePlan } from '../edge-plan.js';
|
|
31
|
+
import { verifyDescent, type DescentVerdict, type LiveState } from '../git-descent.js';
|
|
32
|
+
import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity, type AuthorityVerdict } from '../apply-authority.js';
|
|
34
33
|
import { resolveModelsPath } from '../models-path.js';
|
|
35
|
-
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
34
|
+
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
36
35
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
37
|
-
import { invokeAction } from '../aws.js';
|
|
36
|
+
import { invokeAction, lambdaQueryRunner } from '../aws.js';
|
|
37
|
+
import { executeApplyPlan, type ApplyPlanResult } from '../apply-execute.js';
|
|
38
38
|
import { step, success, fail, info, warn } from '../output.js';
|
|
39
39
|
|
|
40
|
+
// The apply core lives in apply-execute.ts (shared with the ops Lambda's
|
|
41
|
+
// db:apply action). Re-exported so the existing integration suites — and any
|
|
42
|
+
// other in-repo caller — keep importing it from this command module.
|
|
43
|
+
export { executeApplyPlan } from '../apply-execute.js';
|
|
44
|
+
export type { ApplyPlanOptions, ApplyPlanResult } from '../apply-execute.js';
|
|
45
|
+
|
|
40
46
|
// ---------------------------------------------------------------------------
|
|
41
|
-
// The
|
|
47
|
+
// The CLI shell.
|
|
42
48
|
// ---------------------------------------------------------------------------
|
|
43
49
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}) => Promise<{ ok: true } | { ok: false; reason: string }>;
|
|
60
|
-
/**
|
|
61
|
-
* Destructive authority (brick 9, decisions 11+12): called only when the
|
|
62
|
-
* plan is destructive, after the lock and descent checks pass. Return
|
|
63
|
-
* `ok: false` to refuse — the caller is not in the stage's declared
|
|
64
|
-
* approver set. Omitted = no authority gate (non-staged targets keep the
|
|
65
|
-
* ceremony the command shell enforces).
|
|
66
|
-
*/
|
|
67
|
-
verifyAuthority?: () => Promise<{ ok: true } | { ok: false; reason: string }>;
|
|
68
|
-
/**
|
|
69
|
-
* Runs after every verification passes and BEFORE the edge executes, only
|
|
70
|
-
* for destructive plans — the auto-snapshot seam ("losing data should be
|
|
71
|
-
* hard": confirmed, approved, snapshotted, in that order). A throw here
|
|
72
|
-
* aborts the apply with nothing executed.
|
|
73
|
-
*/
|
|
74
|
-
beforeDestructive?: () => Promise<void>;
|
|
75
|
-
/** Injectable clock for tests. */
|
|
76
|
-
now?: () => number;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export interface ApplyPlanResult {
|
|
80
|
-
status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'authority-refused' | 'verify-failed';
|
|
81
|
-
liveFingerprint: string;
|
|
82
|
-
reason?: string;
|
|
83
|
-
logId?: number;
|
|
50
|
+
/**
|
|
51
|
+
* Collapse a DescentVerdict into the ok/reason shape the apply core (and the
|
|
52
|
+
* ops Lambda's db:apply action) enforce. `no-git` / `fresh-target` pass — the
|
|
53
|
+
* rule can't be verified there — matching the direct-URL path's callback.
|
|
54
|
+
*/
|
|
55
|
+
export function descentVerdictForApply(verdict: DescentVerdict): { ok: true } | { ok: false; reason: string } {
|
|
56
|
+
switch (verdict.status) {
|
|
57
|
+
case 'ok':
|
|
58
|
+
case 'fresh-target':
|
|
59
|
+
case 'no-git':
|
|
60
|
+
return { ok: true };
|
|
61
|
+
case 'diverged':
|
|
62
|
+
case 'drift':
|
|
63
|
+
return { ok: false, reason: verdict.reason };
|
|
64
|
+
}
|
|
84
65
|
}
|
|
85
66
|
|
|
86
67
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* bookkeeping write would have failed the apply's ENSURE identically).
|
|
68
|
+
* Render an ApplyPlanResult — shared by the direct (`--database-url`) and the
|
|
69
|
+
* credential-free staged (`--stage`) paths, so the two report identically.
|
|
70
|
+
* Every refusal and the verify-fault exit non-zero.
|
|
91
71
|
*/
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
72
|
+
function reportApplyResult(result: ApplyPlanResult, plan: EdgePlan): void {
|
|
73
|
+
switch (result.status) {
|
|
74
|
+
case 'already-applied':
|
|
75
|
+
success(`Already at ${plan.toFingerprint.slice(0, 12)} — nothing to do (idempotent re-apply).`);
|
|
76
|
+
break;
|
|
77
|
+
case 'refused':
|
|
78
|
+
fail(`REFUSED: ${result.reason}`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
break;
|
|
81
|
+
case 'descent-refused':
|
|
82
|
+
fail(`REFUSED (fast-forward rule): ${result.reason}`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
break;
|
|
85
|
+
case 'authority-refused':
|
|
86
|
+
fail(`REFUSED (destructive authority): ${result.reason}`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
break;
|
|
89
|
+
case 'verify-failed':
|
|
90
|
+
fail(`Verify FAILED: ${result.reason}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
break;
|
|
93
|
+
case 'applied':
|
|
94
|
+
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)}).`);
|
|
95
|
+
break;
|
|
114
96
|
}
|
|
115
97
|
}
|
|
116
98
|
|
|
117
|
-
/**
|
|
118
|
-
|
|
119
|
-
|
|
99
|
+
/**
|
|
100
|
+
* db:apply --stage: the credential-free staged write. The operator never holds
|
|
101
|
+
* a database URL — the WRITE runs in the ops Lambda's db:apply action against
|
|
102
|
+
* its resource-linked connection. The CLI keeps only what needs the checkout
|
|
103
|
+
* or the caller's identity: the fast-forward (descent) rule and the
|
|
104
|
+
* destructive-approver check. The Lambda re-verifies the concurrency lock,
|
|
105
|
+
* applies the DDL in one transaction, stamps schema_log, and verifies plan.to.
|
|
106
|
+
*/
|
|
107
|
+
async function applyPlanViaStage(
|
|
120
108
|
plan: EdgePlan,
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
109
|
+
flags: Record<string, string>,
|
|
110
|
+
forceDescent: string | undefined,
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
step('Resolving deployed config...');
|
|
113
|
+
const config = await resolveConfig(flags.stage);
|
|
114
|
+
const { region } = config;
|
|
115
|
+
const fn = opsFunction(config);
|
|
126
116
|
|
|
127
|
-
|
|
128
|
-
return { status: 'already-applied', liveFingerprint: live };
|
|
129
|
-
}
|
|
130
|
-
const pre = checkPlanPrecondition(plan, live);
|
|
131
|
-
if (!pre.ok) {
|
|
132
|
-
await recordRefusal(runner, plan, live, 'concurrency lock', pre.reason, options);
|
|
133
|
-
return { status: 'refused', liveFingerprint: live, reason: pre.reason };
|
|
134
|
-
}
|
|
117
|
+
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` : ''})`);
|
|
135
118
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
119
|
+
try {
|
|
120
|
+
// Descent — the fast-forward rule needs the git checkout, so the CLI
|
|
121
|
+
// decides it (the Lambda has no checkout). Read the stage's state read-only
|
|
122
|
+
// via the ops Lambda's db:query action, run the rule locally, and hand the
|
|
123
|
+
// verdict to the write action, which enforces + records it in order.
|
|
124
|
+
let descentVerdict: { ok: true } | { ok: false; reason: string };
|
|
125
|
+
if (forceDescent !== undefined) {
|
|
126
|
+
warn(`DESCENT FORCED — the fast-forward rule is bypassed for this apply. Snapshot on record: ${forceDescent}.`);
|
|
127
|
+
descentVerdict = { ok: true };
|
|
128
|
+
} else {
|
|
129
|
+
step('Asking the stage its state (read-only via the ops Lambda)...');
|
|
130
|
+
const readRunner = lambdaQueryRunner(region, fn);
|
|
131
|
+
const snapshot = await introspectSchema(readRunner);
|
|
132
|
+
const contract = await introspectContract(readRunner, contractFunctionRow, FUNCTIONS_SQL);
|
|
133
|
+
step("Descent: searching git for the commit that declares the stage's state...");
|
|
134
|
+
const verdict = await verifyDescent(resolveModelsPath(flags.models), { snapshot, contract }, {});
|
|
135
|
+
switch (verdict.status) {
|
|
136
|
+
case 'ok':
|
|
137
|
+
info(`descent: ${verdict.commit.slice(0, 12)} declares the stage's state and is an ancestor of HEAD — fast-forward.`);
|
|
138
|
+
break;
|
|
139
|
+
case 'fresh-target':
|
|
140
|
+
info('descent: fresh target (no tables) — nothing to protect, bootstrapping.');
|
|
141
|
+
break;
|
|
142
|
+
case 'no-git':
|
|
143
|
+
warn('descent: not a git checkout — the fast-forward rule cannot be verified here.');
|
|
144
|
+
break;
|
|
145
|
+
case 'diverged':
|
|
146
|
+
case 'drift':
|
|
147
|
+
break; // the reason travels to the action's refusal + the memoir
|
|
148
|
+
}
|
|
149
|
+
descentVerdict = descentVerdictForApply(verdict);
|
|
141
150
|
}
|
|
142
|
-
}
|
|
143
151
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
152
|
+
// Destructive ceremony — the approver check is against the caller's REAL
|
|
153
|
+
// IAM identity (STS), which the Lambda cannot see, so the CLI resolves it
|
|
154
|
+
// and checks it against the stage's declared approver set (SSM). The
|
|
155
|
+
// snapshot itself runs IN the Lambda, before the DDL. --confirm always.
|
|
156
|
+
let authorityVerdict: AuthorityVerdict | undefined;
|
|
157
|
+
if (plan.destructive > 0) {
|
|
158
|
+
const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
|
|
159
|
+
if (flags.confirm !== 'true') {
|
|
160
|
+
fail(`This plan is DESTRUCTIVE — ${plan.destructive} statement(s) lose data (${shape}). Explicit confirmation is required, always: re-run with --confirm.`);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
step(`Destructive authority: checking the approver set for stage ${flags.stage}...`);
|
|
164
|
+
const { parseAppName } = await import('../discover.js');
|
|
165
|
+
const appName = await parseAppName();
|
|
166
|
+
const approvers = await readApproverParam(region, appName, flags.stage);
|
|
167
|
+
const identity = approvers === null ? null : await resolveCallerIdentity(region);
|
|
168
|
+
authorityVerdict = checkDestructiveAuthority({ approvers, identity });
|
|
169
|
+
if (authorityVerdict.ok && authorityVerdict.reason === 'approved') {
|
|
170
|
+
info(`authority: ${identity} is a declared approver.`);
|
|
171
|
+
} else if (authorityVerdict.ok) {
|
|
172
|
+
warn('authority: no approver set declared for this stage — proceeding on ceremony alone. Declare one: everystack db:approvers --set <arn-or-name,...> --stage ' + flags.stage);
|
|
173
|
+
}
|
|
174
|
+
// A refusal is enforced + recorded server-side; reportApplyResult renders it.
|
|
149
175
|
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (plan.destructive > 0 && options.beforeDestructive) {
|
|
153
|
-
await options.beforeDestructive();
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const result = await applyGeneratedStatements(runner, plan.statements, {
|
|
157
|
-
actor: options.actor,
|
|
158
|
-
gitRef: options.gitRef ?? plan.gitRef,
|
|
159
|
-
planRef: planHash(plan),
|
|
160
|
-
fromFingerprint: live,
|
|
161
|
-
now: options.now,
|
|
162
|
-
});
|
|
163
176
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
step('Applying in the ops Lambda (credential-free — the operator never holds a database URL)...');
|
|
178
|
+
const result: any = await invokeAction(region, fn, 'db:apply', {
|
|
179
|
+
plan,
|
|
180
|
+
actor: process.env.USER ?? null,
|
|
181
|
+
gitRef: currentGitRef() ?? plan.gitRef,
|
|
182
|
+
descentVerdict,
|
|
183
|
+
...(authorityVerdict ? { authorityVerdict } : {}),
|
|
184
|
+
stage: flags.stage,
|
|
185
|
+
});
|
|
186
|
+
if (result?.error) {
|
|
187
|
+
fail(`Apply failed: ${result.error}`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
if (result?.snapshotId) {
|
|
191
|
+
info(`snapshot on record: ${result.snapshotId} — restore with db:restore --from ${result.snapshotId} --confirm.`);
|
|
192
|
+
}
|
|
193
|
+
reportApplyResult(result as ApplyPlanResult, plan);
|
|
194
|
+
} catch (err: any) {
|
|
195
|
+
fail(`Apply failed: ${err.message}`);
|
|
196
|
+
process.exit(1);
|
|
178
197
|
}
|
|
179
|
-
return {
|
|
180
|
-
status: 'applied',
|
|
181
|
-
liveFingerprint: landed,
|
|
182
|
-
...(result.logId !== undefined ? { logId: result.logId } : {}),
|
|
183
|
-
};
|
|
184
198
|
}
|
|
185
199
|
|
|
186
|
-
// ---------------------------------------------------------------------------
|
|
187
|
-
// The CLI shell.
|
|
188
|
-
// ---------------------------------------------------------------------------
|
|
189
|
-
|
|
190
200
|
export async function dbApplyCommand(flags: Record<string, string>): Promise<void> {
|
|
191
201
|
const planPath = flags.plan;
|
|
192
202
|
if (!planPath) {
|
|
@@ -213,10 +223,6 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
213
223
|
fail(err.message);
|
|
214
224
|
process.exit(1);
|
|
215
225
|
}
|
|
216
|
-
if (dbSource.kind !== 'url') {
|
|
217
|
-
fail('db:apply writes — it needs a direct connection (--database-url or DATABASE_URL).');
|
|
218
|
-
process.exit(1);
|
|
219
|
-
}
|
|
220
226
|
|
|
221
227
|
// The fast-forward rule's force ceremony (design decision 9): explicit
|
|
222
228
|
// (--force-descent), snapshotted (its value NAMES the snapshot you took),
|
|
@@ -233,6 +239,13 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
233
239
|
}
|
|
234
240
|
}
|
|
235
241
|
|
|
242
|
+
// Deployed stage, no direct URL: the write runs in the ops Lambda — the
|
|
243
|
+
// operator never holds a database URL. --database-url stays local-only.
|
|
244
|
+
if (dbSource.kind === 'stage') {
|
|
245
|
+
await applyPlanViaStage(plan, flags, forceDescent);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
236
249
|
// Losing data should be hard (decision 11): a destructive plan applies
|
|
237
250
|
// only confirmed (--confirm, always), snapshotted (automatic with --stage,
|
|
238
251
|
// named over a bare connection), and — when the stage declares an approver
|
|
@@ -279,7 +292,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
279
292
|
}
|
|
280
293
|
}
|
|
281
294
|
|
|
282
|
-
step(dbSource
|
|
295
|
+
step(connectingVia(dbSource));
|
|
283
296
|
const { runner, end } = await createUrlRunner(dbSource.url);
|
|
284
297
|
|
|
285
298
|
try {
|
|
@@ -315,30 +328,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
315
328
|
} : {}),
|
|
316
329
|
});
|
|
317
330
|
|
|
318
|
-
|
|
319
|
-
case 'already-applied':
|
|
320
|
-
success(`Already at ${plan.toFingerprint.slice(0, 12)} — nothing to do (idempotent re-apply).`);
|
|
321
|
-
break;
|
|
322
|
-
case 'refused':
|
|
323
|
-
fail(`REFUSED: ${result.reason}`);
|
|
324
|
-
process.exit(1);
|
|
325
|
-
break;
|
|
326
|
-
case 'descent-refused':
|
|
327
|
-
fail(`REFUSED (fast-forward rule): ${result.reason}`);
|
|
328
|
-
process.exit(1);
|
|
329
|
-
break;
|
|
330
|
-
case 'authority-refused':
|
|
331
|
-
fail(`REFUSED (destructive authority): ${result.reason}`);
|
|
332
|
-
process.exit(1);
|
|
333
|
-
break;
|
|
334
|
-
case 'verify-failed':
|
|
335
|
-
fail(`Verify FAILED: ${result.reason}`);
|
|
336
|
-
process.exit(1);
|
|
337
|
-
break;
|
|
338
|
-
case 'applied':
|
|
339
|
-
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)}).`);
|
|
340
|
-
break;
|
|
341
|
-
}
|
|
331
|
+
reportApplyResult(result, plan);
|
|
342
332
|
} catch (err: any) {
|
|
343
333
|
fail(`Apply failed and rolled back: ${err.message}`);
|
|
344
334
|
process.exit(1);
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
type BackfillPlan,
|
|
28
28
|
} from '../backfill.js';
|
|
29
29
|
import { currentGitRef } from '../state-apply.js';
|
|
30
|
-
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
30
|
+
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
31
31
|
import { readSqlDirIfPresent } from './db-sync.js';
|
|
32
32
|
import { step, success, fail, info, warn } from '../output.js';
|
|
33
33
|
|
|
@@ -69,7 +69,7 @@ export async function dbBackfillCommand(flags: Record<string, string>): Promise<
|
|
|
69
69
|
process.exit(1);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
step(dbSource
|
|
72
|
+
step(connectingVia(dbSource));
|
|
73
73
|
const { runner, end } = await createUrlRunner(dbSource.url);
|
|
74
74
|
try {
|
|
75
75
|
const opts = { actor: process.env.USER ?? null, gitRef: currentGitRef() };
|
|
@@ -63,6 +63,22 @@ export async function dbBackupProbeCommand(flags: Record<string, string>): Promi
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Interpret a `db:backup:probe` result into a fail-clean remedy, or `null` when a dump is safe to
|
|
68
|
+
* run. Pre-flighting this before the dump turns a missing or version-incompatible pg_dump layer
|
|
69
|
+
* into a one-line remedy instead of a cryptic runtime crash mid-dump.
|
|
70
|
+
*/
|
|
71
|
+
export function pgDumpPreflightError(probe: any): string | null {
|
|
72
|
+
if (!probe || probe.error) {
|
|
73
|
+
const detail = probe?.error ?? 'the pg_dump layer probe returned no result';
|
|
74
|
+
return `${detail}\nBuild + publish the layer with scripts/build-pgdump-layer.sh, export PGDUMP_LAYER_ARN, then redeploy.`;
|
|
75
|
+
}
|
|
76
|
+
if (probe.compatible === false) {
|
|
77
|
+
return `pg_dump ${probe.pgDump ?? '(unknown)'} (major ${probe.pgDumpMajor}) is older than the server (major ${probe.serverMajor}) — it will refuse to dump (no bypass exists). Rebuild the layer with pg_dump >= ${probe.serverMajor}.`;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
66
82
|
/** db:backup — pg_dump the stage's DB to S3 (runs in the ops Lambda). Prints the new backup id. */
|
|
67
83
|
export async function dbBackupCommand(flags: Record<string, string>): Promise<void> {
|
|
68
84
|
step('Resolving deployed config...');
|
|
@@ -75,6 +91,24 @@ export async function dbBackupCommand(flags: Record<string, string>): Promise<vo
|
|
|
75
91
|
}
|
|
76
92
|
|
|
77
93
|
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
94
|
+
|
|
95
|
+
// Pre-flight the pg_dump layer before the dump — a missing/incompatible layer becomes a one-line
|
|
96
|
+
// remedy here instead of crashing the runtime mid-dump (the probe returns a clean error, never a
|
|
97
|
+
// crash, when the layer is absent).
|
|
98
|
+
step('Checking the pg_dump layer...');
|
|
99
|
+
let probe: any;
|
|
100
|
+
try {
|
|
101
|
+
probe = await invokeAction(config.region, opsFunction(config), 'db:backup:probe', {});
|
|
102
|
+
} catch (err: any) {
|
|
103
|
+
fail(`Backup pre-flight failed: ${err.message}`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
const preflightError = pgDumpPreflightError(probe);
|
|
107
|
+
if (preflightError) {
|
|
108
|
+
fail(preflightError);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
|
|
78
112
|
step('Running pg_dump → S3 (this may take a while for large databases)...');
|
|
79
113
|
|
|
80
114
|
let result: any;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* decision 14).
|
|
5
5
|
*
|
|
6
6
|
* db:template:refresh [--database-url <url>] [--models <barrel>]
|
|
7
|
-
* [--
|
|
7
|
+
* [--seed "<cmd>"] [--no-seed]
|
|
8
8
|
* db:branch # mint (or find) the current branch's DB
|
|
9
9
|
* db:branch --list # every branch DB, mapped to its branch
|
|
10
10
|
* db:branch --drop --confirm # drop the current branch's DB
|
|
@@ -39,7 +39,7 @@ import { currentGitRef } from '../state-apply.js';
|
|
|
39
39
|
import { fingerprintModels } from '../schema-fingerprint.js';
|
|
40
40
|
import { resolveDbSource, type DbSource } from '../db-source.js';
|
|
41
41
|
import { resolveModelsPath } from '../models-path.js';
|
|
42
|
-
import {
|
|
42
|
+
import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
|
|
43
43
|
import { loadModels } from './db-generate.js';
|
|
44
44
|
import { step, success, fail, info, warn } from '../output.js';
|
|
45
45
|
|
|
@@ -92,7 +92,30 @@ export async function dbTemplateRefreshCommand(flags: Record<string, string>): P
|
|
|
92
92
|
fail(err.message);
|
|
93
93
|
process.exit(1);
|
|
94
94
|
}
|
|
95
|
-
|
|
95
|
+
// B7 tombstone — db/sql is retired and no longer read.
|
|
96
|
+
try {
|
|
97
|
+
if (flags['sql-dir']) {
|
|
98
|
+
fail(await retiredSqlDirFlagRefusal(flags['sql-dir']));
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
const retired = await retiredSqlDirAnywhere(flags.models);
|
|
102
|
+
if (retired) {
|
|
103
|
+
fail(retired);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
} catch (err: any) {
|
|
107
|
+
fail(err.message);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
// The declared derived layer + sequences — branch/template DBs carry the SAME stream
|
|
111
|
+
// every other verb deploys, or a minted branch is silently missing its compute.
|
|
112
|
+
let declaredDb: DeclaredDerived | null = null;
|
|
113
|
+
try {
|
|
114
|
+
declaredDb = await loadDeclaredDerived(flags.models);
|
|
115
|
+
} catch (err: any) {
|
|
116
|
+
fail(err.message);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
96
119
|
|
|
97
120
|
let seedCommand: string | null = null;
|
|
98
121
|
if (flags['no-seed'] === 'true') {
|
|
@@ -109,7 +132,8 @@ export async function dbTemplateRefreshCommand(flags: Record<string, string>): P
|
|
|
109
132
|
step(`Rebuilding ${template} from the declared state (drop → create → sync both layers → seed)...`);
|
|
110
133
|
try {
|
|
111
134
|
const result = await executeTemplateRefresh(url, models, {
|
|
112
|
-
|
|
135
|
+
declared: declaredDb?.objects,
|
|
136
|
+
sequences: declaredDb?.sequences,
|
|
113
137
|
actor: process.env.USER ?? null,
|
|
114
138
|
gitRef: currentGitRef(),
|
|
115
139
|
seed: seedCommand === null ? null : (templateUrl) => {
|