@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.
- package/README.md +5 -1
- package/package.json +9 -4
- package/src/cli/apply-authority.ts +150 -0
- package/src/cli/backfill.ts +231 -0
- package/src/cli/branch-db.ts +269 -0
- package/src/cli/commands/db-apply.ts +117 -2
- package/src/cli/commands/db-approvers.ts +71 -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-plan.ts +21 -0
- package/src/cli/commands/db-sync.ts +26 -0
- package/src/cli/db-build.ts +161 -0
- package/src/cli/derived-source.ts +3 -2
- package/src/cli/edge-plan.ts +35 -8
- package/src/cli/index.ts +20 -1
- package/src/cli/model-api.ts +7 -0
- package/src/cli/schema-source.ts +5 -0
- package/src/cli/state-apply.ts +30 -0
package/README.md
CHANGED
|
@@ -220,8 +220,12 @@ everystack db:reconcile # deploy the derived layer (functions/vi
|
|
|
220
220
|
everystack db:fingerprint # content-address the live base schema vs the Models — MATCH/MISMATCH
|
|
221
221
|
everystack db:sync # make the database match your checkout — state + compute, one verb (dev DBs)
|
|
222
222
|
everystack db:diff # the state edge between two declared states — no DB, CI-pure; reverse = computed rollback
|
|
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)
|
|
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
|
+
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)
|
|
225
229
|
|
|
226
230
|
# Security
|
|
227
231
|
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.
|
|
3
|
+
"version": "0.3.25",
|
|
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>",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"structured-headers": "1.0.1",
|
|
79
79
|
"tsx": "4.21.0",
|
|
80
80
|
"typescript": "5.9.3",
|
|
81
|
-
"@everystack/model": "0.3.
|
|
81
|
+
"@everystack/model": "0.3.6"
|
|
82
82
|
},
|
|
83
83
|
"peerDependencies": {
|
|
84
84
|
"@everystack/server": ">=0.1.0",
|
|
@@ -93,7 +93,8 @@
|
|
|
93
93
|
"expo-updates": "55.0.21",
|
|
94
94
|
"postgres": "3.4.9",
|
|
95
95
|
"react": "19.2.0",
|
|
96
|
-
"react-native": "0.83.6"
|
|
96
|
+
"react-native": "0.83.6",
|
|
97
|
+
"@aws-sdk/client-sts": "3.1053.0"
|
|
97
98
|
},
|
|
98
99
|
"peerDependenciesMeta": {
|
|
99
100
|
"postgres": {
|
|
@@ -131,6 +132,9 @@
|
|
|
131
132
|
},
|
|
132
133
|
"@aws-sdk/s3-request-presigner": {
|
|
133
134
|
"optional": true
|
|
135
|
+
},
|
|
136
|
+
"@aws-sdk/client-sts": {
|
|
137
|
+
"optional": true
|
|
134
138
|
}
|
|
135
139
|
},
|
|
136
140
|
"devDependencies": {
|
|
@@ -148,7 +152,8 @@
|
|
|
148
152
|
"drizzle-orm": "0.41.0",
|
|
149
153
|
"jest": "29.7.0",
|
|
150
154
|
"react": "19.2.0",
|
|
151
|
-
"ts-jest": "29.4.9"
|
|
155
|
+
"ts-jest": "29.4.9",
|
|
156
|
+
"@aws-sdk/client-sts": "3.1053.0"
|
|
152
157
|
},
|
|
153
158
|
"scripts": {
|
|
154
159
|
"test": "jest",
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* apply-authority — destructive applies are role-gated to a declared
|
|
3
|
+
* approver set, enforced against the caller's REAL identity (brick 9 of the
|
|
4
|
+
* migration-authority design, decisions 11 + 12).
|
|
5
|
+
*
|
|
6
|
+
* "CTO drops tables; everyone else creates functions and views." The
|
|
7
|
+
* approver set lives STAGE-SIDE — an SSM parameter in the stage's namespace,
|
|
8
|
+
* admin-writable only — never in the repo, where a checkout edit would
|
|
9
|
+
* defeat it (a repo-declared list is a convention wearing a gate's clothes).
|
|
10
|
+
* The chokepoint resolves the caller's real identity via STS — the same
|
|
11
|
+
* pattern as the `db:psql` break-glass, where the IAM read IS the gate.
|
|
12
|
+
*
|
|
13
|
+
* The gate engages on DECLARATION: a stage that never declared approvers
|
|
14
|
+
* keeps the ceremony (explicit confirmation + snapshot, always) but is not
|
|
15
|
+
* identity-gated — the migration-friendly posture, stated honestly. A
|
|
16
|
+
* declared-but-EMPTY set means "no one": destructive applies are disabled
|
|
17
|
+
* on that stage until the set says otherwise.
|
|
18
|
+
*
|
|
19
|
+
* The refusal is the two-person flow's handoff: it names who CAN approve,
|
|
20
|
+
* and `plan_ref` already pins the exact reviewed artifact — a dev mints the
|
|
21
|
+
* destructive plan, an approver applies that same file.
|
|
22
|
+
*
|
|
23
|
+
* The decision core is pure; the AWS edge (SSM + STS) is thin and injected
|
|
24
|
+
* by the command shell.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The SSM parameter holding the approver set — a JSON array of entries. */
|
|
28
|
+
export function approverParamName(appName: string, stage: string): string {
|
|
29
|
+
return `/everystack/${appName}/${stage}/db-approvers`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Does an approver entry match a caller ARN? Exact ARN match, or a match on
|
|
34
|
+
* any path segment after the first slash — so the set can hold plain names:
|
|
35
|
+
* `ty` matches `arn:aws:iam::123:user/ty` and the session name in
|
|
36
|
+
* `arn:aws:sts::123:assumed-role/Admin/ty`. Segment (not suffix) matching:
|
|
37
|
+
* `ty` never matches `user/notty`.
|
|
38
|
+
*/
|
|
39
|
+
export function approverMatches(entry: string, identityArn: string): boolean {
|
|
40
|
+
if (entry === identityArn) return true;
|
|
41
|
+
const segments = identityArn.split('/').slice(1);
|
|
42
|
+
return segments.includes(entry);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AuthorityInputs {
|
|
46
|
+
/** The declared approver set; null = the stage never declared one. */
|
|
47
|
+
approvers: string[] | null;
|
|
48
|
+
/** The caller's resolved identity (STS ARN); null = unresolvable. */
|
|
49
|
+
identity: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type AuthorityVerdict =
|
|
53
|
+
| { ok: true; reason: 'approved' | 'undeclared' }
|
|
54
|
+
| { ok: false; reason: string };
|
|
55
|
+
|
|
56
|
+
/** The pure decision core for a DESTRUCTIVE apply. Non-destructive plans never reach it. */
|
|
57
|
+
export function checkDestructiveAuthority({ approvers, identity }: AuthorityInputs): AuthorityVerdict {
|
|
58
|
+
if (approvers === null) return { ok: true, reason: 'undeclared' };
|
|
59
|
+
if (approvers.length === 0) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
reason:
|
|
63
|
+
'the declared approver set is EMPTY — destructive applies are disabled on this stage. ' +
|
|
64
|
+
'Declare approvers with `everystack db:approvers --set <arn-or-name,...>` (admin-writable parameter).',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (identity === null) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
reason:
|
|
71
|
+
'this stage declares a destructive-approver set, so the apply must be identity-verified — ' +
|
|
72
|
+
'and your AWS identity could not be resolved (credentials?). Fix the credentials or hand the plan to an approver.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (approvers.some((entry) => approverMatches(entry, identity))) {
|
|
76
|
+
return { ok: true, reason: 'approved' };
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
reason:
|
|
81
|
+
`you are ${identity}, and the declared approvers are: ${approvers.join(', ')}. ` +
|
|
82
|
+
'Hand the plan file to an approver — they review and apply the exact same artifact (its plan_ref pins it).',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// The thin AWS edge (lazy imports, injected by the command shell).
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/** Read the approver set from SSM. Null when the parameter does not exist. */
|
|
91
|
+
export async function readApproverParam(region: string, appName: string, stage: string): Promise<string[] | null> {
|
|
92
|
+
const { SSMClient, GetParameterCommand } = await import('@aws-sdk/client-ssm');
|
|
93
|
+
const client = new SSMClient({ region });
|
|
94
|
+
try {
|
|
95
|
+
const result = await client.send(new GetParameterCommand({ Name: approverParamName(appName, stage) }));
|
|
96
|
+
return parseApproverValue(result.Parameter?.Value ?? '');
|
|
97
|
+
} catch (err: any) {
|
|
98
|
+
if (err?.name === 'ParameterNotFound') return null;
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The stored form is a JSON array; a bare comma-separated string is accepted on read. */
|
|
104
|
+
export function parseApproverValue(value: string): string[] {
|
|
105
|
+
const trimmed = value.trim();
|
|
106
|
+
if (trimmed === '') return [];
|
|
107
|
+
if (trimmed.startsWith('[')) {
|
|
108
|
+
const parsed = JSON.parse(trimmed);
|
|
109
|
+
if (!Array.isArray(parsed) || parsed.some((e) => typeof e !== 'string')) {
|
|
110
|
+
throw new Error('db-approvers parameter must be a JSON array of strings');
|
|
111
|
+
}
|
|
112
|
+
return parsed.map((e: string) => e.trim()).filter(Boolean);
|
|
113
|
+
}
|
|
114
|
+
return trimmed.split(',').map((e) => e.trim()).filter(Boolean);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Declare (or replace) the approver set. Stored as a JSON array. */
|
|
118
|
+
export async function writeApproverParam(region: string, appName: string, stage: string, approvers: string[]): Promise<void> {
|
|
119
|
+
const { SSMClient, PutParameterCommand } = await import('@aws-sdk/client-ssm');
|
|
120
|
+
const client = new SSMClient({ region });
|
|
121
|
+
await client.send(new PutParameterCommand({
|
|
122
|
+
Name: approverParamName(appName, stage),
|
|
123
|
+
Value: JSON.stringify(approvers),
|
|
124
|
+
Type: 'String',
|
|
125
|
+
Overwrite: true,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Remove the declaration entirely — the stage returns to the undeclared (ceremony-only) posture. */
|
|
130
|
+
export async function removeApproverParam(region: string, appName: string, stage: string): Promise<void> {
|
|
131
|
+
const { SSMClient, DeleteParameterCommand } = await import('@aws-sdk/client-ssm');
|
|
132
|
+
const client = new SSMClient({ region });
|
|
133
|
+
try {
|
|
134
|
+
await client.send(new DeleteParameterCommand({ Name: approverParamName(appName, stage) }));
|
|
135
|
+
} catch (err: any) {
|
|
136
|
+
if (err?.name !== 'ParameterNotFound') throw err;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The caller's real identity — the STS ARN. Null when credentials can't answer. */
|
|
141
|
+
export async function resolveCallerIdentity(region: string): Promise<string | null> {
|
|
142
|
+
const { STSClient, GetCallerIdentityCommand } = await import('@aws-sdk/client-sts');
|
|
143
|
+
const client = new STSClient({ region });
|
|
144
|
+
try {
|
|
145
|
+
const result = await client.send(new GetCallerIdentityCommand({}));
|
|
146
|
+
return result.Arn ?? null;
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -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
|
+
}
|