@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.
Files changed (141) hide show
  1. package/CLAUDE.md +761 -0
  2. package/README.md +42 -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 +134 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +219 -0
  13. package/src/cmd-db.ts +458 -153
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +92 -18
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +74 -10
  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 +14 -8
  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 +29 -24
  33. package/src/cmd-verify.ts +197 -25
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +269 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +144 -0
  39. package/src/db-seed.ts +294 -0
  40. package/src/db-snapshot.ts +24 -0
  41. package/src/dev-assets.ts +108 -23
  42. package/src/dev-cache.ts +122 -0
  43. package/src/dev-dashboard.ts +19 -4
  44. package/src/dev-hooks.ts +27 -2
  45. package/src/dev-n-plus-one.ts +191 -0
  46. package/src/dev-queue.ts +105 -19
  47. package/src/dev-render.ts +158 -26
  48. package/src/dev-roles-fixture.ts +67 -0
  49. package/src/dev-roles.ts +167 -78
  50. package/src/dev-runtime.ts +117 -40
  51. package/src/dev-services.ts +15 -0
  52. package/src/dev-storage.ts +247 -0
  53. package/src/dev-sync.ts +107 -0
  54. package/src/dev-traces.ts +37 -7
  55. package/src/dispatch.ts +4 -2
  56. package/src/document-styles.ts +54 -0
  57. package/src/drift.ts +78 -10
  58. package/src/error-catalog.ts +8 -18
  59. package/src/error-codes.ts +192 -0
  60. package/src/error-contract.ts +29 -7
  61. package/src/error-fixes.ts +114 -0
  62. package/src/errors.ts +201 -138
  63. package/src/exec.ts +42 -8
  64. package/src/fix-command.ts +268 -0
  65. package/src/flag-number.ts +67 -0
  66. package/src/framework-scope.ts +49 -0
  67. package/src/generate-kinds.ts +97 -0
  68. package/src/guards.ts +186 -0
  69. package/src/index.ts +92 -15
  70. package/src/island-bundle.ts +166 -0
  71. package/src/island-routes.ts +50 -0
  72. package/src/jobs-driver.ts +33 -0
  73. package/src/jobs-json.ts +24 -0
  74. package/src/jobs-report.ts +17 -4
  75. package/src/mcp-db-target.ts +52 -27
  76. package/src/mcp-errors.ts +128 -19
  77. package/src/mcp-host.ts +44 -25
  78. package/src/messages.ts +93 -2
  79. package/src/metrics-endpoint.ts +64 -16
  80. package/src/migrations.ts +37 -4
  81. package/src/otlp-export.ts +64 -0
  82. package/src/output.ts +46 -16
  83. package/src/parse.ts +41 -3
  84. package/src/policy-facts.ts +38 -6
  85. package/src/policy-fixture.ts +14 -7
  86. package/src/prerender.ts +111 -2
  87. package/src/registry.ts +21 -3
  88. package/src/runtime-overrides.ts +66 -0
  89. package/src/safe-url-label.ts +24 -0
  90. package/src/scaffold-fixture.ts +10 -0
  91. package/src/scaffold-typecheck.ts +16 -38
  92. package/src/serve.ts +185 -13
  93. package/src/shell-quote.ts +15 -0
  94. package/src/source-files.ts +4 -0
  95. package/src/statement-loop.ts +74 -0
  96. package/src/style-csp.ts +18 -0
  97. package/src/sync-authenticator.ts +59 -0
  98. package/src/templates/action.ts +15 -30
  99. package/src/templates/admin-page.ts +103 -0
  100. package/src/templates/admin.ts +11 -7
  101. package/src/templates/backfill.ts +212 -0
  102. package/src/templates/entity.ts +72 -31
  103. package/src/templates/guard.ts +143 -0
  104. package/src/templates/index.ts +12 -1
  105. package/src/templates/island.ts +67 -0
  106. package/src/templates/job.ts +53 -13
  107. package/src/templates/naming.ts +17 -1
  108. package/src/templates/policy.ts +35 -28
  109. package/src/templates/query.ts +24 -5
  110. package/src/templates/resource.ts +19 -11
  111. package/src/templates/route.ts +90 -15
  112. package/src/templates/scaffold-app.ts +142 -45
  113. package/src/templates/scaffold-claude-agents.ts +149 -0
  114. package/src/templates/scaffold-claude-commands.ts +221 -0
  115. package/src/templates/scaffold-claude.ts +134 -0
  116. package/src/templates/scaffold-container.ts +46 -2
  117. package/src/templates/scaffold-db-package.ts +91 -0
  118. package/src/templates/scaffold-docs.ts +24 -5
  119. package/src/templates/scaffold-domain-package.ts +90 -0
  120. package/src/templates/scaffold-env.ts +87 -0
  121. package/src/templates/scaffold-i18n.ts +4 -1
  122. package/src/templates/scaffold-mcp-package.ts +49 -0
  123. package/src/templates/scaffold-package-shape.ts +25 -4
  124. package/src/templates/scaffold-repo.ts +116 -257
  125. package/src/templates/scaffold-roles.ts +68 -0
  126. package/src/templates/scaffold-ui-package.ts +56 -0
  127. package/src/templates/slice-foundation.ts +88 -0
  128. package/src/templates/wrap.ts +95 -0
  129. package/src/test-counts.ts +35 -0
  130. package/src/test-select.ts +30 -15
  131. package/src/test-shards.ts +20 -11
  132. package/src/test-workers.ts +50 -0
  133. package/src/ts-scan.ts +284 -15
  134. package/src/tsconfig-references.ts +103 -0
  135. package/src/verify-floor.ts +133 -0
  136. package/src/verify-step.ts +19 -0
  137. package/src/verify-test-run.ts +72 -0
  138. package/src/verify-tests.ts +160 -71
  139. package/src/version-loader.ts +20 -3
  140. package/src/workspace-checks.ts +87 -16
  141. package/src/write-line.ts +34 -0
