@ultimat3/cli 1.1.0 → 2.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.
Files changed (138) hide show
  1. package/CLAUDE.md +724 -0
  2. package/README.md +41 -9
  3. package/package.json +25 -23
  4. package/src/api-routes.ts +16 -0
  5. package/src/app-auth.ts +32 -0
  6. package/src/app-entities.ts +18 -0
  7. package/src/app-env.ts +103 -0
  8. package/src/app-load.ts +20 -3
  9. package/src/bin.ts +4 -3
  10. package/src/budgets.ts +114 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +215 -0
  13. package/src/cmd-db.ts +332 -155
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +87 -17
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +64 -9
  18. package/src/cmd-env.ts +95 -0
  19. package/src/cmd-errors.ts +33 -13
  20. package/src/cmd-fix.ts +5 -1
  21. package/src/cmd-generate.ts +146 -111
  22. package/src/cmd-help.ts +16 -5
  23. package/src/cmd-i18n.ts +2 -0
  24. package/src/cmd-jobs.ts +47 -33
  25. package/src/cmd-mcp.ts +11 -2
  26. package/src/cmd-new.ts +13 -7
  27. package/src/cmd-planned.ts +55 -10
  28. package/src/cmd-policy.ts +1 -0
  29. package/src/cmd-registries.ts +3 -0
  30. package/src/cmd-secrets.ts +368 -0
  31. package/src/cmd-tasks.ts +1 -0
  32. package/src/cmd-test.ts +17 -23
  33. package/src/cmd-verify.ts +177 -23
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +251 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +112 -0
  39. package/src/db-snapshot.ts +24 -0
  40. package/src/dev-assets.ts +86 -20
  41. package/src/dev-cache.ts +122 -0
  42. package/src/dev-dashboard.ts +19 -4
  43. package/src/dev-hooks.ts +27 -2
  44. package/src/dev-n-plus-one.ts +191 -0
  45. package/src/dev-queue.ts +105 -19
  46. package/src/dev-render.ts +158 -26
  47. package/src/dev-roles-fixture.ts +67 -0
  48. package/src/dev-roles.ts +186 -78
  49. package/src/dev-runtime.ts +117 -40
  50. package/src/dev-services.ts +15 -0
  51. package/src/dev-storage.ts +245 -0
  52. package/src/dev-sync.ts +107 -0
  53. package/src/dev-traces.ts +11 -3
  54. package/src/dispatch.ts +4 -2
  55. package/src/document-styles.ts +54 -0
  56. package/src/drift.ts +37 -9
  57. package/src/error-catalog.ts +7 -18
  58. package/src/error-codes.ts +186 -0
  59. package/src/error-contract.ts +29 -7
  60. package/src/error-fixes.ts +114 -0
  61. package/src/errors.ts +205 -140
  62. package/src/fix-command.ts +268 -0
  63. package/src/flag-number.ts +56 -0
  64. package/src/framework-scope.ts +49 -0
  65. package/src/generate-kinds.ts +97 -0
  66. package/src/guards.ts +186 -0
  67. package/src/index.ts +87 -14
  68. package/src/island-bundle.ts +166 -0
  69. package/src/island-routes.ts +50 -0
  70. package/src/jobs-driver.ts +33 -0
  71. package/src/jobs-json.ts +24 -0
  72. package/src/jobs-report.ts +17 -4
  73. package/src/mcp-db-target.ts +52 -27
  74. package/src/mcp-errors.ts +120 -19
  75. package/src/mcp-host.ts +44 -25
  76. package/src/messages.ts +81 -2
  77. package/src/metrics-endpoint.ts +73 -0
  78. package/src/migrations.ts +37 -4
  79. package/src/otlp-export.ts +64 -0
  80. package/src/output.ts +46 -16
  81. package/src/parse.ts +41 -3
  82. package/src/policy-facts.ts +38 -6
  83. package/src/policy-fixture.ts +14 -7
  84. package/src/prerender.ts +111 -2
  85. package/src/registry.ts +21 -3
  86. package/src/runtime-overrides.ts +66 -0
  87. package/src/safe-url-label.ts +24 -0
  88. package/src/scaffold-fixture.ts +10 -0
  89. package/src/scaffold-typecheck.ts +16 -38
  90. package/src/serve.ts +202 -18
  91. package/src/source-files.ts +4 -0
  92. package/src/statement-loop.ts +74 -0
  93. package/src/style-csp.ts +18 -0
  94. package/src/sync-authenticator.ts +59 -0
  95. package/src/templates/action.ts +15 -30
  96. package/src/templates/admin-page.ts +103 -0
  97. package/src/templates/admin.ts +11 -7
  98. package/src/templates/backfill.ts +212 -0
  99. package/src/templates/entity.ts +72 -31
  100. package/src/templates/guard.ts +143 -0
  101. package/src/templates/index.ts +12 -1
  102. package/src/templates/island.ts +67 -0
  103. package/src/templates/job.ts +53 -13
  104. package/src/templates/naming.ts +17 -1
  105. package/src/templates/policy.ts +35 -28
  106. package/src/templates/query.ts +24 -5
  107. package/src/templates/resource.ts +19 -11
  108. package/src/templates/route.ts +90 -15
  109. package/src/templates/scaffold-app.ts +142 -45
  110. package/src/templates/scaffold-claude-agents.ts +149 -0
  111. package/src/templates/scaffold-claude-commands.ts +221 -0
  112. package/src/templates/scaffold-claude.ts +134 -0
  113. package/src/templates/scaffold-container.ts +46 -2
  114. package/src/templates/scaffold-db-package.ts +91 -0
  115. package/src/templates/scaffold-docs.ts +24 -5
  116. package/src/templates/scaffold-domain-package.ts +90 -0
  117. package/src/templates/scaffold-env.ts +87 -0
  118. package/src/templates/scaffold-i18n.ts +4 -1
  119. package/src/templates/scaffold-mcp-package.ts +49 -0
  120. package/src/templates/scaffold-package-shape.ts +25 -4
  121. package/src/templates/scaffold-repo.ts +116 -257
  122. package/src/templates/scaffold-roles.ts +68 -0
  123. package/src/templates/scaffold-ui-package.ts +56 -0
  124. package/src/templates/slice-foundation.ts +88 -0
  125. package/src/templates/wrap.ts +95 -0
  126. package/src/test-counts.ts +35 -0
  127. package/src/test-select.ts +30 -15
  128. package/src/test-shards.ts +21 -3
  129. package/src/test-workers.ts +47 -0
  130. package/src/ts-scan.ts +271 -13
  131. package/src/tsconfig-references.ts +78 -0
  132. package/src/verify-floor.ts +133 -0
  133. package/src/verify-step.ts +19 -0
  134. package/src/verify-test-run.ts +72 -0
  135. package/src/verify-tests.ts +160 -71
  136. package/src/version-loader.ts +20 -3
  137. package/src/workspace-checks.ts +87 -16
  138. 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
