@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 +4 -0
- package/package.json +1 -1
- package/src/cli/backfill.ts +231 -0
- package/src/cli/branch-db.ts +269 -0
- package/src/cli/commands/db-backfill.ts +127 -0
- package/src/cli/commands/db-branch.ts +220 -0
- package/src/cli/commands/db-check.ts +16 -58
- package/src/cli/commands/db-fork.ts +139 -0
- package/src/cli/commands/db-plan.ts +21 -0
- package/src/cli/commands/db-sync.ts +21 -0
- package/src/cli/commands/runbook.ts +5 -4
- package/src/cli/db-build.ts +161 -0
- package/src/cli/derived-source.ts +3 -2
- package/src/cli/index.ts +19 -0
- package/src/cli/model-api.ts +7 -0
- package/src/cli/runbook/sections.ts +23 -10
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
|
@@ -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
|
+
}
|