package/src/cmd-db.ts CHANGED
@@ -1,187 +1,492 @@
1
- // `x db gen|migrate|reset|studio|branch` — everything that touches the database, including the
2
- // branch DB that makes destructive work safe. An agent that can clone the database in a second
3
- // can migrate, seed and break things without a human deciding whether to let it.
1
+ // `x db gen|migrate|reset|seed|studio|branch|backfill` — everything that touches the database. One
2
+ // subcommand per line and no fall-through: a word this file does not know is refused, never
3
+ // re-read as an argument to the last branch. `branch` itself is `cmd-db-branch.ts`.
4
+ //
5
+ // Every subcommand here runs `@ultimat3/db`'s own engine, which is the engine `ROLE=migrate` runs
6
+ // (`serve.ts`): one `x_migrations` ledger, one checksum rule, one advisory lock, from a laptop to
7
+ // a release phase. Until 1.2.0 these shelled out to `bunx drizzle-kit` — a second engine with a
8
+ // second journal, declared in no `package.json` and fetched unpinned at run time.
4
9
 
5
- import { existsSync, readdirSync } from 'node:fs';
10
+ // `node:fs/promises` for `rm` — `Bun.file().delete()` takes one file, and `x db reset` removes a
11
+ // directory tree. `node:path` for `join` — Bun exposes no path joiner.
6
12
  import { rm } from 'node:fs/promises';
7
13
  import { join } from 'node:path';
8
- import { branchPglite } from '@ultimat3/db';
14
+ import { resolveEnvironment } from '@ultimat3/core';
15
+ import { type DriftReport, driftError, withTransaction } from '@ultimat3/db';
16
+ import { postgresDriver } from '@ultimat3/entity';
17
+ import { BackfillPendingError } from '@ultimat3/jobs';
18
+ import { loadApp } from './app-load';
9
19
  import { requireAppRoot } from './app-root';
20
+ import { runBranchCommand } from './cmd-db-branch';
21
+ import { plannedSubcommand } from './cmd-planned';
10
22
  import type { CliCommand, CommandContext } from './command';
23
+ import type { BackfillAction, BackfillPlanRow } from './db-backfill';
24
+ import {
25
+ listBackfills,
26
+ pendingReport,
27
+ pendingToJson,
28
+ planToJson,
29
+ readAppliedMigrations,
30
+ renderBackfillTable,
31
+ renderPendingTable,
32
+ renderPlanTable,
33
+ runBackfills,
34
+ } from './db-backfill';
35
+ import { BRANCH_SUBCOMMANDS } from './db-branch';
36
+ import { stepFinding } from './db-finding';
37
+ import { generateAppMigration } from './db-generate';
38
+ import type { SeedPassRow } from './db-seed';
39
+ import {
40
+ discoverSeeds,
41
+ parseSeedTierFlag,
42
+ renderSeedTable,
43
+ runSeeds,
44
+ seedPassToJson,
45
+ seedTotals,
46
+ selectSeeds,
47
+ } from './db-seed';
11
48
  import { resolveServices } from './dev-services';
12
- import { checkDrift, writeSchemaHash } from './drift';
13
- import { CliNotImplementedError } from './errors';
14
- import type { ExecResult } from './exec';
15
- import { execOutput } from './exec';
49
+ import {
50
+ BadFlagError,
51
+ CliNotImplementedError,
52
+ MissingSubcommandError,
53
+ UnknownCommandError,
54
+ } from './errors';
55
+ import { withJobDriver } from './jobs-driver';
56
+ import { backfillToJson } from './jobs-json';
16
57
  import { msg } from './messages';