+ }
@@ -0,0 +1,251 @@
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
+ /** Where a branch's app answers once something serves it — the preview half of the design. */
52
+ export const previewUrl = (branch: string, port: number): string =>
53
+ `http://${branch}.localhost:${port}`;
54
+
55
+ /** One branch, whichever database it lives in. */
56
+ export interface BranchRow {
57
+ readonly name: string;
58
+ /** Where it lives: a database name, or a PGlite data directory. Point `DATABASE_URL` at it. */
59
+ readonly location: string;
60
+ /** `null` where nothing recorded one — the embedded copy keeps no creation record of its own. */
61
+ readonly createdAt: string | null;
62
+ /** `null` where measuring it would cost a full walk of the branch. */
63
+ readonly sizeBytes: number | null;
64
+ }
65
+
66
+ /** `x db branch create <name>` on a real Postgres clones into `<source>_branch_<name>`. */
67
+ export function branchDatabaseName(source: string, branch: string): string {
68
+ return `${source}_branch_${branch.replace(/[^a-zA-Z0-9_]/g, '_')}`;
69
+ }
70
+
71
+ /**
72
+ * The reverse asked of NO source, and the ONE reader of it: `mcp-db-target.ts` decides whether
73
+ * `db.migrate` is aimed at a private database from a URL alone, with no connection to ask
74
+ * `current_database()` — so "a branch of somebody" is the only question it can pose, and the
75
+ * answer it wants for `analytics_branch_feat` is still "not the shared database". Anything holding
76
+ * a client asks `branchNameIn` instead, which is the question `ls` and `drop` need.
77
+ */
78
+ export const branchNameOf = (database: string): string | null =>
79
+ /_branch_(.+)$/.exec(database)?.[1] ?? null;
80
+
81
+ /**
82
+ * The same reverse asked of ONE source: is `database` a branch of `source`, and of what name?
83
+ * `branchNameOf` cannot answer that — it finds the first `_branch_` in any database on the server,
84
+ * so `analytics_branch_feat` reduced to `feat` for a session connected to `postly`, and
85
+ * `postly_branch_a_branch_b` reduced to `a_branch_b` for one connected to `postly_branch_a`.
86
+ * The exact inverse of `branchDatabaseName`, which is what makes a listed name safe to drop:
87
+ * `branchNameIn(s, branchDatabaseName(s, b))` is `b` with the same substitution applied.
88
+ */
89
+ export function branchNameIn(source: string, database: string): string | null {
90
+ const prefix = `${source}_branch_`;
91
+ return database.startsWith(prefix) ? database.slice(prefix.length) : null;
92
+ }
93
+
94
+ /**
95
+ * The embedded peer: `branchPglite` copies `<dir>` to `<dir>-<name>`, so the branch name is the
96
+ * suffix. `pgliteBranchDir` is the forward rule and this is its inverse — written once, because
97
+ * `x db branch ls` and the MCP host's branch check must agree about what a branch directory is.
98
+ */
99
+ export function pgliteBranchName(dir: string, source: string): string | null {
100
+ return dir.startsWith(`${source}-`) ? dir.slice(source.length + 1) : null;
101
+ }
102
+
103
+ async function isDirectory(path: string): Promise<boolean> {
104
+ try {
105
+ return (await stat(path)).isDirectory();
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * The directory's own creation time, which is when the copy landed. `branchPglite` returns a
113
+ * `createdAt` and persists it nowhere, so this is the only record there is — and filesystems that
114
+ * keep none report 0, which is answered as "unknown" rather than as 1970.
115
+ */
116
+ async function createdAtOf(path: string): Promise<string | null> {
117
+ try {
118
+ const birth = (await stat(path)).birthtimeMs;
119
+ return birth > 0 ? new Date(birth).toISOString() : null;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ export async function listPgliteBranches(url: string): Promise<readonly BranchRow[]> {
126
+ const source = pgliteDataDir(url);
127
+ const parent = dirname(source);
128
+ let entries: readonly string[];
129
+ try {
130
+ entries = await readdir(parent);
131
+ } catch {
132
+ // Nothing has run against this app yet. "No branches" is the honest answer to the question.
133
+ return [];
134
+ }
135
+ const rows: BranchRow[] = [];
136
+ for (const entry of entries.toSorted((a, b) => a.localeCompare(b))) {
137
+ const name = pgliteBranchName(entry, basename(source));
138
+ if (name === null) continue;
139
+ const location = join(parent, entry);
140
+ if (!(await isDirectory(location))) continue;
141
+ rows.push({ name, location, createdAt: await createdAtOf(location), sizeBytes: null });
142
+ }
143
+ return rows;
144
+ }
145
+
146
+ /** Where a branch WOULD live — what a refusal names, so it can be checked rather than believed. */
147
+ export const pgliteBranchLocation = (url: string, branch: string): string =>
148
+ pgliteBranchDir(pgliteDataDir(url), branch);
149
+
150
+ export async function createPgliteBranch(url: string, branch: string): Promise<BranchRow> {
151
+ const info = await branchPglite(branch, { from: url });
152
+ return {
153
+ name: info.name,
154
+ location: info.dataDir,
155
+ createdAt: info.createdAt,
156
+ sizeBytes: info.sizeBytes,
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Answers whether there was a branch there, exactly as `dropBranch` does. The name is asserted
162
+ * before it reaches a path: it is spliced into a directory name, so an unvalidated one is
163
+ * traversal rather than a typo — and `pgliteBranchDir` can never resolve to the source itself.
164
+ */
165
+ export async function dropPgliteBranch(url: string, branch: string): Promise<boolean> {
166
+ assertBranchName(branch);
167
+ const dir = pgliteBranchDir(pgliteDataDir(url), branch);
168
+ if (!(await isDirectory(dir))) return false;
169
+ await rm(dir, { recursive: true, force: true });
170
+ return true;
171
+ }
172
+
173
+ /**
174
+ * Branches OF `source`: `createBranch`'s marker comment AND this source's own prefix. The marker
175
+ * alone is not enough, and that is the whole reason this takes a source at all — it records when a
176
+ * clone was made and never what it was cloned from, so one Postgres server hosting two Ultimate
177
+ * apps answers `listBranches()` with both apps' clones and `postly_branch_feat` and
178
+ * `analytics_branch_feat` both reduce to the branch name `feat`.
179
+ */
180
+ async function branchesOf(client: DbClient, source: string): Promise<readonly BranchRow[]> {
181
+ const rows: BranchRow[] = [];
182
+ for (const branch of await listBranches({ client })) {
183
+ const name = branchNameIn(source, branch.name);
184
+ if (name === null) continue;
185
+ rows.push({
186
+ name,
187
+ location: branch.name,
188
+ createdAt: branch.createdAt,
189
+ sizeBytes: branch.sizeBytes,
190
+ });
191
+ }
192
+ return rows;
193
+ }
194
+
195
+ /**
196
+ * Only databases carrying `createBranch`'s own marker comment, and only branches of the database
197
+ * this session is connected to. A database this listing does not name is one `drop` may not touch,
198
+ * which is what makes "you may only drop what `ls` shows" a guard rather than a courtesy — and a
199
+ * row belonging to another app on the same server made that guard answer for a database it had
200
+ * never seen.
201
+ */
202
+ export async function listExternalBranches(client: DbClient): Promise<readonly BranchRow[]> {
203
+ return branchesOf(client, await currentDatabase(client));
204
+ }
205
+
206
+ /**
207
+ * Through `createBranch`, never a hand-written `CREATE DATABASE`: it validates the name, refuses a
208
+ * database that already exists with `X_BRANCH_EXISTS`, and writes the marker comment that makes
209
+ * the clone visible to `ls`. The CLI shelled out to `psql` until now and wrote no marker at all,
210
+ * so every branch it made was invisible to the only lister the framework has.
211
+ */
212
+ export async function createExternalBranch(client: DbClient, branch: string): Promise<BranchRow> {
213
+ const source = await currentDatabase(client);
214
+ const database = branchDatabaseName(source, branch);
215
+ const info = await createBranch(database, { client, base: source });
216
+ return {
217
+ // The name `ls` will show for it, derived the way `ls` derives one — a create that reported a
218
+ // name the listing then spells differently is a `drop` the caller has to guess at.
219
+ name: branchNameIn(source, database) ?? database,
220
+ location: database,
221
+ createdAt: info.createdAt,
222
+ // `createBranch` reports 0 for a database it has not measured; unknown is the truthful word.
223
+ sizeBytes: null,
224
+ };
225
+ }
226
+
227
+ /**
228
+ * `force`, because a branch exists to be thrown away and its own sessions must not outvote that.
229
+ *
230
+ * The listing is the guard, so it is taken HERE — on the connection about to issue the `DROP`, one
231
+ * statement before it — and never accepted from a caller that listed earlier. Two things it closes:
232
+ * a name approved by another app's clone (the listing is now this source's alone), and a database
233
+ * that merely LOOKS like a branch of this one — `postly_branch_feat` with no marker is somebody
234
+ * else's database and `drop database if exists` would have taken it without asking.
235
+ *
236
+ * It is not atomic and cannot be: `DROP DATABASE` runs in no transaction, so no single statement
237
+ * can both verify the marker and delete. What remains is the gap between two adjacent statements on
238
+ * one session — another process dropping and recreating `<source>_branch_<name>` inside it would
239
+ * have this drop take the new one. Closing that needs a lock around both halves inside
240
+ * `@ultimat3/db`'s own `dropBranch`, which is where the `DROP` lives; a `psql` at the next terminal
241
+ * would still not hold it.
242
+ */
243
+ export async function dropExternalBranch(client: DbClient, branch: string): Promise<boolean> {
244
+ const source = await currentDatabase(client);
245
+ // Matched on the DATABASE, not on the listed name: `branchDatabaseName` substitutes `-` for `_`,
246
+ // so `feat-x` and `feat_x` are one clone and both spellings must reach it.
247
+ const database = branchDatabaseName(source, branch);
248
+ const listed = await branchesOf(client, source);
249
+ if (!listed.some((row) => row.location === database)) return false;
250
+ return dropBranch(database, { client, force: true });
251
+ }
@@ -0,0 +1,29 @@
1
+ // The destructive-SQL rail at the gate: an `up` that drops, truncates or retypes must declare it.
2
+ // Files, not the database, so the rail fires in CI rather than first in a release phase.
3
+ // `@ultimat3/db` owns the classifier `x db gen` wrote the marker from — the generator and the gate
4
+ // cannot disagree about one file.
5
+
6
+ import { destructiveStatements, hasDestructiveMarker, migrationDestructive } from '@ultimat3/db';
7
+ import { MIGRATIONS_DIR, readMigrations } from './migrations';
8
+ import { type Finding, findingFrom } from './output';
9
+
10
+ /**
11
+ * One finding per migration, never one per statement: the marker declares the whole file, so a
12
+ * second finding would repeat the instruction the first already gave. The count still rides along
13
+ * in `cause`, because "and 3 more" is the difference between a typo and a rewrite.
14
+ *
15
+ * `readMigrations` is the reader `x db migrate` applies from, and only its `up` half is judged —
16
+ * a rail checking a list the migrator does not run, or SQL the migrator never sends, enforces
17
+ * nothing. An app with no migrations directory has nothing to declare and reports nothing.
18
+ */
19
+ export async function checkDestructiveMigrations(root: string): Promise<readonly Finding[]> {
20
+ const findings: Finding[] = [];
21
+ for (const migration of await readMigrations(root)) {
22
+ if (hasDestructiveMarker(migration.up)) continue;
23
+ const [first, ...rest] = destructiveStatements(migration.up);
24
+ if (first === undefined) continue;
25
+ const file = `${MIGRATIONS_DIR}/${migration.id}.sql`;
26
+ findings.push({ ...findingFrom(migrationDestructive(file, first, rest.length)), at: file });
27
+ }
28
+ return findings;
29
+ }