@everystack/cli 0.3.23 → 0.3.27

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 CHANGED
@@ -223,6 +223,10 @@ everystack db:diff # the state edge between two declared st
223
223
  everystack db:plan | db:apply # mint a reviewable plan against a target, apply it fingerprint-verified at both ends + the fast-forward rule (checkout must descend from the commit declaring the target's state); destructive plans: --confirm + snapshot + approver set when declared
224
224
  everystack db:check # the CI gate per PR: merged declared state composes + artifacts match regeneration (+ ephemeral from-scratch build w/ a scratch PG)
225
225
  everystack db:approvers # declare who can DESTROY: the stage's destructive-approver set (SSM, STS-verified at apply)
226
+ everystack db:backfill # one-shot data jobs in db/backfills/*.sql — content-hash identity, own per-DB record, never a schema side effect
227
+ everystack db:template:refresh # (re)build the dev template from the declared state + your db:seed script — never a data copy
228
+ everystack db:branch # per-branch dev DB minted from the template, keyed on the git branch (--list / --prune --confirm)
229
+ everystack db:fork # fork a deployed stage's DB into a feature stage (backup → presigned hand-off → restore); production is never a target
226
230
 
227
231
  # Security
228
232
  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.23",
3
+ "version": "0.3.27",
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>",
@@ -0,0 +1,231 @@
1
+ /**
2
+ * backfill — one-shot data jobs in their own lane (brick 8 of the
3
+ * migration-authority design, decision 15).
4
+ *
5
+ * Schema is fingerprint-governed; DATA has no live authority to fingerprint,
6
+ * so the backfill lane is the one place a per-database ledger is correct:
7
+ * `everystack.backfill_log` records what ran here, and the runner READS it —
8
+ * unlike `schema_log`, which stays a write-only memoir. The distrust that
9
+ * killed the migration tape doesn't transfer: nothing is numbered, nothing
10
+ * is watermarked, and identity is the NORMALIZED CONTENT HASH (the same
11
+ * comment-insensitive, dollar-quote-aware lexing as db/sql) — renaming a
12
+ * file or editing a comment is a no-op, never a re-run.
13
+ *
14
+ * One-shot jobs are IMMUTABLE. A file whose name already ran on this
15
+ * database in a different form is BLOCKED, loudly, with the fix named: a
16
+ * genuinely new job gets a new file; a cosmetic-but-not-comment edit gets
17
+ * `--mark-applied`. Never silently re-run, never silently skipped. A prior
18
+ * FAILED run is a retry, not an edit — it stays pending.
19
+ *
20
+ * Backfills never run as a side effect of a schema apply (expand →
21
+ * backfill → contract is a sequence of deliberate acts); the schema verbs
22
+ * only SURFACE pending backfills as notices. Long-running smells get an
23
+ * advisory naming the jobs lane — advisory only, taste is not enforced.
24
+ */
25
+
26
+ import { createHash } from 'node:crypto';
27
+ import { lex, normalizeSql, splitSqlStatements, type SourceFile } from './derived-source.js';
28
+ import { escapeLiteral } from './derived-apply.js';
29
+ import type { QueryRunner } from './authz-contract.js';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Identity + advisories (pure).
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /** The job's identity: sha256 over the comment-insensitive canonical form. */
36
+ export function backfillIdentity(sql: string): string {
37
+ return createHash('sha256').update(normalizeSql(sql)).digest('hex');
38
+ }
39
+
40
+ /**
41
+ * The statement with comments dropped and quoted content blanked — a shape
42
+ * regexes can interrogate without being fooled by `'WHERE it hurts'`.
43
+ */
44
+ function sqlSkeleton(sql: string): string {
45
+ let out = '';
46
+ lex(sql, (ch, state) => {
47
+ if (state === 'line-comment' || state === 'block-comment') return;
48
+ if (state === 'single' || state === 'dollar') return; // quoted content blanked
49
+ out += ch;
50
+ });
51
+ return out;
52
+ }
53
+
54
+ /** Advisory only — an unbounded data pass belongs in the jobs lane, but taste never blocks. */
55
+ export function backfillAdvisories(sql: string): string[] {
56
+ const advisories: string[] = [];
57
+ for (const statement of splitSqlStatements(sql)) {
58
+ const skeleton = sqlSkeleton(statement);
59
+ const verb = skeleton.match(/^\s*(UPDATE|DELETE)\b/i)?.[1];
60
+ if (verb && !/\bWHERE\b/i.test(skeleton)) {
61
+ advisories.push(
62
+ `${verb.toUpperCase()} without WHERE — an unbounded pass over the table. Fine for small tables; for big ones, batch it or run it as a job (@everystack/jobs).`,
63
+ );
64
+ }
65
+ }
66
+ return advisories;
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Planning (pure).
71
+ // ---------------------------------------------------------------------------
72
+
73
+ export interface BackfillRecord {
74
+ name: string;
75
+ identity: string;
76
+ outcome: string; // 'applied' | 'marked-applied' | 'failed'
77
+ }
78
+
79
+ export interface BackfillPlan {
80
+ applied: { file: string; identity: string }[];
81
+ /** To run, in file-name order. */
82
+ pending: { file: string; identity: string; sql: string; advisories: string[] }[];
83
+ /** Edited-after-run — refused with the fix named. */
84
+ blocked: { file: string; identity: string; reason: string }[];
85
+ }
86
+
87
+ const baseName = (file: string): string => file.split('/').pop() ?? file;
88
+ const counts = (outcome: string): boolean => outcome === 'applied' || outcome === 'marked-applied';
89
+
90
+ export function planBackfills(files: SourceFile[], log: BackfillRecord[]): BackfillPlan {
91
+ const appliedIdentities = new Set(log.filter((r) => counts(r.outcome)).map((r) => r.identity));
92
+ const appliedByName = new Map<string, string>(); // name → identity that ran under it
93
+ for (const r of log) {
94
+ if (counts(r.outcome)) appliedByName.set(r.name, r.identity);
95
+ }
96
+
97
+ const plan: BackfillPlan = { applied: [], pending: [], blocked: [] };
98
+ for (const file of [...files].sort((a, b) => baseName(a.file).localeCompare(baseName(b.file)))) {
99
+ const identity = backfillIdentity(file.sql);
100
+ if (appliedIdentities.has(identity)) {
101
+ plan.applied.push({ file: file.file, identity });
102
+ continue;
103
+ }
104
+ const ranAs = appliedByName.get(baseName(file.file));
105
+ if (ranAs !== undefined && ranAs !== identity) {
106
+ plan.blocked.push({
107
+ file: file.file,
108
+ identity,
109
+ reason:
110
+ `${baseName(file.file)} already ran on this database in a different form — one-shot jobs are immutable. ` +
111
+ 'A genuinely new job gets a NEW file name; if this edit changed nothing the job does, record it: --mark-applied.',
112
+ });
113
+ continue;
114
+ }
115
+ plan.pending.push({ file: file.file, identity, sql: file.sql, advisories: backfillAdvisories(file.sql) });
116
+ }
117
+ return plan;
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // The record + the runner.
122
+ // ---------------------------------------------------------------------------
123
+
124
+ export const ENSURE_BACKFILL_SQL: string[] = [
125
+ 'CREATE SCHEMA IF NOT EXISTS everystack',
126
+ `CREATE TABLE IF NOT EXISTS everystack.backfill_log (
127
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
128
+ applied_at timestamptz NOT NULL DEFAULT now(),
129
+ identity text NOT NULL,
130
+ name text NOT NULL,
131
+ actor text,
132
+ git_ref text,
133
+ sql text NOT NULL,
134
+ duration_ms integer,
135
+ outcome text NOT NULL
136
+ )`,
137
+ ];
138
+
139
+ export interface BackfillLogEntry {
140
+ identity: string;
141
+ name: string;
142
+ sql: string;
143
+ outcome: string;
144
+ actor?: string | null;
145
+ gitRef?: string | null;
146
+ durationMs?: number;
147
+ }
148
+
149
+ export function renderBackfillLogInsert(entry: BackfillLogEntry): string {
150
+ const opt = (v: string | null | undefined): string => (v == null ? 'NULL' : escapeLiteral(v));
151
+ const duration = entry.durationMs === undefined ? 'NULL' : String(Math.round(entry.durationMs));
152
+ return `INSERT INTO everystack.backfill_log (identity, name, actor, git_ref, sql, duration_ms, outcome)
153
+ VALUES (${escapeLiteral(entry.identity)}, ${escapeLiteral(entry.name)}, ${opt(entry.actor)}, ${opt(entry.gitRef)}, ${escapeLiteral(entry.sql)}, ${duration}, ${escapeLiteral(entry.outcome)})`;
154
+ }
155
+
156
+ /** The applied record for this database. Empty when the lane has never run here. */
157
+ export async function readBackfillLog(runner: QueryRunner): Promise<BackfillRecord[]> {
158
+ const rows = await runner(`
159
+ SELECT name, identity, outcome FROM everystack.backfill_log ORDER BY id
160
+ `).catch((err: any) => {
161
+ if (/relation .*backfill_log.* does not exist|schema .* does not exist/i.test(err.message ?? '')) return [];
162
+ throw err;
163
+ });
164
+ return rows as BackfillRecord[];
165
+ }
166
+
167
+ export interface BackfillRunOptions {
168
+ actor?: string | null;
169
+ gitRef?: string | null;
170
+ /** Injectable clock for tests. */
171
+ now?: () => number;
172
+ }
173
+
174
+ export interface BackfillRunResult {
175
+ ran: { file: string; identity: string; durationMs: number }[];
176
+ /** The first failure, if any — the run STOPS there (later files may depend on it). */
177
+ failed?: { file: string; identity: string; error: string };
178
+ }
179
+
180
+ /**
181
+ * Run the pending jobs in order, each as its own single batch (one implicit
182
+ * transaction per job — a failed job rolls back alone and is recorded as
183
+ * `failed`, staying pending for the retry). Blocked files never execute.
184
+ */
185
+ export async function executeBackfills(
186
+ runner: QueryRunner,
187
+ plan: BackfillPlan,
188
+ options: BackfillRunOptions = {},
189
+ ): Promise<BackfillRunResult> {
190
+ const now = options.now ?? Date.now;
191
+ const result: BackfillRunResult = { ran: [] };
192
+ if (plan.pending.length === 0) return result;
193
+
194
+ await runner(ENSURE_BACKFILL_SQL.join(';\n'));
195
+ for (const job of plan.pending) {
196
+ const name = baseName(job.file);
197
+ const started = now();
198
+ try {
199
+ await runner(job.sql);
200
+ const durationMs = now() - started;
201
+ await runner(renderBackfillLogInsert({
202
+ identity: job.identity, name, sql: job.sql, outcome: 'applied',
203
+ actor: options.actor, gitRef: options.gitRef, durationMs,
204
+ }));
205
+ result.ran.push({ file: job.file, identity: job.identity, durationMs });
206
+ } catch (err: any) {
207
+ await runner(renderBackfillLogInsert({
208
+ identity: job.identity, name, sql: `-- FAILED: ${err.message}\n${job.sql}`, outcome: 'failed',
209
+ actor: options.actor, gitRef: options.gitRef, durationMs: now() - started,
210
+ }));
211
+ result.failed = { file: job.file, identity: job.identity, error: err.message };
212
+ return result;
213
+ }
214
+ }
215
+ return result;
216
+ }
217
+
218
+ /** The adopt hatch: record a job as applied WITHOUT running it (mirrors --baseline). */
219
+ export async function markBackfillApplied(
220
+ runner: QueryRunner,
221
+ file: SourceFile,
222
+ options: BackfillRunOptions = {},
223
+ ): Promise<string> {
224
+ const identity = backfillIdentity(file.sql);
225
+ await runner(ENSURE_BACKFILL_SQL.join(';\n'));
226
+ await runner(renderBackfillLogInsert({
227
+ identity, name: baseName(file.file), sql: file.sql, outcome: 'marked-applied',
228
+ actor: options.actor, gitRef: options.gitRef,
229
+ }));
230
+ return identity;
231
+ }
@@ -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
+ }
@@ -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
+ }