@ultimat3/cli 1.2.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +761 -0
- package/README.md +42 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +134 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +219 -0
- package/src/cmd-db.ts +458 -153
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +92 -18
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +74 -10
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +14 -8
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +29 -24
- package/src/cmd-verify.ts +197 -25
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +269 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +144 -0
- package/src/db-seed.ts +294 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +108 -23
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +167 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +247 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +37 -7
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +78 -10
- package/src/error-catalog.ts +8 -18
- package/src/error-codes.ts +192 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +201 -138
- package/src/exec.ts +42 -8
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +67 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +92 -15
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +128 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +93 -2
- package/src/metrics-endpoint.ts +64 -16
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +185 -13
- package/src/shell-quote.ts +15 -0
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +20 -11
- package/src/test-workers.ts +50 -0
- package/src/ts-scan.ts +284 -15
- package/src/tsconfig-references.ts +103 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// `x db backfill`, everything except the argv: the ledger `--list` reports, the declared-minus-
|
|
2
|
+
// completed diff `--pending` reports, and the gate-plus-enqueue `<name>`/`--all` performs. A
|
|
3
|
+
// driver plus plain strings in, plain data out — the `cmd-jobs.ts` / `jobs-report.ts` split
|
|
4
|
+
// repeated, so every projection here is testable with no `ParsedArgs`, no app boot and no queue.
|
|
5
|
+
// `cmd-db.ts` is the CLI wiring and nothing else.
|
|
6
|
+
//
|
|
7
|
+
// The decisions themselves are `@ultimat3/jobs`': `gateBackfill`, `pendingBackfills` and
|
|
8
|
+
// `registeredBackfills` all live there, because the pass enforces the same rails and two copies of
|
|
9
|
+
// "may this sweep run" would be two answers. What is decided HERE is only what the CLI knows —
|
|
10
|
+
// which names were asked for, whether `--write` was passed, and what `x_migrations` says.
|
|
11
|
+
|
|
12
|
+
import type { Environment } from '@ultimat3/core';
|
|
13
|
+
import { createContext } from '@ultimat3/core';
|
|
14
|
+
import { db, isLedgerMissing, readLedger } from '@ultimat3/db';
|
|
15
|
+
import type {
|
|
16
|
+
BackfillDeclaration,
|
|
17
|
+
BackfillInput,
|
|
18
|
+
BackfillPendingReport,
|
|
19
|
+
BackfillProgress,
|
|
20
|
+
BackfillState,
|
|
21
|
+
BackfillStatus,
|
|
22
|
+
JobDriver,
|
|
23
|
+
JobHandle,
|
|
24
|
+
} from '@ultimat3/jobs';
|
|
25
|
+
import {
|
|
26
|
+
BACKFILL_STATUSES,
|
|
27
|
+
BackfillRunningError,
|
|
28
|
+
BackfillUnknownError,
|
|
29
|
+
backfillOrigin,
|
|
30
|
+
gateBackfill,
|
|
31
|
+
getBackfill,
|
|
32
|
+
inspectBackfills,
|
|
33
|
+
isBackfillStatus,
|
|
34
|
+
isPendingBackfillState,
|
|
35
|
+
pendingBackfills,
|
|
36
|
+
registeredBackfills,
|
|
37
|
+
} from '@ultimat3/jobs';
|
|
38
|
+
import { BadFlagError } from './errors';
|
|
39
|
+
import { parseLimitFlag } from './jobs-report';
|
|
40
|
+
import { msg } from './messages';
|
|
41
|
+
import type { Finding, JsonValue } from './output';
|
|
42
|
+
import { findingFrom } from './output';
|
|
43
|
+
import { renderTable } from './table';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The list and the guard are `@ultimat3/jobs`': a status the ledger can record and this flag
|
|
47
|
+
* rejects is exactly the drift a second copy here would produce.
|
|
48
|
+
*/
|
|
49
|
+
export function parseBackfillStatusFlag(value: string | undefined): BackfillStatus | undefined {
|
|
50
|
+
if (value === undefined) return undefined;
|
|
51
|
+
if (isBackfillStatus(value)) return value;
|
|
52
|
+
throw new BadFlagError({
|
|
53
|
+
flag: 'status',
|
|
54
|
+
command: 'db',
|
|
55
|
+
reason: `unknown status "${value}" (known: ${BACKFILL_STATUSES.join(', ')})`,
|
|
56
|
+
fix: 'x db backfill --list --json',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface BackfillListFilter {
|
|
61
|
+
readonly name?: string | undefined;
|
|
62
|
+
readonly status?: string | undefined;
|
|
63
|
+
readonly limit?: string | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Every pass the ledger holds, newest first, filtered by the flags as typed. The empty list is an
|
|
68
|
+
* ANSWER — a driver with no ledger and an app that has never swept anything are both "nothing has
|
|
69
|
+
* run", and `inspectBackfills` already refuses to throw for either.
|
|
70
|
+
*/
|
|
71
|
+
export async function listBackfills(
|
|
72
|
+
driver: JobDriver,
|
|
73
|
+
filter: BackfillListFilter = {},
|
|
74
|
+
): Promise<readonly BackfillProgress[]> {
|
|
75
|
+
const status = parseBackfillStatusFlag(filter.status);
|
|
76
|
+
const limit = parseLimitFlag(filter.limit, 'db');
|
|
77
|
+
return inspectBackfills(driver, {
|
|
78
|
+
...(filter.name === undefined ? {} : { name: filter.name }),
|
|
79
|
+
...(status === undefined ? {} : { status }),
|
|
80
|
+
...(limit === undefined ? {} : { limit }),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const HEADER = ['name', 'status', 'rows', 'cursor', 'started-at', 'duration-ms', 'run-id'] as const;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `started-at` is the ledger's own ISO string, printed verbatim and never re-formatted: the repo
|
|
88
|
+
* forbids a date rendered without an explicit IANA `timeZone`, and not formatting at all is the
|
|
89
|
+
* one rendering with no zone to get wrong — the same rule `jobs-table.ts` states for `run-at-ms`.
|
|
90
|
+
* `--json` carries these exact values, so the two renders of one command stay comparable.
|
|
91
|
+
*/
|
|
92
|
+
export function renderBackfillTable(rows: readonly BackfillProgress[]): readonly string[] {
|
|
93
|
+
const none = msg('cli.db.backfill.none');
|
|
94
|
+
return renderTable(
|
|
95
|
+
HEADER,
|
|
96
|
+
rows.map((row) => [
|
|
97
|
+
row.name,
|
|
98
|
+
row.status,
|
|
99
|
+
String(row.rows),
|
|
100
|
+
row.cursor ?? none,
|
|
101
|
+
row.startedAt,
|
|
102
|
+
row.durationMs === null ? none : String(row.durationMs),
|
|
103
|
+
row.runId,
|
|
104
|
+
]),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── declared minus completed ──────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Every declaration this app made, judged against every pass the ledger holds. The ledger read is
|
|
112
|
+
* `inspectBackfills` and never a second one, and the arithmetic is `@ultimat3/jobs`' — this
|
|
113
|
+
* function is the join and nothing else.
|
|
114
|
+
*/
|
|
115
|
+
export async function pendingReport(
|
|
116
|
+
driver: JobDriver,
|
|
117
|
+
environment: Environment,
|
|
118
|
+
): Promise<BackfillPendingReport> {
|
|
119
|
+
return pendingBackfills({
|
|
120
|
+
declarations: registeredBackfills(),
|
|
121
|
+
// Unfiltered and unlimited: a name whose only pass scrolled past a `--limit` would be reported
|
|
122
|
+
// as never run, which is the one answer this diff must never get wrong.
|
|
123
|
+
runs: await inspectBackfills(driver, { limit: Number.MAX_SAFE_INTEGER }),
|
|
124
|
+
environment,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const PENDING_HEADER = ['name', 'state', 'requires', 'environments', 'last-run-id'] as const;
|
|
129
|
+
|
|
130
|
+
export function renderPendingTable(report: BackfillPendingReport): readonly string[] {
|
|
131
|
+
const none = msg('cli.db.backfill.none');
|
|
132
|
+
return renderTable(
|
|
133
|
+
PENDING_HEADER,
|
|
134
|
+
report.rows.map((row) => [
|
|
135
|
+
row.name,
|
|
136
|
+
row.state,
|
|
137
|
+
row.requires ?? none,
|
|
138
|
+
row.environments === null ? none : row.environments.join('|'),
|
|
139
|
+
row.lastRunId ?? none,
|
|
140
|
+
]),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function pendingToJson(report: BackfillPendingReport): JsonValue {
|
|
145
|
+
return {
|
|
146
|
+
environment: report.environment,
|
|
147
|
+
declared: report.rows.length,
|
|
148
|
+
pending: report.pending.map((row) => row.name),
|
|
149
|
+
orphaned: [...report.orphaned],
|
|
150
|
+
rows: report.rows.map((row) => ({
|
|
151
|
+
name: row.name,
|
|
152
|
+
state: row.state,
|
|
153
|
+
checksum: row.checksum,
|
|
154
|
+
ledgerChecksum: row.ledgerChecksum,
|
|
155
|
+
changed: row.changed,
|
|
156
|
+
requires: row.requires,
|
|
157
|
+
environments: row.environments === null ? null : [...row.environments],
|
|
158
|
+
lastRunId: row.lastRunId,
|
|
159
|
+
rows: row.rows,
|
|
160
|
+
})),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── running one ───────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Migration ids `x_migrations` records as applied. Three outcomes, and they mean different things:
|
|
168
|
+
*
|
|
169
|
+
* | Answer | When | Gate reads it as |
|
|
170
|
+
* |---|---|---|
|
|
171
|
+
* | the ids | the ledger was read | exactly what is applied |
|
|
172
|
+
* | `[]` | `x_migrations` does not exist | nothing applied — every `requires` is unsatisfied |
|
|
173
|
+
* | `undefined` | no declaration waits on a migration | there is nothing to check |
|
|
174
|
+
*
|
|
175
|
+
* An absent table is an ANSWER, never a failure: a database this app has never migrated genuinely
|
|
176
|
+
* has no applied migration, so `[]` blocks and that is the honest verdict. Everything else —
|
|
177
|
+
* permission denied, a timeout, a dropped connection, a malformed query — means the check DID NOT
|
|
178
|
+
* HAPPEN, and those propagate. A gate that read "I could not ask" as "it is applied" would let a
|
|
179
|
+
* sweep run against exactly the shape it exists to wait for, which is the silent pass this whole
|
|
180
|
+
* slice exists to remove.
|
|
181
|
+
*
|
|
182
|
+
* The read is skipped entirely when nothing declares `requires`: it opens the app's database, and
|
|
183
|
+
* a command with no question to ask must not fail for want of an answer it will not use.
|
|
184
|
+
*/
|
|
185
|
+
export async function readAppliedMigrations(): Promise<readonly string[] | undefined> {
|
|
186
|
+
if (!registeredBackfills().some((declaration) => declaration.requires !== null)) return undefined;
|
|
187
|
+
try {
|
|
188
|
+
return (await readLedger(db())).map((row) => row.id);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (isLedgerMissing(error)) return [];
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** What one name's turn produced. `planned` is the dry run — `--write` is never implied. */
|
|
196
|
+
export type BackfillAction = 'planned' | 'enqueued' | 'deduped' | 'blocked';
|
|
197
|
+
|
|
198
|
+
export interface BackfillPlanRow {
|
|
199
|
+
readonly name: string;
|
|
200
|
+
readonly action: BackfillAction;
|
|
201
|
+
readonly state: BackfillState | null;
|
|
202
|
+
/** The queue row a `--write` created. `null` for every dry run and every refusal. */
|
|
203
|
+
readonly jobId: string | null;
|
|
204
|
+
/** What `count()` still matches, when the declaration has one and it could be asked. */
|
|
205
|
+
readonly remaining: number | null;
|
|
206
|
+
readonly finding: Finding | null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* `count()` is the same predicate `source` selects on, so this is the one number that keeps a dry
|
|
211
|
+
* run honest. A tenanted sweep counts within one org — its `ctx.actor` carries none here — so a
|
|
212
|
+
* throw is reported as `null` rather than guessed at: a dry run that invented a row count is the
|
|
213
|
+
* failure `count()` exists to close.
|
|
214
|
+
*/
|
|
215
|
+
async function remainingFor(declaration: BackfillDeclaration): Promise<number | null> {
|
|
216
|
+
if (!declaration.counts) return null;
|
|
217
|
+
const handle = getBackfill(declaration.name);
|
|
218
|
+
const count = handle === undefined ? undefined : backfillOrigin(handle)?.count;
|
|
219
|
+
if (count === undefined) return null;
|
|
220
|
+
try {
|
|
221
|
+
return await count({ ctx: createContext({ role: 'migrate' }) });
|
|
222
|
+
} catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* One shape for `X_BACKFILL_UNKNOWN`, wherever it is raised. Two constructions of one code with
|
|
229
|
+
* different payloads — one listing the candidate names and one listing none — is a finding an
|
|
230
|
+
* agent cannot act on half the time, which is the same code meaning two things.
|
|
231
|
+
*/
|
|
232
|
+
const unknownRow = (
|
|
233
|
+
name: string,
|
|
234
|
+
state: BackfillState | null,
|
|
235
|
+
declarations: readonly BackfillDeclaration[],
|
|
236
|
+
): BackfillPlanRow => ({
|
|
237
|
+
name,
|
|
238
|
+
action: 'blocked',
|
|
239
|
+
state,
|
|
240
|
+
jobId: null,
|
|
241
|
+
remaining: null,
|
|
242
|
+
finding: findingFrom(
|
|
243
|
+
new BackfillUnknownError({ backfill: name, known: declarations.map((row) => row.name) }),
|
|
244
|
+
),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
export interface BackfillRunInput {
|
|
248
|
+
readonly driver: JobDriver;
|
|
249
|
+
/** The names asked for, or every PENDING one when `--all` was passed. */
|
|
250
|
+
readonly names: readonly string[] | 'all';
|
|
251
|
+
readonly write: boolean;
|
|
252
|
+
readonly force: boolean;
|
|
253
|
+
readonly environment: Environment;
|
|
254
|
+
readonly appliedMigrations: readonly string[] | undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* One turn per name, isolated: a refusal or a throw becomes that name's row and the loop goes on.
|
|
259
|
+
* That isolation is the whole point of `--all` — one wedged cleanup must not block every later one
|
|
260
|
+
* forever, which is exactly what a `for` loop over a throwing gate would have done.
|
|
261
|
+
*/
|
|
262
|
+
export async function runBackfills(input: BackfillRunInput): Promise<readonly BackfillPlanRow[]> {
|
|
263
|
+
const report = await pendingReport(input.driver, input.environment);
|
|
264
|
+
const declarations = registeredBackfills();
|
|
265
|
+
const byName = new Map(declarations.map((row) => [row.name, row]));
|
|
266
|
+
// `--all` sweeps what is PENDING, and `--all --force` every name this environment may run: a
|
|
267
|
+
// forced rerun of a completed name is a decision, so it is never what a bare `--all` performs.
|
|
268
|
+
//
|
|
269
|
+
// Selected by STATE through `@ultimat3/jobs`' own predicate, never by `report.pending.includes`:
|
|
270
|
+
// that worked only because `pendingBackfills` filters the same array it returns, and the day it
|
|
271
|
+
// mapped its rows instead, `--all` would have found zero targets, exited 0 and reported that
|
|
272
|
+
// nothing needed sweeping — the silent success this slice exists to remove, reintroduced by an
|
|
273
|
+
// identity check nobody could see from here.
|
|
274
|
+
const targets =
|
|
275
|
+
input.names === 'all'
|
|
276
|
+
? report.rows
|
|
277
|
+
.filter((row) =>
|
|
278
|
+
input.force ? row.state !== 'excluded' : isPendingBackfillState(row.state),
|
|
279
|
+
)
|
|
280
|
+
.map((row) => row.name)
|
|
281
|
+
: input.names;
|
|
282
|
+
|
|
283
|
+
const rows: BackfillPlanRow[] = [];
|
|
284
|
+
for (const name of targets) {
|
|
285
|
+
const declaration = byName.get(name);
|
|
286
|
+
const state = report.rows.find((row) => row.name === name)?.state ?? null;
|
|
287
|
+
if (declaration === undefined) {
|
|
288
|
+
rows.push(unknownRow(name, state, declarations));
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const verdict = gateBackfill({
|
|
292
|
+
declaration,
|
|
293
|
+
environment: input.environment,
|
|
294
|
+
appliedMigrations: input.appliedMigrations,
|
|
295
|
+
// Only fetched for a name the diff already judged completed — the gate wants the row, and
|
|
296
|
+
// the diff is what knows whether there is one to want.
|
|
297
|
+
completed: state === 'completed' ? await newestCompleted(input.driver, name) : undefined,
|
|
298
|
+
force: input.force,
|
|
299
|
+
});
|
|
300
|
+
if (!verdict.run) {
|
|
301
|
+
rows.push({
|
|
302
|
+
name,
|
|
303
|
+
action: 'blocked',
|
|
304
|
+
state,
|
|
305
|
+
jobId: null,
|
|
306
|
+
remaining: null,
|
|
307
|
+
finding: findingFrom(verdict.error),
|
|
308
|
+
});
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const remaining = await remainingFor(declaration);
|
|
312
|
+
if (!input.write) {
|
|
313
|
+
rows.push({ name, action: 'planned', state, jobId: null, remaining, finding: null });
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
// `getBackfill` cannot answer undefined here — `declaration` came from `registeredBackfills()`,
|
|
317
|
+
// which is derived from the same registry — so the handle is resolved once, at the only place
|
|
318
|
+
// that already proved the name exists. Resolving it again inside `enqueueOne` meant a second
|
|
319
|
+
// `X_BACKFILL_UNKNOWN` that could not list the candidates the first one lists.
|
|
320
|
+
const handle = getBackfill(name);
|
|
321
|
+
if (handle === undefined) {
|
|
322
|
+
rows.push(unknownRow(name, state, declarations));
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
rows.push(await enqueueOne(handle, state, remaining, input.force));
|
|
326
|
+
}
|
|
327
|
+
return rows;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const newestCompleted = async (
|
|
331
|
+
driver: JobDriver,
|
|
332
|
+
name: string,
|
|
333
|
+
): Promise<BackfillProgress | undefined> =>
|
|
334
|
+
(await inspectBackfills(driver, { name, status: 'completed', limit: 1 }))[0];
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The queue is a job's execution surface, so `--write` ENQUEUES and never runs the pass inline —
|
|
338
|
+
* the rule `handle.as()` already states. That is also what makes `ROLE=backfill` a trigger rather
|
|
339
|
+
* than a gate: the container puts the sweeps on the queue and exits, and the workers already
|
|
340
|
+
* serving the new release are what drain them.
|
|
341
|
+
*/
|
|
342
|
+
async function enqueueOne(
|
|
343
|
+
handle: JobHandle<BackfillInput>,
|
|
344
|
+
state: BackfillState | null,
|
|
345
|
+
remaining: number | null,
|
|
346
|
+
force: boolean,
|
|
347
|
+
): Promise<BackfillPlanRow> {
|
|
348
|
+
const name = handle.name;
|
|
349
|
+
try {
|
|
350
|
+
const result = await handle.enqueue({ force });
|
|
351
|
+
// One live pass per name, forced or not. A deduped enqueue started nothing, so the operator who
|
|
352
|
+
// asked for a pass has to hear about the one already holding the key.
|
|
353
|
+
return result.deduped
|
|
354
|
+
? {
|
|
355
|
+
name,
|
|
356
|
+
action: 'deduped',
|
|
357
|
+
state,
|
|
358
|
+
jobId: result.id,
|
|
359
|
+
remaining,
|
|
360
|
+
finding: findingFrom(new BackfillRunningError({ backfill: name, jobId: result.id })),
|
|
361
|
+
}
|
|
362
|
+
: { name, action: 'enqueued', state, jobId: result.id, remaining, finding: null };
|
|
363
|
+
} catch (error) {
|
|
364
|
+
return { name, action: 'blocked', state, jobId: null, remaining, finding: findingFrom(error) };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const PLAN_HEADER = ['name', 'action', 'state', 'remaining', 'job-id'] as const;
|
|
369
|
+
|
|
370
|
+
export function renderPlanTable(rows: readonly BackfillPlanRow[]): readonly string[] {
|
|
371
|
+
const none = msg('cli.db.backfill.none');
|
|
372
|
+
return renderTable(
|
|
373
|
+
PLAN_HEADER,
|
|
374
|
+
rows.map((row) => [
|
|
375
|
+
row.name,
|
|
376
|
+
row.action,
|
|
377
|
+
row.state ?? none,
|
|
378
|
+
row.remaining === null ? none : String(row.remaining),
|
|
379
|
+
row.jobId ?? none,
|
|
380
|
+
]),
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function planToJson(rows: readonly BackfillPlanRow[]): JsonValue {
|
|
385
|
+
return rows.map((row) => ({
|
|
386
|
+
name: row.name,
|
|
387
|
+
action: row.action,
|
|
388
|
+
state: row.state,
|
|
389
|
+
remaining: row.remaining,
|
|
390
|
+
jobId: row.jobId,
|
|
391
|
+
finding:
|
|
392
|
+
row.finding === null
|
|
393
|
+
? null
|
|
394
|
+
: {
|
|
395
|
+
code: row.finding.code,
|
|
396
|
+
cause: row.finding.cause,
|
|
397
|
+
fix: row.finding.fix,
|
|
398
|
+
docs: row.finding.docs ?? null,
|
|
399
|
+
},
|
|
400
|
+
}));
|
|
401
|
+
}
|
package/src/db-branch.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// What a branch database IS, for the two databases `x db` can be pointed at: the closed set of
|
|
2
|
+
// verbs, the name a branch takes on disk or in `pg_database`, and list/create/drop for each mode.
|
|
3
|
+
// Plain inputs, plain rows — no `ParsedArgs`, no `CommandResult` — so every rule here is testable
|
|
4
|
+
// against a temp directory and a recording client, and `cmd-db-branch.ts` owns only the wiring.
|
|
5
|
+
|
|
6
|
+
// `node:fs/promises` for `readdir`/`rm`/`stat` — Bun exposes no directory listing, no recursive
|
|
7
|
+
// delete and no birthtime. `node:path` for the joiner Bun also does not have.
|
|
8
|
+
import { readdir, rm, stat } from 'node:fs/promises';
|
|
9
|
+
import { basename, dirname, join } from 'node:path';
|
|
10
|
+
import type { DbClient } from '@ultimat3/db';
|
|
11
|
+
import {
|
|
12
|
+
assertBranchName,
|
|
13
|
+
branchPglite,
|
|
14
|
+
createBranch,
|
|
15
|
+
currentDatabase,
|
|
16
|
+
dropBranch,
|
|
17
|
+
listBranches,
|
|
18
|
+
pgliteBranchDir,
|
|
19
|
+
pgliteDataDir,
|
|
20
|
+
} from '@ultimat3/db';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The closed set `x db branch` takes as its first positional, and the reason a branch name can no
|
|
24
|
+
* longer be mistaken for one. `x db branch <name>` read its argument as a name, so `x db branch
|
|
25
|
+
* ls` — a fix line three shipped errors hand out — cloned a database called `ls`.
|
|
26
|
+
*
|
|
27
|
+
* `reap` is deliberately absent: a nightly sweep is a `task` (`reapBranches` from `@ultimat3/db`),
|
|
28
|
+
* and a CLI verb for it would be a second path to one job with a max-age nobody can default.
|
|
29
|
+
*/
|
|
30
|
+
export const BRANCH_SUBCOMMANDS = ['ls', 'create', 'drop'] as const;
|
|
31
|
+
|
|
32
|
+
export type BranchSubcommand = (typeof BRANCH_SUBCOMMANDS)[number];
|
|
33
|
+
|
|
34
|
+
export const isBranchSubcommand = (word: string): word is BranchSubcommand =>
|
|
35
|
+
(BRANCH_SUBCOMMANDS as readonly string[]).includes(word);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `@ultimat3/db` owns what a branch name may be (`[a-z0-9_-]+`, validated before it reaches a path
|
|
39
|
+
* or a `CREATE DATABASE`). Asked through its own assertion rather than re-spelled here, because a
|
|
40
|
+
* second copy of that regex is a second answer to "is this safe to interpolate".
|
|
41
|
+
*/
|
|
42
|
+
export function isBranchName(value: string): boolean {
|
|
43
|
+
try {
|
|
44
|
+
assertBranchName(value);
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The database a connection URL names. `url.split('/').at(-1)` took the query string with it, so
|
|
53
|
+
* a refusal built from it named `postly?sslmode=require_branch_x` — a database that does not exist
|
|
54
|
+
* — in a message whose whole point is that a reader can check it. `pathname` is the one part that
|
|
55
|
+
* IS the database, and it arrives percent-encoded, which `pg_database` does not.
|
|
56
|
+
*
|
|
57
|
+
* Falls back rather than throwing: the caller is already reporting a failure, and a second throw
|
|
58
|
+
* from the reporter replaces a checkable refusal with a stack trace.
|
|
59
|
+
*/
|
|
60
|
+
export function databaseNameOf(url: string, fallback = 'postgres'): string {
|
|
61
|
+
try {
|
|
62
|
+
const name = decodeURIComponent(new URL(url).pathname.replace(/^\//, ''));
|
|
63
|
+
return name === '' ? fallback : name;
|
|
64
|
+
} catch {
|
|
65
|
+
return fallback;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Where a branch's app answers once something serves it — the preview half of the design. */
|
|
70
|
+
export const previewUrl = (branch: string, port: number): string =>
|
|
71
|
+
`http://${branch}.localhost:${port}`;
|
|
72
|
+
|
|
73
|
+
/** One branch, whichever database it lives in. */
|
|
74
|
+
export interface BranchRow {
|
|
75
|
+
readonly name: string;
|
|
76
|
+
/** Where it lives: a database name, or a PGlite data directory. Point `DATABASE_URL` at it. */
|
|
77
|
+
readonly location: string;
|
|
78
|
+
/** `null` where nothing recorded one — the embedded copy keeps no creation record of its own. */
|
|
79
|
+
readonly createdAt: string | null;
|
|
80
|
+
/** `null` where measuring it would cost a full walk of the branch. */
|
|
81
|
+
readonly sizeBytes: number | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `x db branch create <name>` on a real Postgres clones into `<source>_branch_<name>`. */
|
|
85
|
+
export function branchDatabaseName(source: string, branch: string): string {
|
|
86
|
+
return `${source}_branch_${branch.replace(/[^a-zA-Z0-9_]/g, '_')}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The reverse asked of NO source, and the ONE reader of it: `mcp-db-target.ts` decides whether
|
|
91
|
+
* `db.migrate` is aimed at a private database from a URL alone, with no connection to ask
|
|
92
|
+
* `current_database()` — so "a branch of somebody" is the only question it can pose, and the
|
|
93
|
+
* answer it wants for `analytics_branch_feat` is still "not the shared database". Anything holding
|
|
94
|
+
* a client asks `branchNameIn` instead, which is the question `ls` and `drop` need.
|
|
95
|
+
*/
|
|
96
|
+
export const branchNameOf = (database: string): string | null =>
|
|
97
|
+
/_branch_(.+)$/.exec(database)?.[1] ?? null;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The same reverse asked of ONE source: is `database` a branch of `source`, and of what name?
|
|
101
|
+
* `branchNameOf` cannot answer that — it finds the first `_branch_` in any database on the server,
|
|
102
|
+
* so `analytics_branch_feat` reduced to `feat` for a session connected to `postly`, and
|
|
103
|
+
* `postly_branch_a_branch_b` reduced to `a_branch_b` for one connected to `postly_branch_a`.
|
|
104
|
+
* The exact inverse of `branchDatabaseName`, which is what makes a listed name safe to drop:
|
|
105
|
+
* `branchNameIn(s, branchDatabaseName(s, b))` is `b` with the same substitution applied.
|
|
106
|
+
*/
|
|
107
|
+
export function branchNameIn(source: string, database: string): string | null {
|
|
108
|
+
const prefix = `${source}_branch_`;
|
|
109
|
+
return database.startsWith(prefix) ? database.slice(prefix.length) : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The embedded peer: `branchPglite` copies `<dir>` to `<dir>-<name>`, so the branch name is the
|
|
114
|
+
* suffix. `pgliteBranchDir` is the forward rule and this is its inverse — written once, because
|
|
115
|
+
* `x db branch ls` and the MCP host's branch check must agree about what a branch directory is.
|
|
116
|
+
*/
|
|
117
|
+
export function pgliteBranchName(dir: string, source: string): string | null {
|
|
118
|
+
return dir.startsWith(`${source}-`) ? dir.slice(source.length + 1) : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function isDirectory(path: string): Promise<boolean> {
|
|
122
|
+
try {
|
|
123
|
+
return (await stat(path)).isDirectory();
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The directory's own creation time, which is when the copy landed. `branchPglite` returns a
|
|
131
|
+
* `createdAt` and persists it nowhere, so this is the only record there is — and filesystems that
|
|
132
|
+
* keep none report 0, which is answered as "unknown" rather than as 1970.
|
|
133
|
+
*/
|
|
134
|
+
async function createdAtOf(path: string): Promise<string | null> {
|
|
135
|
+
try {
|
|
136
|
+
const birth = (await stat(path)).birthtimeMs;
|
|
137
|
+
return birth > 0 ? new Date(birth).toISOString() : null;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function listPgliteBranches(url: string): Promise<readonly BranchRow[]> {
|
|
144
|
+
const source = pgliteDataDir(url);
|
|
145
|
+
const parent = dirname(source);
|
|
146
|
+
let entries: readonly string[];
|
|
147
|
+
try {
|
|
148
|
+
entries = await readdir(parent);
|
|
149
|
+
} catch {
|
|
150
|
+
// Nothing has run against this app yet. "No branches" is the honest answer to the question.
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
const rows: BranchRow[] = [];
|
|
154
|
+
for (const entry of entries.toSorted((a, b) => a.localeCompare(b))) {
|
|
155
|
+
const name = pgliteBranchName(entry, basename(source));
|
|
156
|
+
if (name === null) continue;
|
|
157
|
+
const location = join(parent, entry);
|
|
158
|
+
if (!(await isDirectory(location))) continue;
|
|
159
|
+
rows.push({ name, location, createdAt: await createdAtOf(location), sizeBytes: null });
|
|
160
|
+
}
|
|
161
|
+
return rows;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Where a branch WOULD live — what a refusal names, so it can be checked rather than believed. */
|
|
165
|
+
export const pgliteBranchLocation = (url: string, branch: string): string =>
|
|
166
|
+
pgliteBranchDir(pgliteDataDir(url), branch);
|
|
167
|
+
|
|
168
|
+
export async function createPgliteBranch(url: string, branch: string): Promise<BranchRow> {
|
|
169
|
+
const info = await branchPglite(branch, { from: url });
|
|
170
|
+
return {
|
|
171
|
+
name: info.name,
|
|
172
|
+
location: info.dataDir,
|
|
173
|
+
createdAt: info.createdAt,
|
|
174
|
+
sizeBytes: info.sizeBytes,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Answers whether there was a branch there, exactly as `dropBranch` does. The name is asserted
|
|
180
|
+
* before it reaches a path: it is spliced into a directory name, so an unvalidated one is
|
|
181
|
+
* traversal rather than a typo — and `pgliteBranchDir` can never resolve to the source itself.
|
|
182
|
+
*/
|
|
183
|
+
export async function dropPgliteBranch(url: string, branch: string): Promise<boolean> {
|
|
184
|
+
assertBranchName(branch);
|
|
185
|
+
const dir = pgliteBranchDir(pgliteDataDir(url), branch);
|
|
186
|
+
if (!(await isDirectory(dir))) return false;
|
|
187
|
+
await rm(dir, { recursive: true, force: true });
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Branches OF `source`: `createBranch`'s marker comment AND this source's own prefix. The marker
|
|
193
|
+
* alone is not enough, and that is the whole reason this takes a source at all — it records when a
|
|
194
|
+
* clone was made and never what it was cloned from, so one Postgres server hosting two Ultimate
|
|
195
|
+
* apps answers `listBranches()` with both apps' clones and `postly_branch_feat` and
|
|
196
|
+
* `analytics_branch_feat` both reduce to the branch name `feat`.
|
|
197
|
+
*/
|
|
198
|
+
async function branchesOf(client: DbClient, source: string): Promise<readonly BranchRow[]> {
|
|
199
|
+
const rows: BranchRow[] = [];
|
|
200
|
+
for (const branch of await listBranches({ client })) {
|
|
201
|
+
const name = branchNameIn(source, branch.name);
|
|
202
|
+
if (name === null) continue;
|
|
203
|
+
rows.push({
|
|
204
|
+
name,
|
|
205
|
+
location: branch.name,
|
|
206
|
+
createdAt: branch.createdAt,
|
|
207
|
+
sizeBytes: branch.sizeBytes,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return rows;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Only databases carrying `createBranch`'s own marker comment, and only branches of the database
|
|
215
|
+
* this session is connected to. A database this listing does not name is one `drop` may not touch,
|
|
216
|
+
* which is what makes "you may only drop what `ls` shows" a guard rather than a courtesy — and a
|
|
217
|
+
* row belonging to another app on the same server made that guard answer for a database it had
|
|
218
|
+
* never seen.
|
|
219
|
+
*/
|
|
220
|
+
export async function listExternalBranches(client: DbClient): Promise<readonly BranchRow[]> {
|
|
221
|
+
return branchesOf(client, await currentDatabase(client));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Through `createBranch`, never a hand-written `CREATE DATABASE`: it validates the name, refuses a
|
|
226
|
+
* database that already exists with `X_BRANCH_EXISTS`, and writes the marker comment that makes
|
|
227
|
+
* the clone visible to `ls`. The CLI shelled out to `psql` until now and wrote no marker at all,
|
|
228
|
+
* so every branch it made was invisible to the only lister the framework has.
|
|
229
|
+
*/
|
|
230
|
+
export async function createExternalBranch(client: DbClient, branch: string): Promise<BranchRow> {
|
|
231
|
+
const source = await currentDatabase(client);
|
|
232
|
+
const database = branchDatabaseName(source, branch);
|
|
233
|
+
const info = await createBranch(database, { client, base: source });
|
|
234
|
+
return {
|
|
235
|
+
// The name `ls` will show for it, derived the way `ls` derives one — a create that reported a
|
|
236
|
+
// name the listing then spells differently is a `drop` the caller has to guess at.
|
|
237
|
+
name: branchNameIn(source, database) ?? database,
|
|
238
|
+
location: database,
|
|
239
|
+
createdAt: info.createdAt,
|
|
240
|
+
// `createBranch` reports 0 for a database it has not measured; unknown is the truthful word.
|
|
241
|
+
sizeBytes: null,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* `force`, because a branch exists to be thrown away and its own sessions must not outvote that.
|
|
247
|
+
*
|
|
248
|
+
* The listing is the guard, so it is taken HERE — on the connection about to issue the `DROP`, one
|
|
249
|
+
* statement before it — and never accepted from a caller that listed earlier. Two things it closes:
|
|
250
|
+
* a name approved by another app's clone (the listing is now this source's alone), and a database
|
|
251
|
+
* that merely LOOKS like a branch of this one — `postly_branch_feat` with no marker is somebody
|
|
252
|
+
* else's database and `drop database if exists` would have taken it without asking.
|
|
253
|
+
*
|
|
254
|
+
* It is not atomic and cannot be: `DROP DATABASE` runs in no transaction, so no single statement
|
|
255
|
+
* can both verify the marker and delete. What remains is the gap between two adjacent statements on
|
|
256
|
+
* one session — another process dropping and recreating `<source>_branch_<name>` inside it would
|
|
257
|
+
* have this drop take the new one. Closing that needs a lock around both halves inside
|
|
258
|
+
* `@ultimat3/db`'s own `dropBranch`, which is where the `DROP` lives; a `psql` at the next terminal
|
|
259
|
+
* would still not hold it.
|
|
260
|
+
*/
|
|
261
|
+
export async function dropExternalBranch(client: DbClient, branch: string): Promise<boolean> {
|
|
262
|
+
const source = await currentDatabase(client);
|
|
263
|
+
// Matched on the DATABASE, not on the listed name: `branchDatabaseName` substitutes `-` for `_`,
|
|
264
|
+
// so `feat-x` and `feat_x` are one clone and both spellings must reach it.
|
|
265
|
+
const database = branchDatabaseName(source, branch);
|
|
266
|
+
const listed = await branchesOf(client, source);
|
|
267
|
+
if (!listed.some((row) => row.location === database)) return false;
|
|
268
|
+
return dropBranch(database, { client, force: true });
|
|
269
|
+
}
|