17
58
  import type { CommandResult, Finding } from './output';
18
59
  import { findingFrom } from './output';
19
- import { flagString } from './parse';
60
+ import { flagBool, flagString } from './parse';
61
+ import { runMigrations } from './serve';
20
62
 
21
- export const DB_SUBCOMMANDS = ['gen', 'migrate', 'reset', 'studio', 'branch'] as const;
63
+ export const DB_SUBCOMMANDS = [
64
+ 'gen',
65
+ 'migrate',
66
+ 'reset',
67
+ 'seed',
68
+ 'studio',
69
+ 'branch',
70
+ 'backfill',
71
+ ] as const;
22
72
 
23
- const failure = (result: ExecResult, code: string, fix: string): Finding => ({
24
- code,
25
- cause: `${result.command.join(' ')} exited ${result.code}: ${execOutput(result).slice(0, 400)}`,
26
- fix,
27
- docs: `https://ultimate.dev/errors/${code}`,
28
- });
73
+ export const dbCommand: CliCommand = {
74
+ spec: {
75
+ name: 'db',
76
+ summary: 'gen, migrate, reset, seed, studio, branch, backfill',
77
+ usage:
78
+ 'x db gen "add publish_at" | migrate | reset | seed [<name>] [--tier reference|dev] [--dry-run] | studio | branch ls | branch create <name> | branch drop <name> | backfill [<name>|--all] [--write] [--force] | backfill --pending | backfill --list [--name n] [--status s] [--limit n]',
79
+ requiresApp: true,
80
+ subcommands: DB_SUBCOMMANDS,
81
+ // Declared from the constant `runBranchCommand` validates against, never a second literal: it
82
+ // is what lets the `errors` step resolve `x db branch ls` — a fix line three shipped errors
83
+ // hand out, which read `ls` as a branch name and cloned a database until 1.2.x.
84
+ subcommandPositionals: { branch: BRANCH_SUBCOMMANDS },
85
+ flags: [
86
+ {
87
+ name: 'name',
88
+ type: 'string',
89
+ summary: 'migration, branch or seed name, or backfill to filter',
90
+ },
91
+ {
92
+ name: 'tier',
93
+ type: 'string',
94
+ summary: 'seed: which tier to run — reference or dev; also ULTIMATE_SEED_TIER',
95
+ },
96
+ {
97
+ name: 'dry-run',
98
+ type: 'boolean',
99
+ summary: 'seed: report what each seed would write, and write nothing',
100
+ },
101
+ { name: 'list', type: 'boolean', summary: 'backfill: print the x_backfills ledger' },
102
+ {
103
+ name: 'pending',
104
+ type: 'boolean',
105
+ summary: 'backfill: declared minus completed; non-zero exit when anything is unswept',
106
+ },
107
+ { name: 'all', type: 'boolean', summary: 'backfill: every pending sweep, isolated per name' },
108
+ { name: 'write', type: 'boolean', summary: 'backfill: enqueue the pass; dry run without it' },
109
+ {
110
+ name: 'force',
111
+ type: 'boolean',
112
+ summary: 'backfill: sweep a name the ledger records as completed, as a NEW ledger row',
113
+ },
114
+ {
115
+ name: 'status',
116
+ type: 'string',
117
+ summary: 'backfill: filter by running, completed or failed',
118
+ },
119
+ { name: 'limit', type: 'string', summary: 'backfill: max ledger rows to return' },
120
+ // Declared because `X_MIGRATION_IRREVERSIBLE`'s own fix line names it. A `fix:` is copied
121
+ // and run verbatim, so a flag the parser refuses would make the error unfollowable.
122
+ {
123
+ name: 'allow-destructive',
124
+ type: 'boolean',
125
+ summary: 'let x db gen emit a drop whose down cannot restore the rows',
126
+ },
127
+ ],
128
+ },
129
+ async run(ctx: CommandContext): Promise<CommandResult> {
130
+ const root = requireAppRoot('db', ctx.cwd).dir;
131
+ // No default, and no `?? 'migrate'` here either: `gen` writes a migration file and `reset`
132
+ // drops the database, so "whatever the caller left out" is not a safe guess for any of the six.
133
+ // The parser refuses a bare `x db`; this covers a `ParsedArgs` built by hand.
134
+ const sub = ctx.args.subcommand;
135
+ if (sub === undefined)
136
+ throw new MissingSubcommandError({ command: 'db', known: DB_SUBCOMMANDS });
137
+ const argument = ctx.args.positionals[0] ?? flagString(ctx.args, 'name');
29
138
 
30
- /** `x db branch <name>` on a real Postgres: copy-on-write clone, cheap and disposable. */
31
- export function branchSql(source: string, branch: string): string {
32
- return `CREATE DATABASE "${branch}" TEMPLATE "${source}"`;
33
- }
139
+ if (sub === 'gen') return runGen(ctx, root, argument ?? 'change');
140
+ if (sub === 'migrate') return runMigrate(ctx, root, msg('cli.db.migrate.applied'));
141
+ if (sub === 'reset') return runReset(ctx, root);
142
+ if (sub === 'seed') return runSeed(ctx, root);
143
+ if (sub === 'studio') throw plannedSubcommand('db', 'studio');
144
+ if (sub === 'backfill') return runBackfill(ctx, root);
145
+ if (sub === 'branch') return runBranchCommand(ctx, root);
34
146
 
35
- export function branchDatabaseName(source: string, branch: string): string {
36
- return `${source}_branch_${branch.replace(/[^a-zA-Z0-9_]/g, '_')}`;
147
+ // Never a fall-through. This used to end `return runBranch(ctx, root, argument ?? 'preview')`,
148
+ // so ANY subcommand the parser had not already refused was reinterpreted as a branch NAME and
149
+ // cloned a database out of it. A word this command does not know is a refusal, not a guess.
150
+ throw new UnknownCommandError({
151
+ path: `db ${sub}`,
152
+ known: DB_SUBCOMMANDS,
153
+ // Help, and not the nearest name: `studio` is planned, and `branch`/`backfill` both need a
154
+ // word this refusal does not have — a suggestion that refuses in turn is not a fix.
155
+ suggestion: 'help db',
156
+ });
157
+ },
158
+ };
159
+
160
+ /**
161
+ * Source in, files out — no database is opened, so this answers the same in CI and on a laptop
162
+ * with nothing running. A diff that finds nothing writes no MIGRATION and still exits 0: "no
163
+ * change" is an answer, and an empty migration would take a ledger row and a checksum forever. It
164
+ * may still write the `.hash` sidecar `x verify`'s `drift` step reads, which is what makes
165
+ * `X_DB_DRIFT`'s `fix:` — this command — a real instruction rather than a no-op.
166
+ *
167
+ * So there are THREE answers, not two, and `--json` carries `outcome` on every one: collapsing
168
+ * `hash-recorded` into either neighbour tells the machine reading this output that a migration
169
+ * exists when none does, or that nothing was written when the sidecar was.
170
+ */
171
+ async function runGen(ctx: CommandContext, root: string, name: string): Promise<CommandResult> {
172
+ let generated: Awaited<ReturnType<typeof generateAppMigration>>;
173
+ try {
174
+ generated = await generateAppMigration(root, {
175
+ name,
176
+ allowDestructive: flagBool(ctx.args, 'allow-destructive'),
177
+ });
178
+ } catch (error) {
179
+ return {
180
+ ok: false,
181
+ command: 'db',
182
+ summary: msg('cli.db.gen.failed'),
183
+ findings: [stepFinding(error, 'X_DB_GEN_FAILED')],
184
+ };
185
+ }
186
+ const migration = generated.migration;
187
+ if (migration === undefined) {
188
+ return {
189
+ ok: generated.findings.length === 0,
190
+ command: 'db',
191
+ // The sidecar path, never a bare id: `hash-recorded` writes exactly one, and it is the file
192
+ // the `drift` step reads back.
193
+ summary:
194
+ generated.outcome === 'hash-recorded'
195
+ ? msg('cli.db.gen.recorded', { file: generated.files[0] ?? '' })
196
+ : msg('cli.db.gen.unchanged'),
197
+ findings: generated.findings,
198
+ // `files` is what this command WROTE, so the empty array here was a false claim.
199
+ lines: generated.files.map((file) => ` ${file}`),
200
+ data: {
201
+ outcome: generated.outcome,
202
+ migration: null,
203
+ files: [...generated.files],
204
+ schemaHash: generated.schemaHash ?? null,
205
+ },
206
+ };
207
+ }
208
+ return {
209
+ ok: true,
210
+ command: 'db',
211
+ summary: msg('cli.db.gen.written', { id: migration.id }),
212
+ lines: generated.files.map((file) => ` ${file}`),
213
+ data: {
214
+ outcome: generated.outcome,
215
+ migration: migration.id,
216
+ name: migration.name,
217
+ files: [...generated.files],
218
+ schemaHash: generated.schemaHash ?? null,
219
+ },
220
+ };
37
221
  }
38
222
 
39
- export const previewUrl = (branch: string, port: number): string =>
40
- `http://${branch}.localhost:${port}`;
223
+ /**
224
+ * The post-migrate report, rendered. Through `driftError` rather than a second literal: the
225
+ * three-line `X_DB_DRIFT` output is pinned by the framework contract, and this command must not be
226
+ * where a copy of it drifts from the one `x verify` prints.
227
+ */
228
+ export const driftFindings = (report: DriftReport): readonly Finding[] =>
229
+ report.differences.map((difference) => findingFrom(driftError(difference)));
41
230
 
42
- async function runBranch(
231
+ /**
232
+ * `runMigrations` is `serve.ts`'s, unchanged and unwrapped: the developer applying a migration and
233
+ * the release-phase container applying it run the same function, over the same file list, through
234
+ * the same ledger, and verify the same post-condition — the live schema against that ledger. A
235
+ * database that migrated cleanly and still disagrees is the failure this command exists to
236
+ * surface, and only a check that opened the connection can see it.
237
+ *
238
+ * The *source* half — an entity edited with no migration generated — is `x verify`'s `drift` step
239
+ * (`checkSourceDrift`) and is deliberately not repeated here: two reporters of one condition is the
240
+ * duplication this package's own rule forbids, and that one needs no database at all.
241
+ */
242
+ async function runMigrate(
43
243
  ctx: CommandContext,
44
244
  root: string,
45
- branch: string,
245
+ summary: string,
46
246
  ): Promise<CommandResult> {
247
+ try {
248
+ const migrated = await runMigrations({ root, env: ctx.env });
249
+ const report = migrated.report;
250
+ return {
251
+ ok: migrated.drift.ok,
252
+ command: 'db',
253
+ summary,
254
+ findings: driftFindings(migrated.drift),
255
+ data: {
256
+ applied: report.applied.map((entry) => entry.id),
257
+ skipped: report.skipped.length,
258
+ appVersion: report.appVersion,
259
+ durationMs: report.durationMs,
260
+ },
261
+ };
262
+ } catch (error) {
263
+ return {
264
+ ok: false,
265
+ command: 'db',
266
+ summary: msg('cli.db.migrate.failed'),
267
+ findings: [stepFinding(error, 'X_DB_MIGRATE_FAILED')],
268
+ };
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Embedded only: `rm -rf` against a database this process does not own is not a reset, it is an
274
+ * outage. The data directory goes before the migrator starts, so the run that follows is a fresh
275
+ * database with an empty ledger rather than a re-apply over a live one.
276
+ */
277
+ async function runReset(ctx: CommandContext, root: string): Promise<CommandResult> {
47
278
  const services = resolveServices(root, ctx.env);
48
- const port = Number.parseInt(ctx.env['PORT'] ?? '3000', 10);
49
- const url = previewUrl(branch, port);
50
- if (services.db.mode === 'embedded') {
51
- // @ultimat3/db owns embedded branching, name validation and the on-disk layout. Shelling out
52
- // to `cp` here was a second implementation of all three — and `--reflink` is a GNU-only flag.
53
- try {
54
- const info = await branchPglite(branch, { from: services.db.url });
55
- return {
56
- ok: true,
57
- command: 'db',
58
- summary: msg('cli.db.branch.ready', { name: branch }),
59
- data: { branch, database: info.dataDir, preview: url, mode: 'embedded' },
60
- };
61
- } catch (error) {
62
- return {
63
- ok: false,
64
- command: 'db',
65
- summary: msg('cli.usage'),
66
- findings: [findingFrom(error)],
67
- };
68
- }
279
+ if (services.db.mode === 'external') {
280
+ throw new CliNotImplementedError({
281
+ feature: 'x db reset against an external Postgres',
282
+ fix: 'drop and recreate the database yourself, then run: x db migrate',
283
+ });
69
284
  }
70
- const source = services.db.url.split('/').at(-1) ?? 'postgres';
71
- const database = branchDatabaseName(source, branch);
72
- const psql = await ctx.runner(['psql', services.db.url, '-c', branchSql(source, database)], {
73
- cwd: root,
285
+ await rm(join(services.stateDir, 'pgdata'), { recursive: true, force: true });
286
+ return runMigrate(ctx, root, msg('cli.db.reset.done'));
287
+ }
288
+
289
+ /**
290
+ * `x db seed [<name>]` — the fixture graph, applied and replayable.
291
+ *
292
+ * The environment is resolved BEFORE anything is imported or connected: a run this environment does
293
+ * not take must refuse without having opened a connection to the database it was refusing to write
294
+ * to. `selectSeeds` asks the same question a second time, on the seeds themselves, because seeding
295
+ * is the one irreversible thing this command does (`db-seed.ts`).
296
+ *
297
+ * `withJobDriver` is the boot, though nothing here claims a job: it is the CLI's one answer to
298
+ * "which database is this command talking to", and it also puts a real queue behind any
299
+ * `handle.enqueue()` a seeded write triggers. A second boot path would be a second answer.
300
+ */
301
+ async function runSeed(ctx: CommandContext, root: string): Promise<CommandResult> {
302
+ const environment = resolveEnvironment({ env: ctx.env });
303
+ const requested = parseSeedTierFlag(
304
+ flagString(ctx.args, 'tier') ?? ctx.env['ULTIMATE_SEED_TIER'],
305
+ );
306
+ const name = ctx.args.positionals[0] ?? flagString(ctx.args, 'name');
307
+ const dryRun = flagBool(ctx.args, 'dry-run');
308
+ const discovery = await discoverSeeds(root);
309
+ const chosen = selectSeeds({
310
+ discovered: discovery.seeds,
311
+ ...(name === undefined ? {} : { name }),
312
+ environment,
313
+ requested,
74
314
  });
75
- if (!psql.ok) {
315
+ if (chosen.length === 0) {
76
316
  return {
77
- ok: false,
317
+ ok: true,
78
318
  command: 'db',
79
- summary: msg('cli.usage'),
80
- findings: [
81
- failure(
82
- psql,
83
- 'X_DB_BRANCH_FAILED',
84
- `close open connections to "${source}" (a TEMPLATE clone needs none), then retry`,
85
- ),
86
- ],
319
+ summary: msg('cli.db.seed.none'),
320
+ findings: discovery.findings,
321
+ data: seedPassToJson([]),
87
322
  };
88
323
  }
324
+ return withJobDriver(root, ctx, async () => {
325
+ const rows = await runSeeds({
326
+ seeds: chosen,
327
+ driver: postgresDriver(),
328
+ dryRun,
329
+ env: ctx.env,
330
+ // One transaction per seed, so a seed that throws takes only its own rows with it.
331
+ transaction: (work) => withTransaction(() => work()),
332
+ });
333
+ return seedPassResult(rows, dryRun, discovery.findings);
334
+ });
335
+ }
336
+
337
+ function seedPassResult(
338
+ rows: readonly SeedPassRow[],
339
+ dryRun: boolean,
340
+ findings: readonly Finding[],
341
+ ): CommandResult {
342
+ const totals = seedTotals(rows);
343
+ const failures = rows.flatMap((row) => (row.finding === null ? [] : [row.finding]));
89
344
  return {
90
- ok: true,
345
+ ok: failures.length === 0 && findings.length === 0,
91
346
  command: 'db',
92
- summary: msg('cli.db.branch.ready', { name: branch }),
93
- data: { branch, database, preview: url, mode: 'external' },
347
+ summary:
348
+ totals.failed > 0
349
+ ? msg('cli.db.seed.failed', { failed: totals.failed, count: rows.length })
350
+ : dryRun
351
+ ? msg('cli.db.seed.dryRun', { count: rows.length })
352
+ : msg('cli.db.seed.done', {
353
+ count: rows.length,
354
+ inserted: totals.inserted,
355
+ updated: totals.updated,
356
+ skipped: totals.skipped,
357
+ }),
358
+ findings: [...failures, ...findings],
359
+ lines: renderSeedTable(rows).map((line) => ` ${line}`),
360
+ data: seedPassToJson(rows),
94
361
  };
95
362
  }
96
363
 
97
- export const dbCommand: CliCommand = {
98
- spec: {
99
- name: 'db',
100
- summary: 'gen, migrate, reset, studio, branch',
101
- usage: 'x db gen "add publish_at" | migrate | reset | studio | branch <name>',
102
- requiresApp: true,
103
- subcommands: DB_SUBCOMMANDS,
104
- flags: [{ name: 'name', type: 'string', summary: 'migration or branch name' }],
105
- },
106
- async run(ctx: CommandContext): Promise<CommandResult> {
107
- const root = requireAppRoot('db', ctx.cwd).dir;
108
- const sub = ctx.args.subcommand ?? 'migrate';
109
- const argument = ctx.args.positionals[0] ?? flagString(ctx.args, 'name');
364
+ /**
365
+ * Four shapes, one subcommand: `--list` reads the `x_backfills` ledger, `--pending` diffs it
366
+ * against what the app DECLARED, and `<name>` / `--all` gate a pass and put it on the queue. A
367
+ * bare `x db backfill` is still refused rather than defaulted — the four answer four different
368
+ * questions, and picking one for the operator is the ambiguity axiom 1 exists to refuse.
369
+ *
370
+ * An empty ledger is `ok: true`. "Nothing has swept this database yet" is an answer to the
371
+ * question asked, and a command that failed over it would be unrunnable on a fresh app.
372
+ */
373
+ async function runBackfill(ctx: CommandContext, root: string): Promise<CommandResult> {
374
+ if (flagBool(ctx.args, 'list')) return runBackfillList(ctx, root);
375
+ const all = flagBool(ctx.args, 'all');
376
+ const name = ctx.args.positionals[0] ?? flagString(ctx.args, 'name');
377
+ if (flagBool(ctx.args, 'pending')) return runBackfillPending(ctx, root);
378
+ if (all) return runBackfillPass(ctx, root, 'all');
379
+ if (name !== undefined) return runBackfillPass(ctx, root, [name]);
380
+ throw new BadFlagError({
381
+ flag: 'list',
382
+ command: 'db',
383
+ reason:
384
+ 'x db backfill needs a shape: --list (the ledger), --pending (declared minus completed), <name> or --all (run one, or every pending one)',
385
+ fix: 'x db backfill --pending --json',
386
+ });
387
+ }
110
388
 
111
- if (sub === 'gen') {
112
- const name = (argument ?? 'change').replace(/[^a-zA-Z0-9]+/g, '_').toLowerCase();
113
- const result = await ctx.runner(['bunx', 'drizzle-kit', 'generate', '--name', name], {
114
- cwd: root,
115
- });
116
- if (!result.ok) {
117
- return {
118
- ok: false,
119
- command: 'db',
120
- summary: msg('cli.usage'),
121
- findings: [failure(result, 'X_DB_GEN_FAILED', 'x doctor --json')],
122
- };
123
- }
124
- const hash = await writeSchemaHash(root, latestMigration(root, name));
125
- return {
126
- ok: true,
127
- command: 'db',
128
- summary: `migration ${name} generated`,
129
- data: { migration: name, schemaHash: hash },
130
- };
131
- }
132
-
133
- if (sub === 'migrate') {
134
- const result = await ctx.runner(['bunx', 'drizzle-kit', 'migrate'], { cwd: root });
135
- const drift = await checkDrift(root);
136
- return {
137
- ok: result.ok && drift.length === 0,
138
- command: 'db',
139
- summary: result.ok ? 'migrations applied' : 'migration failed',
140
- findings: result.ok ? drift : [failure(result, 'X_DB_MIGRATE_FAILED', 'x db reset')],
141
- data: { applied: result.ok },
142
- };
143
- }
144
-
145
- if (sub === 'reset') {
146
- const services = resolveServices(root, ctx.env);
147
- if (services.db.mode === 'external') {
148
- throw new CliNotImplementedError({
149
- feature: 'x db reset against an external Postgres',
150
- fix: 'drop and recreate the database yourself, then run: x db migrate',
151
- });
152
- }
153
- await rm(join(services.stateDir, 'pgdata'), { recursive: true, force: true });
154
- const migrate = await ctx.runner(['bunx', 'drizzle-kit', 'migrate'], { cwd: root });
155
- return {
156
- ok: migrate.ok,
157
- command: 'db',
158
- summary: migrate.ok ? 'database reset and migrated' : 'reset failed',
159
- findings: migrate.ok ? [] : [failure(migrate, 'X_DB_MIGRATE_FAILED', 'x doctor --json')],
160
- data: { stateDir: services.stateDir },
161
- };
162
- }
163
-
164
- if (sub === 'studio') {
165
- const result = await ctx.runner(['bunx', 'drizzle-kit', 'studio'], { cwd: root });
166
- return {
167
- ok: result.ok,
168
- command: 'db',
169
- summary: result.ok ? 'studio exited' : 'studio failed to start',
170
- findings: result.ok ? [] : [failure(result, 'X_DB_STUDIO_FAILED', 'x doctor --json')],
171
- };
172
- }
173
-
174
- return runBranch(ctx, root, argument ?? 'preview');
175
- },
176
- };
389
+ async function runBackfillList(ctx: CommandContext, root: string): Promise<CommandResult> {
390
+ return withJobDriver(root, ctx, async (driver) => {
391
+ const rows = await listBackfills(driver, {
392
+ name: flagString(ctx.args, 'name'),
393
+ status: flagString(ctx.args, 'status'),
394
+ limit: flagString(ctx.args, 'limit'),
395
+ });
396
+ return {
397
+ ok: true,
398
+ command: 'db',
399
+ summary:
400
+ rows.length === 0
401
+ ? msg('cli.db.backfill.empty')
402
+ : msg('cli.db.backfill.listed', { count: rows.length }),
403
+ lines: rows.length === 0 ? [] : renderBackfillTable(rows).map((line) => ` ${line}`),
404
+ data: rows.map(backfillToJson),
405
+ };
406
+ });
407
+ }
408
+
409
+ /**
410
+ * The alarm the framework did not have. Non-zero when anything is unswept, so a cron or a deploy
411
+ * check can read the exit code — a `--json` nobody has to parse to know something is wrong.
412
+ * `loadApp` first: importing the app's modules IS the declaration, and a diff run without it
413
+ * would report a clean database against an empty declaration list.
414
+ */
415
+ async function runBackfillPending(ctx: CommandContext, root: string): Promise<CommandResult> {
416
+ await loadApp(root);
417
+ const environment = resolveEnvironment({ env: ctx.env });
418
+ return withJobDriver(root, ctx, async (driver) => {
419
+ const report = await pendingReport(driver, environment);
420
+ return {
421
+ ok: report.pending.length === 0,
422
+ command: 'db',
423
+ summary:
424
+ report.pending.length === 0
425
+ ? msg('cli.db.backfill.swept', { declared: report.rows.length })
426
+ : msg('cli.db.backfill.pending', {
427
+ count: report.pending.length,
428
+ declared: report.rows.length,
429
+ }),
430
+ findings: report.pending.map((row) =>
431
+ findingFrom(new BackfillPendingError({ backfill: row.name, environment })),
432
+ ),
433
+ lines: report.rows.length === 0 ? [] : renderPendingTable(report).map((line) => ` ${line}`),
434
+ data: pendingToJson(report),
435
+ };
436
+ });
437
+ }
177
438
 
178
- /** drizzle-kit names files `<index>_<name>.sql`; the hash sidecar has to match that base name. */
179
- function latestMigration(root: string, name: string): string {
180
- const dir = join(root, 'packages', 'db', 'migrations');
181
- if (!existsSync(dir)) return `0000_${name}`;
182
- const matches = readdirSync(dir)
183
- .filter((file) => file.endsWith(`_${name}.sql`))
184
- .sort();
185
- const newest = matches.at(-1);
186
- return newest === undefined ? `0000_${name}` : newest.replace(/\.sql$/, '');
439
+ /**
440
+ * DRY RUN by default: `--write` is never implied, because the alternative is a command whose
441
+ * inspection form writes to a production table. What `--write` does is ENQUEUE — the queue is a
442
+ * job's execution surface, so the sweep runs on the workers already serving the new release
443
+ * rather than inside this process.
444
+ */
445
+ async function runBackfillPass(
446
+ ctx: CommandContext,
447
+ root: string,
448
+ names: readonly string[] | 'all',
449
+ ): Promise<CommandResult> {
450
+ await loadApp(root);
451
+ const environment = resolveEnvironment({ env: ctx.env });
452
+ const write = flagBool(ctx.args, 'write');
453
+ return withJobDriver(root, ctx, async (driver) => {
454
+ const rows = await runBackfills({
455
+ driver,
456
+ names,
457
+ write,
458
+ force: flagBool(ctx.args, 'force'),
459
+ environment,
460
+ appliedMigrations: await readAppliedMigrations(),
461
+ });
462
+ return backfillPassResult(rows, write);
463
+ });
464
+ }
465
+
466
+ /**
467
+ * A blocked or deduped name is a finding and a non-zero exit, and every OTHER name still ran —
468
+ * that isolation is what stops one wedged cleanup blocking every later one forever.
469
+ */
470
+ function backfillPassResult(rows: readonly BackfillPlanRow[], write: boolean): CommandResult {
471
+ const findings = rows.flatMap((row) => (row.finding === null ? [] : [row.finding]));
472
+ // Counted per action, never derived from the total: a deduped pass is neither enqueued nor
473
+ // blocked, and `rows.length - enqueued` reported it as blocked while `--json` reported it as
474
+ // deduped. `planToJson` is the same list, so the two renders now add up to the same run.
475
+ const tally = (action: BackfillAction): number =>
476
+ rows.filter((row) => row.action === action).length;
477
+ return {
478
+ ok: findings.length === 0,
479
+ command: 'db',
480
+ summary: write
481
+ ? msg('cli.db.backfill.planned', {
482
+ count: rows.length,
483
+ enqueued: tally('enqueued'),
484
+ deduped: tally('deduped'),
485
+ blocked: tally('blocked'),
486
+ })
487
+ : msg('cli.db.backfill.dryRun', { count: rows.length }),
488
+ findings,
489
+ lines: rows.length === 0 ? [] : renderPlanTable(rows).map((line) => ` ${line}`),
490
+ data: planToJson(rows),
491
+ };
187
492
  }