@everystack/cli 0.3.22 → 0.3.25

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.
@@ -0,0 +1,269 @@
1
+ /**
2
+ * branch-db — per-branch dev databases from a models-built template
3
+ * (brick 5-remainder of the migration-authority design, decision 14).
4
+ *
5
+ * The original wound: one shared dev database, poisoned by every branch
6
+ * switch. The cure: the dev template `<base>_tpl` is built FROM THE
7
+ * DECLARED STATE plus seed-as-code — deterministic, PII-free, refreshed by
8
+ * an explicit `db:template:refresh`, never a data copy (real data enters
9
+ * branch testing only through stage forks, brick 10) — and each branch
10
+ * gets its own database, minted `CREATE DATABASE … TEMPLATE`, keyed on the
11
+ * git branch. `db:sync` then evolves it with the checkout. Branch
12
+ * databases are disposable by construction: never long-lived, never
13
+ * poisoned.
14
+ *
15
+ * Names are deterministic lowercase identifiers; branches that clean to
16
+ * the same text (`feat/foo` vs `feat_foo`) get a disambiguating content
17
+ * hash. The raw branch name travels as a database COMMENT, so listing and
18
+ * pruning map databases back to branches without guessing — a database
19
+ * whose comment is gone is never pruned.
20
+ */
21
+
22
+ import { createHash } from 'node:crypto';
23
+ import { execSync } from 'node:child_process';
24
+ import type { ModelDescriptor } from '@everystack/model';
25
+ import type { QueryRunner } from './authz-contract.js';
26
+ import { escapeLiteral } from './derived-apply.js';
27
+ import { createUrlRunner } from './db-source.js';
28
+ import {
29
+ withDatabase,
30
+ createDatabase,
31
+ dropDatabase,
32
+ buildIntoDatabase,
33
+ type BuildOptions,
34
+ } from './db-build.js';
35
+
36
+ export { withDatabase } from './db-build.js';
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Naming (pure).
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const FAMILY_SUFFIX = /(_tpl|_br_.*|_eph_.*)$/;
43
+
44
+ /** The family's base name, from ANY member's URL (base, template, branch, ephemeral). */
45
+ export function baseNameFromUrl(url: string): string {
46
+ const m = url.match(/\/([^/?]+)(\?|$)/);
47
+ if (!m) throw new Error('the connection URL names no database — add the path segment (postgres://…/mydb)');
48
+ return m[1].replace(FAMILY_SUFFIX, '');
49
+ }
50
+
51
+ export function templateName(base: string): string {
52
+ return `${base}_tpl`;
53
+ }
54
+
55
+ /**
56
+ * The branch database's name: deterministic, collision-free, ≤63 chars.
57
+ * A branch that IS already a clean identifier passes through readably;
58
+ * anything cleaned (case, slashes, unicode) or truncated carries a 6-char
59
+ * content hash so `feat/foo` and `feat_foo` never collide.
60
+ */
61
+ export function branchDatabaseName(base: string, branch: string): string {
62
+ const prefix = `${base}_br_`;
63
+ const clean = branch.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
64
+ const budget = 63 - prefix.length;
65
+ if (clean === branch && clean.length > 0 && clean.length <= budget) return `${prefix}${clean}`;
66
+ const hash = createHash('sha256').update(branch).digest('hex').slice(0, 6);
67
+ const body = clean.slice(0, Math.max(0, budget - 7));
68
+ return `${prefix}${body ? `${body}_` : ''}${hash}`;
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Comments — the branch mapping and the template provenance (pure).
73
+ // ---------------------------------------------------------------------------
74
+
75
+ const BRANCH_PREFIX = 'everystack branch: ';
76
+
77
+ export function branchComment(branch: string): string {
78
+ return `${BRANCH_PREFIX}${branch}`;
79
+ }
80
+
81
+ export function parseBranchComment(comment: string | null): string | null {
82
+ if (!comment || !comment.startsWith(BRANCH_PREFIX)) return null;
83
+ return comment.slice(BRANCH_PREFIX.length);
84
+ }
85
+
86
+ export function templateComment(fingerprint: string, gitRef: string | null, builtAt: string): string {
87
+ return `everystack template fp=${fingerprint} git=${gitRef ?? 'none'} built=${builtAt}`;
88
+ }
89
+
90
+ export function parseTemplateComment(
91
+ comment: string | null,
92
+ ): { fingerprint: string; gitRef: string | null; builtAt: string } | null {
93
+ const m = comment?.match(/^everystack template fp=(\S+) git=(\S+) built=(\S+)$/);
94
+ if (!m) return null;
95
+ return { fingerprint: m[1], gitRef: m[2] === 'none' ? null : m[2], builtAt: m[3] };
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Pruning (pure).
100
+ // ---------------------------------------------------------------------------
101
+
102
+ export interface BranchDatabase {
103
+ database: string;
104
+ /** From the database comment; null when the mapping is lost — never pruned. */
105
+ branch: string | null;
106
+ }
107
+
108
+ /** Databases whose branch no longer exists — and ONLY those. Unknown = kept. */
109
+ export function prunableBranchDatabases(
110
+ databases: BranchDatabase[],
111
+ liveBranches: string[],
112
+ ): BranchDatabase[] {
113
+ const live = new Set(liveBranches);
114
+ return databases.filter((d) => d.branch !== null && !live.has(d.branch));
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Git (thin).
119
+ // ---------------------------------------------------------------------------
120
+
121
+ export function currentBranch(cwd?: string): string | null {
122
+ try {
123
+ const out = execSync('git rev-parse --abbrev-ref HEAD', {
124
+ cwd, stdio: ['ignore', 'pipe', 'ignore'],
125
+ }).toString().trim();
126
+ return out === 'HEAD' ? null : out; // detached HEAD names no branch
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ export function localBranches(cwd?: string): string[] {
133
+ try {
134
+ return execSync('git for-each-ref --format=%(refname:short) refs/heads', {
135
+ cwd, stdio: ['ignore', 'pipe', 'ignore'],
136
+ }).toString().trim().split('\n').filter(Boolean);
137
+ } catch {
138
+ return [];
139
+ }
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Orchestration.
144
+ // ---------------------------------------------------------------------------
145
+
146
+ async function withAdmin<T>(adminUrl: string, fn: (runner: QueryRunner) => Promise<T>): Promise<T> {
147
+ const { runner, end } = await createUrlRunner(adminUrl);
148
+ try {
149
+ return await fn(runner);
150
+ } finally {
151
+ await end?.();
152
+ }
153
+ }
154
+
155
+ async function databaseExists(runner: QueryRunner, name: string): Promise<boolean> {
156
+ const rows = await runner(`SELECT 1 FROM pg_database WHERE datname = ${escapeLiteral(name)}`);
157
+ return rows.length > 0;
158
+ }
159
+
160
+ async function setComment(runner: QueryRunner, database: string, comment: string): Promise<void> {
161
+ await runner(`COMMENT ON DATABASE "${database}" IS ${escapeLiteral(comment)}`);
162
+ }
163
+
164
+ export interface TemplateRefreshOptions extends BuildOptions {
165
+ /** Runs against the freshly built template (the app's own seed, injected by the shell). */
166
+ seed?: ((templateUrl: string) => Promise<void>) | null;
167
+ /** Injectable for tests. Default: now, ISO. */
168
+ builtAt?: string;
169
+ }
170
+
171
+ export interface TemplateRefreshResult {
172
+ database: string;
173
+ url: string;
174
+ fingerprint: string;
175
+ report: string[];
176
+ }
177
+
178
+ /**
179
+ * (Re)build the dev template from the declared state + seed. All or
180
+ * nothing: a template that fails to build (or seed) is DROPPED — a broken
181
+ * template silently minting broken branch databases is the one outcome
182
+ * this must never produce.
183
+ */
184
+ export async function executeTemplateRefresh(
185
+ adminUrl: string,
186
+ models: ModelDescriptor[],
187
+ options: TemplateRefreshOptions = {},
188
+ ): Promise<TemplateRefreshResult> {
189
+ const base = baseNameFromUrl(adminUrl);
190
+ const template = templateName(base);
191
+ await dropDatabase(adminUrl, template);
192
+ await createDatabase(adminUrl, template);
193
+ const url = withDatabase(adminUrl, template);
194
+ try {
195
+ const built = await buildIntoDatabase(url, models, options);
196
+ if (!(built.converged && built.fingerprintMatch)) {
197
+ throw new Error(`the template did not land on the declared state:\n${built.report.join('\n')}`);
198
+ }
199
+ if (options.seed) await options.seed(url);
200
+ await withAdmin(adminUrl, (runner) =>
201
+ setComment(runner, template, templateComment(
202
+ built.fingerprint,
203
+ options.gitRef ?? null,
204
+ options.builtAt ?? new Date().toISOString(),
205
+ )),
206
+ );
207
+ return { database: template, url, fingerprint: built.fingerprint, report: built.report };
208
+ } catch (err) {
209
+ await dropDatabase(adminUrl, template).catch(() => {});
210
+ throw err;
211
+ }
212
+ }
213
+
214
+ export type EnsureBranchResult =
215
+ | { status: 'created' | 'exists'; database: string; url: string; template: string }
216
+ | { status: 'no-template'; database: string; url: string; template: string };
217
+
218
+ /** Mint (or find) the current branch's database from the template. */
219
+ export async function ensureBranchDatabase(adminUrl: string, branch: string): Promise<EnsureBranchResult> {
220
+ const base = baseNameFromUrl(adminUrl);
221
+ const template = templateName(base);
222
+ const database = branchDatabaseName(base, branch);
223
+ const url = withDatabase(adminUrl, database);
224
+
225
+ const exists = await withAdmin(adminUrl, (runner) => databaseExists(runner, database));
226
+ if (exists) return { status: 'exists', database, url, template };
227
+
228
+ const templateExists = await withAdmin(adminUrl, (runner) => databaseExists(runner, template));
229
+ if (!templateExists) return { status: 'no-template', database, url, template };
230
+
231
+ await createDatabase(adminUrl, database, template);
232
+ await withAdmin(adminUrl, (runner) => setComment(runner, database, branchComment(branch)));
233
+ return { status: 'created', database, url, template };
234
+ }
235
+
236
+ /** Every branch database in the family, with its branch (from the comment). */
237
+ export async function listBranchDatabases(adminUrl: string): Promise<BranchDatabase[]> {
238
+ const base = baseNameFromUrl(adminUrl);
239
+ return withAdmin(adminUrl, async (runner) => {
240
+ const rows = await runner(
241
+ `SELECT datname, shobj_description(oid, 'pg_database') AS comment
242
+ FROM pg_database WHERE datname LIKE ${escapeLiteral(`${base}\\_br\\_%`)} ORDER BY datname`,
243
+ );
244
+ return rows.map((r: any) => ({ database: r.datname, branch: parseBranchComment(r.comment) }));
245
+ });
246
+ }
247
+
248
+ /** The template's provenance, or null when no template exists. */
249
+ export async function readTemplateProvenance(
250
+ adminUrl: string,
251
+ ): Promise<{ fingerprint: string; gitRef: string | null; builtAt: string } | null> {
252
+ const base = baseNameFromUrl(adminUrl);
253
+ return withAdmin(adminUrl, async (runner) => {
254
+ const rows = await runner(
255
+ `SELECT shobj_description(oid, 'pg_database') AS comment
256
+ FROM pg_database WHERE datname = ${escapeLiteral(templateName(base))}`,
257
+ );
258
+ if (rows.length === 0) return null;
259
+ return parseTemplateComment(rows[0].comment);
260
+ });
261
+ }
262
+
263
+ /** Drop a branch database — refuses anything outside the `_br_` family. */
264
+ export async function dropBranchDatabase(adminUrl: string, database: string): Promise<void> {
265
+ if (!/_br_/.test(database)) {
266
+ throw new Error(`${database} is not a branch database — this path only drops the _br_ family.`);
267
+ }
268
+ await dropDatabase(adminUrl, database);
269
+ }
@@ -27,11 +27,14 @@ import { introspectSchema, type SchemaSnapshot } from '../schema-introspect.js';
27
27
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
28
28
  import { fingerprintLive } from '../schema-fingerprint.js';
29
29
  import { applyGeneratedStatements, currentGitRef } from '../state-apply.js';
30
- import { renderSchemaLogFingerprintUpdate } from '../derived-apply.js';
30
+ import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from '../derived-apply.js';
31
31
  import { checkPlanPrecondition, planHash, PLAN_VERSION, type EdgePlan } from '../edge-plan.js';
32
32
  import { verifyDescent, type LiveState } from '../git-descent.js';
33
+ import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity } from '../apply-authority.js';
33
34
  import { resolveModelsPath } from '../models-path.js';
34
35
  import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
36
+ import { resolveConfig, opsFunction } from '../config.js';
37
+ import { invokeAction } from '../aws.js';
35
38
  import { step, success, fail, info, warn } from '../output.js';
36
39
 
37
40
  // ---------------------------------------------------------------------------
@@ -54,17 +57,63 @@ export interface ApplyPlanOptions {
54
57
  contract: AuthzContract;
55
58
  fingerprint: string;
56
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>;
57
75
  /** Injectable clock for tests. */
58
76
  now?: () => number;
59
77
  }
60
78
 
61
79
  export interface ApplyPlanResult {
62
- status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'verify-failed';
80
+ status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'authority-refused' | 'verify-failed';
63
81
  liveFingerprint: string;
64
82
  reason?: string;
65
83
  logId?: number;
66
84
  }
67
85
 
86
+ /**
87
+ * Decision 7, made whole: every database records every refusal, not just
88
+ * every apply. Best-effort by construction — a memoir INSERT that cannot be
89
+ * written must never mask the refusal itself (a target that rejects the
90
+ * bookkeeping write would have failed the apply's ENSURE identically).
91
+ */
92
+ async function recordRefusal(
93
+ runner: QueryRunner,
94
+ plan: EdgePlan,
95
+ liveFingerprint: string,
96
+ gate: string,
97
+ reason: string,
98
+ options: ApplyPlanOptions,
99
+ ): Promise<void> {
100
+ try {
101
+ await runner(ENSURE_RECONCILER_SQL.join(';\n'));
102
+ await runner(renderSchemaLogInsert({
103
+ kind: 'state refusal',
104
+ outcome: 'refused',
105
+ actor: options.actor ?? null,
106
+ gitRef: options.gitRef ?? plan.gitRef,
107
+ planRef: planHash(plan),
108
+ fromFingerprint: liveFingerprint,
109
+ toFingerprint: plan.toFingerprint,
110
+ sql: `-- REFUSED (${gate}): ${reason}\n${plan.statements.join('\n;\n')}`,
111
+ }));
112
+ } catch {
113
+ // Swallowed deliberately — see the docblock.
114
+ }
115
+ }
116
+
68
117
  /** Verify → apply → verify. Pure orchestration over an injected QueryRunner. */
69
118
  export async function executeApplyPlan(
70
119
  runner: QueryRunner,
@@ -80,16 +129,30 @@ export async function executeApplyPlan(
80
129
  }
81
130
  const pre = checkPlanPrecondition(plan, live);
82
131
  if (!pre.ok) {
132
+ await recordRefusal(runner, plan, live, 'concurrency lock', pre.reason, options);
83
133
  return { status: 'refused', liveFingerprint: live, reason: pre.reason };
84
134
  }
85
135
 
86
136
  if (options.verifyDescent) {
87
137
  const descent = await options.verifyDescent({ snapshot: before, contract: beforeAuthz, fingerprint: live });
88
138
  if (!descent.ok) {
139
+ await recordRefusal(runner, plan, live, 'fast-forward rule', descent.reason, options);
89
140
  return { status: 'descent-refused', liveFingerprint: live, reason: descent.reason };
90
141
  }
91
142
  }
92
143
 
144
+ if (plan.destructive > 0 && options.verifyAuthority) {
145
+ const authority = await options.verifyAuthority();
146
+ if (!authority.ok) {
147
+ await recordRefusal(runner, plan, live, 'destructive authority', authority.reason, options);
148
+ return { status: 'authority-refused', liveFingerprint: live, reason: authority.reason };
149
+ }
150
+ }
151
+
152
+ if (plan.destructive > 0 && options.beforeDestructive) {
153
+ await options.beforeDestructive();
154
+ }
155
+
93
156
  const result = await applyGeneratedStatements(runner, plan.statements, {
94
157
  actor: options.actor,
95
158
  gitRef: options.gitRef ?? plan.gitRef,
@@ -170,6 +233,52 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
170
233
  }
171
234
  }
172
235
 
236
+ // Losing data should be hard (decision 11): a destructive plan applies
237
+ // only confirmed (--confirm, always), snapshotted (automatic with --stage,
238
+ // named over a bare connection), and — when the stage declares an approver
239
+ // set — identity-approved (decision 12).
240
+ const isDestructive = plan.destructive > 0;
241
+ let verifyAuthority: (() => Promise<{ ok: true } | { ok: false; reason: string }>) | undefined;
242
+ let beforeDestructive: (() => Promise<void>) | undefined;
243
+ if (isDestructive) {
244
+ const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
245
+ if (flags.confirm !== 'true') {
246
+ fail(`This plan is DESTRUCTIVE — ${plan.destructive} statement(s) lose data (${shape}). Explicit confirmation is required, always: re-run with --confirm.`);
247
+ process.exit(1);
248
+ }
249
+ if (flags.stage) {
250
+ const config = await resolveConfig(flags.stage);
251
+ const { parseAppName } = await import('../discover.js');
252
+ const appName = await parseAppName();
253
+ verifyAuthority = async () => {
254
+ step(`Destructive authority: checking the approver set for stage ${flags.stage}...`);
255
+ const approvers = await readApproverParam(config.region, appName, flags.stage);
256
+ const identity = approvers === null ? null : await resolveCallerIdentity(config.region);
257
+ const verdict = checkDestructiveAuthority({ approvers, identity });
258
+ if (!verdict.ok) return verdict;
259
+ if (verdict.reason === 'approved') {
260
+ info(`authority: ${identity} is a declared approver.`);
261
+ } else {
262
+ 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);
263
+ }
264
+ return { ok: true };
265
+ };
266
+ beforeDestructive = async () => {
267
+ step('Destructive apply — taking a snapshot first (db:backup)...');
268
+ const result: any = await invokeAction(config.region, opsFunction(config), 'db:backup', { stage: flags.stage });
269
+ if (result?.error) {
270
+ throw new Error(`auto-snapshot failed, so the destructive plan was NOT applied: ${result.error}`);
271
+ }
272
+ info(`snapshot on record: ${result?.id ?? 'backup complete'} — restore with db:restore --from ${result?.id ?? '<id>'} --confirm.`);
273
+ };
274
+ } else if (!flags['snapshot-ref']) {
275
+ fail(`This plan is DESTRUCTIVE (${shape}) and the target is a bare connection — the apply cannot take the snapshot itself. Take one (db:snapshot / db:backup / pg_dump) and name it: --snapshot-ref <ref>. Or pass --stage and the apply snapshots automatically, with the stage's approver set enforced.`);
276
+ process.exit(1);
277
+ } else {
278
+ warn(`DESTRUCTIVE apply over a bare connection — approver gating unavailable (no --stage), proceeding on ceremony: --confirm + snapshot ${flags['snapshot-ref']}.`);
279
+ }
280
+ }
281
+
173
282
  step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
174
283
  const { runner, end } = await createUrlRunner(dbSource.url);
175
284
 
@@ -182,6 +291,8 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
182
291
  const result = await executeApplyPlan(runner, plan, {
183
292
  actor: process.env.USER ?? null,
184
293
  gitRef: currentGitRef() ?? plan.gitRef,
294
+ ...(verifyAuthority ? { verifyAuthority } : {}),
295
+ ...(beforeDestructive ? { beforeDestructive } : {}),
185
296
  ...(forceDescent === undefined ? {
186
297
  verifyDescent: async (live: LiveState & { fingerprint: string }) => {
187
298
  step('Descent: searching git for the commit that declares the target\'s state...');
@@ -216,6 +327,10 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
216
327
  fail(`REFUSED (fast-forward rule): ${result.reason}`);
217
328
  process.exit(1);
218
329
  break;
330
+ case 'authority-refused':
331
+ fail(`REFUSED (destructive authority): ${result.reason}`);
332
+ process.exit(1);
333
+ break;
219
334
  case 'verify-failed':
220
335
  fail(`Verify FAILED: ${result.reason}`);
221
336
  process.exit(1);
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `everystack db:approvers` — declare who can destroy (brick 9, decision 12).
3
+ *
4
+ * db:approvers --stage <name> # show the declared set
5
+ * db:approvers --stage <name> --set "cto,arn:..." # declare / replace
6
+ * db:approvers --stage <name> --set '' # declare EMPTY: destructive applies disabled
7
+ * db:approvers --stage <name> --remove # undeclare: back to ceremony-only
8
+ *
9
+ * The set lives stage-side (an SSM parameter in the stage's namespace) so a
10
+ * checkout edit can't reach it; lock it down further with IAM by denying
11
+ * `ssm:PutParameter` on `/everystack/<app>/<stage>/db-approvers` to
12
+ * non-admins — the same posture as the rest of the stage's parameters.
13
+ * Entries are exact ARNs or bare names matched against the caller's ARN
14
+ * path segments (`ty` matches `user/ty` and an assumed-role session `ty`).
15
+ */
16
+
17
+ import { resolveConfig } from '../config.js';
18
+ import {
19
+ approverParamName,
20
+ parseApproverValue,
21
+ readApproverParam,
22
+ writeApproverParam,
23
+ removeApproverParam,
24
+ } from '../apply-authority.js';
25
+ import { step, success, fail, info, warn } from '../output.js';
26
+
27
+ export async function dbApproversCommand(flags: Record<string, string>): Promise<void> {
28
+ if (!flags.stage) {
29
+ fail('db:approvers needs --stage <name> — the approver set is per-stage configuration.');
30
+ process.exit(1);
31
+ }
32
+
33
+ step('Resolving deployed config...');
34
+ const config = await resolveConfig(flags.stage);
35
+ const { parseAppName } = await import('../discover.js');
36
+ const appName = await parseAppName();
37
+ const paramName = approverParamName(appName, flags.stage);
38
+
39
+ if (flags.remove === 'true') {
40
+ await removeApproverParam(config.region, appName, flags.stage);
41
+ success(`Removed ${paramName} — the stage is back to the undeclared posture (destructive applies gated by ceremony only).`);
42
+ return;
43
+ }
44
+
45
+ if (flags.set !== undefined) {
46
+ if (flags.set === 'true') {
47
+ fail("--set needs a value: a comma-separated list of ARNs or names (--set '' declares an EMPTY set, disabling destructive applies).");
48
+ process.exit(1);
49
+ }
50
+ const approvers = parseApproverValue(flags.set);
51
+ await writeApproverParam(config.region, appName, flags.stage, approvers);
52
+ if (approvers.length === 0) {
53
+ warn(`Declared an EMPTY approver set at ${paramName} — destructive applies are now DISABLED on ${flags.stage}.`);
54
+ } else {
55
+ success(`Declared ${approvers.length} approver(s) at ${paramName}: ${approvers.join(', ')}`);
56
+ info('Destructive db:apply runs against this stage now require one of these identities (STS-verified).');
57
+ }
58
+ return;
59
+ }
60
+
61
+ const approvers = await readApproverParam(config.region, appName, flags.stage);
62
+ if (approvers === null) {
63
+ info(`No approver set declared (${paramName} absent) — destructive applies are gated by ceremony only (--confirm + snapshot).`);
64
+ info("Declare one: everystack db:approvers --set 'cto,arn:aws:iam::…:user/…' --stage " + flags.stage);
65
+ } else if (approvers.length === 0) {
66
+ warn(`The approver set is declared EMPTY — destructive applies are DISABLED on ${flags.stage}.`);
67
+ } else {
68
+ info(`Destructive-approver set for ${flags.stage} (${approvers.length}):`);
69
+ for (const a of approvers) info(` - ${a}`);
70
+ }
71
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `everystack db:backfill` — the one-shot data-job lane (brick 8).
3
+ *
4
+ * db:backfill [--database-url <url>] [--dir db/backfills] [--json]
5
+ * db:backfill --apply # run the pending jobs, in order
6
+ * db:backfill --mark-applied <file.sql> # record without running (adopt hatch)
7
+ *
8
+ * Plan first, always: applied (by content identity — renames and comment
9
+ * edits are no-ops), pending (in file-name order, with jobs-lane advisories
10
+ * for unbounded passes), and blocked (a file name that already ran here in
11
+ * a different form — one-shot jobs are immutable; the fix is named). Apply
12
+ * runs each pending job as its own transaction and records it in
13
+ * `everystack.backfill_log`; a failure rolls back alone, is recorded, and
14
+ * stops the run. Backfills NEVER run as a side effect of a schema verb —
15
+ * db:sync and db:plan only surface the pending count.
16
+ *
17
+ * Needs a direct connection (--database-url / DATABASE_URL): the lane
18
+ * reads and writes its own per-database record.
19
+ */
20
+
21
+ import type { QueryRunner } from '../authz-contract.js';
22
+ import {
23
+ planBackfills,
24
+ readBackfillLog,
25
+ executeBackfills,
26
+ markBackfillApplied,
27
+ type BackfillPlan,
28
+ } from '../backfill.js';
29
+ import { currentGitRef } from '../state-apply.js';
30
+ import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
31
+ import { readSqlDirIfPresent } from './db-sync.js';
32
+ import { step, success, fail, info, warn } from '../output.js';
33
+
34
+ const DEFAULT_DIR = 'db/backfills';
35
+
36
+ export function buildBackfillReport(plan: BackfillPlan): string[] {
37
+ const lines: string[] = [];
38
+ lines.push(`= ${plan.applied.length} applied (by content identity)`);
39
+ for (const b of plan.blocked) lines.push(`! BLOCKED ${b.file}: ${b.reason}`);
40
+ if (plan.pending.length === 0) {
41
+ lines.push('= nothing pending');
42
+ } else {
43
+ lines.push(`~ ${plan.pending.length} pending, in order:`);
44
+ for (const p of plan.pending) {
45
+ lines.push(`~ ${p.file}`);
46
+ for (const a of p.advisories) lines.push(`· advisory: ${a}`);
47
+ }
48
+ }
49
+ return lines;
50
+ }
51
+
52
+ export async function dbBackfillCommand(flags: Record<string, string>): Promise<void> {
53
+ const dir = flags.dir || DEFAULT_DIR;
54
+ const files = await readSqlDirIfPresent(dir);
55
+ if (files === null) {
56
+ info(`No ${dir}/ directory — the backfill lane is not declared. One-shot data jobs live there as *.sql files.`);
57
+ return;
58
+ }
59
+
60
+ let dbSource: DbSource;
61
+ try {
62
+ dbSource = resolveDbSource(flags);
63
+ } catch (err: any) {
64
+ fail(err.message);
65
+ process.exit(1);
66
+ }
67
+ if (dbSource.kind !== 'url') {
68
+ fail('db:backfill needs a direct connection (--database-url or DATABASE_URL) — the lane reads and writes its per-database record.');
69
+ process.exit(1);
70
+ }
71
+
72
+ step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
73
+ const { runner, end } = await createUrlRunner(dbSource.url);
74
+ try {
75
+ const opts = { actor: process.env.USER ?? null, gitRef: currentGitRef() };
76
+
77
+ if (flags['mark-applied'] !== undefined) {
78
+ const name = flags['mark-applied'];
79
+ const file = files.find((f) => f.file.endsWith(`/${name}`) || f.file === name);
80
+ if (name === 'true' || !file) {
81
+ fail(`--mark-applied needs a file from ${dir}/ (got ${name === 'true' ? 'nothing' : name}).`);
82
+ process.exit(1);
83
+ }
84
+ const identity = await markBackfillApplied(runner as QueryRunner, file, opts);
85
+ success(`Recorded ${file.file} as applied WITHOUT running it (identity ${identity.slice(0, 12)}).`);
86
+ return;
87
+ }
88
+
89
+ const plan = planBackfills(files, await readBackfillLog(runner));
90
+ for (const line of buildBackfillReport(plan)) info(line);
91
+
92
+ if (flags.json === 'true') {
93
+ console.log(JSON.stringify({
94
+ applied: plan.applied,
95
+ pending: plan.pending.map(({ sql: _sql, ...rest }) => rest),
96
+ blocked: plan.blocked,
97
+ }, null, 2));
98
+ }
99
+
100
+ if (flags.apply !== 'true') {
101
+ if (plan.pending.length > 0) info(`Run them: everystack db:backfill --apply`);
102
+ if (plan.blocked.length > 0) process.exit(1);
103
+ return;
104
+ }
105
+
106
+ if (plan.pending.length === 0) {
107
+ success('Nothing to run.');
108
+ if (plan.blocked.length > 0) process.exit(1);
109
+ return;
110
+ }
111
+
112
+ step(`Running ${plan.pending.length} backfill(s), each as its own transaction...`);
113
+ const result = await executeBackfills(runner, plan, opts);
114
+ for (const ran of result.ran) info(`~ ${ran.file} applied in ${ran.durationMs}ms`);
115
+ if (result.failed) {
116
+ fail(`${result.failed.file} FAILED and rolled back (recorded; it stays pending for the retry): ${result.failed.error}`);
117
+ process.exit(1);
118
+ }
119
+ if (plan.blocked.length > 0) {
120
+ fail('Pending jobs ran, but blocked file(s) remain above — fix them (new file name, or --mark-applied).');
121
+ process.exit(1);
122
+ }
123
+ success(`${result.ran.length} backfill(s) applied and recorded in everystack.backfill_log.`);
124
+ } finally {
125
+ await end?.();
126
+ }
127
+ }