@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
package/src/cmd-db.ts CHANGED
@@ -1,187 +1,364 @@
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|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 } from '@ultimat3/db';
16
+ import { BackfillPendingError } from '@ultimat3/jobs';
17
+ import { loadApp } from './app-load';
9
18
  import { requireAppRoot } from './app-root';
19
+ import { runBranchCommand } from './cmd-db-branch';
20
+ import { plannedSubcommand } from './cmd-planned';
10
21
  import type { CliCommand, CommandContext } from './command';
22
+ import type { BackfillAction, BackfillPlanRow } from './db-backfill';
23
+ import {
24
+ listBackfills,
25
+ pendingReport,
26
+ pendingToJson,
27
+ planToJson,
28
+ readAppliedMigrations,
29
+ renderBackfillTable,
30
+ renderPendingTable,
31
+ renderPlanTable,
32
+ runBackfills,
33
+ } from './db-backfill';
34
+ import { BRANCH_SUBCOMMANDS } from './db-branch';
35
+ import { stepFinding } from './db-finding';
36
+ import { generateAppMigration } from './db-generate';
11
37
  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';
38
+ import {
39
+ BadFlagError,
40
+ CliNotImplementedError,
41
+ MissingSubcommandError,
42
+ UnknownCommandError,
43
+ } from './errors';
44
+ import { withJobDriver } from './jobs-driver';
45
+ import { backfillToJson } from './jobs-json';
16
46
  import { msg } from './messages';
17
47
  import type { CommandResult, Finding } from './output';
18
48
  import { findingFrom } from './output';
19
- import { flagString } from './parse';
49
+ import { flagBool, flagString } from './parse';
50
+ import { runMigrations } from './serve';
20
51
 
21
- export const DB_SUBCOMMANDS = ['gen', 'migrate', 'reset', 'studio', 'branch'] as const;
52
+ export const DB_SUBCOMMANDS = ['gen', 'migrate', 'reset', 'studio', 'branch', 'backfill'] as const;
22
53
 
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
- });
29
-
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
- }
54
+ export const dbCommand: CliCommand = {
55
+ spec: {
56
+ name: 'db',
57
+ summary: 'gen, migrate, reset, studio, branch, backfill',
58
+ usage:
59
+ 'x db gen "add publish_at" | migrate | reset | studio | branch ls | branch create <name> | branch drop <name> | backfill [<name>|--all] [--write] [--force] | backfill --pending | backfill --list [--name n] [--status s] [--limit n]',
60
+ requiresApp: true,
61
+ subcommands: DB_SUBCOMMANDS,
62
+ // Declared from the constant `runBranchCommand` validates against, never a second literal: it
63
+ // is what lets the `errors` step resolve `x db branch ls` — a fix line three shipped errors
64
+ // hand out, which read `ls` as a branch name and cloned a database until 1.2.x.
65
+ subcommandPositionals: { branch: BRANCH_SUBCOMMANDS },
66
+ flags: [
67
+ { name: 'name', type: 'string', summary: 'migration or branch name, or backfill to filter' },
68
+ { name: 'list', type: 'boolean', summary: 'backfill: print the x_backfills ledger' },
69
+ {
70
+ name: 'pending',
71
+ type: 'boolean',
72
+ summary: 'backfill: declared minus completed; non-zero exit when anything is unswept',
73
+ },
74
+ { name: 'all', type: 'boolean', summary: 'backfill: every pending sweep, isolated per name' },
75
+ { name: 'write', type: 'boolean', summary: 'backfill: enqueue the pass; dry run without it' },
76
+ {
77
+ name: 'force',
78
+ type: 'boolean',
79
+ summary: 'backfill: sweep a name the ledger records as completed, as a NEW ledger row',
80
+ },
81
+ {
82
+ name: 'status',
83
+ type: 'string',
84
+ summary: 'backfill: filter by running, completed or failed',
85
+ },
86
+ { name: 'limit', type: 'string', summary: 'backfill: max ledger rows to return' },
87
+ // Declared because `X_MIGRATION_IRREVERSIBLE`'s own fix line names it. A `fix:` is copied
88
+ // and run verbatim, so a flag the parser refuses would make the error unfollowable.
89
+ {
90
+ name: 'allow-destructive',
91
+ type: 'boolean',
92
+ summary: 'let x db gen emit a drop whose down cannot restore the rows',
93
+ },
94
+ ],
95
+ },
96
+ async run(ctx: CommandContext): Promise<CommandResult> {
97
+ const root = requireAppRoot('db', ctx.cwd).dir;
98
+ // No default, and no `?? 'migrate'` here either: `gen` writes a migration file and `reset`
99
+ // drops the database, so "whatever the caller left out" is not a safe guess for any of the six.
100
+ // The parser refuses a bare `x db`; this covers a `ParsedArgs` built by hand.
101
+ const sub = ctx.args.subcommand;
102
+ if (sub === undefined)
103
+ throw new MissingSubcommandError({ command: 'db', known: DB_SUBCOMMANDS });
104
+ const argument = ctx.args.positionals[0] ?? flagString(ctx.args, 'name');
34
105
 
35
- export function branchDatabaseName(source: string, branch: string): string {
36
- return `${source}_branch_${branch.replace(/[^a-zA-Z0-9_]/g, '_')}`;
37
- }
106
+ if (sub === 'gen') return runGen(ctx, root, argument ?? 'change');
107
+ if (sub === 'migrate') return runMigrate(ctx, root, msg('cli.db.migrate.applied'));
108
+ if (sub === 'reset') return runReset(ctx, root);
109
+ if (sub === 'studio') throw plannedSubcommand('db', 'studio');
110
+ if (sub === 'backfill') return runBackfill(ctx, root);
111
+ if (sub === 'branch') return runBranchCommand(ctx, root);
38
112
 
39
- export const previewUrl = (branch: string, port: number): string =>
40
- `http://${branch}.localhost:${port}`;
113
+ // Never a fall-through. This used to end `return runBranch(ctx, root, argument ?? 'preview')`,
114
+ // so ANY subcommand the parser had not already refused was reinterpreted as a branch NAME and
115
+ // cloned a database out of it. A word this command does not know is a refusal, not a guess.
116
+ throw new UnknownCommandError({
117
+ path: `db ${sub}`,
118
+ known: DB_SUBCOMMANDS,
119
+ // Help, and not the nearest name: `studio` is planned, and `branch`/`backfill` both need a
120
+ // word this refusal does not have — a suggestion that refuses in turn is not a fix.
121
+ suggestion: 'help db',
122
+ });
123
+ },
124
+ };
41
125
 
42
- async function runBranch(
43
- ctx: CommandContext,
44
- root: string,
45
- branch: string,
46
- ): Promise<CommandResult> {
47
- 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
- }
69
- }
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,
74
- });
75
- if (!psql.ok) {
126
+ /**
127
+ * Source in, files out — no database is opened, so this answers the same in CI and on a laptop
128
+ * with nothing running. A diff that finds nothing writes nothing and still exits 0: "no change" is
129
+ * an answer, and an empty migration would take a ledger row and a checksum forever.
130
+ */
131
+ async function runGen(ctx: CommandContext, root: string, name: string): Promise<CommandResult> {
132
+ let generated: Awaited<ReturnType<typeof generateAppMigration>>;
133
+ try {
134
+ generated = await generateAppMigration(root, {
135
+ name,
136
+ allowDestructive: flagBool(ctx.args, 'allow-destructive'),
137
+ });
138
+ } catch (error) {
76
139
  return {
77
140
  ok: false,
78
141
  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
- ],
142
+ summary: msg('cli.db.gen.failed'),
143
+ findings: [stepFinding(error, 'X_DB_GEN_FAILED')],
144
+ };
145
+ }
146
+ const migration = generated.migration;
147
+ if (migration === undefined) {
148
+ return {
149
+ ok: generated.findings.length === 0,
150
+ command: 'db',
151
+ summary: msg('cli.db.gen.unchanged'),
152
+ findings: generated.findings,
153
+ data: { migration: null, files: [] },
87
154
  };
88
155
  }
89
156
  return {
90
157
  ok: true,
91
158
  command: 'db',
92
- summary: msg('cli.db.branch.ready', { name: branch }),
93
- data: { branch, database, preview: url, mode: 'external' },
159
+ summary: msg('cli.db.gen.written', { id: migration.id }),
160
+ lines: generated.files.map((file) => ` ${file}`),
161
+ data: {
162
+ migration: migration.id,
163
+ name: migration.name,
164
+ files: [...generated.files],
165
+ schemaHash: generated.schemaHash ?? null,
166
+ },
94
167
  };
95
168
  }
96
169
 
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');
170
+ /**
171
+ * The post-migrate report, rendered. Through `driftError` rather than a second literal: the
172
+ * three-line `X_DB_DRIFT` output is pinned by the framework contract, and this command must not be
173
+ * where a copy of it drifts from the one `x verify` prints.
174
+ */
175
+ export const driftFindings = (report: DriftReport): readonly Finding[] =>
176
+ report.differences.map((difference) => findingFrom(driftError(difference)));
177
+
178
+ /**
179
+ * `runMigrations` is `serve.ts`'s, unchanged and unwrapped: the developer applying a migration and
180
+ * the release-phase container applying it run the same function, over the same file list, through
181
+ * the same ledger, and verify the same post-condition — the live schema against that ledger. A
182
+ * database that migrated cleanly and still disagrees is the failure this command exists to
183
+ * surface, and only a check that opened the connection can see it.
184
+ *
185
+ * The *source* half — an entity edited with no migration generated — is `x verify`'s `drift` step
186
+ * (`checkSourceDrift`) and is deliberately not repeated here: two reporters of one condition is the
187
+ * duplication this package's own rule forbids, and that one needs no database at all.
188
+ */
189
+ async function runMigrate(
190
+ ctx: CommandContext,
191
+ root: string,
192
+ summary: string,
193
+ ): Promise<CommandResult> {
194
+ try {
195
+ const migrated = await runMigrations({ root, env: ctx.env });
196
+ const report = migrated.report;
197
+ return {
198
+ ok: migrated.drift.ok,
199
+ command: 'db',
200
+ summary,
201
+ findings: driftFindings(migrated.drift),
202
+ data: {
203
+ applied: report.applied.map((entry) => entry.id),
204
+ skipped: report.skipped.length,
205
+ appVersion: report.appVersion,
206
+ durationMs: report.durationMs,
207
+ },
208
+ };
209
+ } catch (error) {
210
+ return {
211
+ ok: false,
212
+ command: 'db',
213
+ summary: msg('cli.db.migrate.failed'),
214
+ findings: [stepFinding(error, 'X_DB_MIGRATE_FAILED')],
215
+ };
216
+ }
217
+ }
110
218
 
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
- }
219
+ /**
220
+ * Embedded only: `rm -rf` against a database this process does not own is not a reset, it is an
221
+ * outage. The data directory goes before the migrator starts, so the run that follows is a fresh
222
+ * database with an empty ledger rather than a re-apply over a live one.
223
+ */
224
+ async function runReset(ctx: CommandContext, root: string): Promise<CommandResult> {
225
+ const services = resolveServices(root, ctx.env);
226
+ if (services.db.mode === 'external') {
227
+ throw new CliNotImplementedError({
228
+ feature: 'x db reset against an external Postgres',
229
+ fix: 'drop and recreate the database yourself, then run: x db migrate',
230
+ });
231
+ }
232
+ await rm(join(services.stateDir, 'pgdata'), { recursive: true, force: true });
233
+ return runMigrate(ctx, root, msg('cli.db.reset.done'));
234
+ }
132
235
 
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
- }
236
+ /**
237
+ * Four shapes, one subcommand: `--list` reads the `x_backfills` ledger, `--pending` diffs it
238
+ * against what the app DECLARED, and `<name>` / `--all` gate a pass and put it on the queue. A
239
+ * bare `x db backfill` is still refused rather than defaulted — the four answer four different
240
+ * questions, and picking one for the operator is the ambiguity axiom 1 exists to refuse.
241
+ *
242
+ * An empty ledger is `ok: true`. "Nothing has swept this database yet" is an answer to the
243
+ * question asked, and a command that failed over it would be unrunnable on a fresh app.
244
+ */
245
+ async function runBackfill(ctx: CommandContext, root: string): Promise<CommandResult> {
246
+ if (flagBool(ctx.args, 'list')) return runBackfillList(ctx, root);
247
+ const all = flagBool(ctx.args, 'all');
248
+ const name = ctx.args.positionals[0] ?? flagString(ctx.args, 'name');
249
+ if (flagBool(ctx.args, 'pending')) return runBackfillPending(ctx, root);
250
+ if (all) return runBackfillPass(ctx, root, 'all');
251
+ if (name !== undefined) return runBackfillPass(ctx, root, [name]);
252
+ throw new BadFlagError({
253
+ flag: 'list',
254
+ command: 'db',
255
+ reason:
256
+ 'x db backfill needs a shape: --list (the ledger), --pending (declared minus completed), <name> or --all (run one, or every pending one)',
257
+ fix: 'x db backfill --pending --json',
258
+ });
259
+ }
144
260
 
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
- }
261
+ async function runBackfillList(ctx: CommandContext, root: string): Promise<CommandResult> {
262
+ return withJobDriver(root, ctx, async (driver) => {
263
+ const rows = await listBackfills(driver, {
264
+ name: flagString(ctx.args, 'name'),
265
+ status: flagString(ctx.args, 'status'),
266
+ limit: flagString(ctx.args, 'limit'),
267
+ });
268
+ return {
269
+ ok: true,
270
+ command: 'db',
271
+ summary:
272
+ rows.length === 0
273
+ ? msg('cli.db.backfill.empty')
274
+ : msg('cli.db.backfill.listed', { count: rows.length }),
275
+ lines: rows.length === 0 ? [] : renderBackfillTable(rows).map((line) => ` ${line}`),
276
+ data: rows.map(backfillToJson),
277
+ };
278
+ });
279
+ }
163
280
 
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
- }
281
+ /**
282
+ * The alarm the framework did not have. Non-zero when anything is unswept, so a cron or a deploy
283
+ * check can read the exit code — a `--json` nobody has to parse to know something is wrong.
284
+ * `loadApp` first: importing the app's modules IS the declaration, and a diff run without it
285
+ * would report a clean database against an empty declaration list.
286
+ */
287
+ async function runBackfillPending(ctx: CommandContext, root: string): Promise<CommandResult> {
288
+ await loadApp(root);
289
+ const environment = resolveEnvironment({ env: ctx.env });
290
+ return withJobDriver(root, ctx, async (driver) => {
291
+ const report = await pendingReport(driver, environment);
292
+ return {
293
+ ok: report.pending.length === 0,
294
+ command: 'db',
295
+ summary:
296
+ report.pending.length === 0
297
+ ? msg('cli.db.backfill.swept', { declared: report.rows.length })
298
+ : msg('cli.db.backfill.pending', {
299
+ count: report.pending.length,
300
+ declared: report.rows.length,
301
+ }),
302
+ findings: report.pending.map((row) =>
303
+ findingFrom(new BackfillPendingError({ backfill: row.name, environment })),
304
+ ),
305
+ lines: report.rows.length === 0 ? [] : renderPendingTable(report).map((line) => ` ${line}`),
306
+ data: pendingToJson(report),
307
+ };
308
+ });
309
+ }
173
310
 
174
- return runBranch(ctx, root, argument ?? 'preview');
175
- },
176
- };
311
+ /**
312
+ * DRY RUN by default: `--write` is never implied, because the alternative is a command whose
313
+ * inspection form writes to a production table. What `--write` does is ENQUEUE — the queue is a
314
+ * job's execution surface, so the sweep runs on the workers already serving the new release
315
+ * rather than inside this process.
316
+ */
317
+ async function runBackfillPass(
318
+ ctx: CommandContext,
319
+ root: string,
320
+ names: readonly string[] | 'all',
321
+ ): Promise<CommandResult> {
322
+ await loadApp(root);
323
+ const environment = resolveEnvironment({ env: ctx.env });
324
+ const write = flagBool(ctx.args, 'write');
325
+ return withJobDriver(root, ctx, async (driver) => {
326
+ const rows = await runBackfills({
327
+ driver,
328
+ names,
329
+ write,
330
+ force: flagBool(ctx.args, 'force'),
331
+ environment,
332
+ appliedMigrations: await readAppliedMigrations(),
333
+ });
334
+ return backfillPassResult(rows, write);
335
+ });
336
+ }
177
337
 
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$/, '');
338
+ /**
339
+ * A blocked or deduped name is a finding and a non-zero exit, and every OTHER name still ran —
340
+ * that isolation is what stops one wedged cleanup blocking every later one forever.
341
+ */
342
+ function backfillPassResult(rows: readonly BackfillPlanRow[], write: boolean): CommandResult {
343
+ const findings = rows.flatMap((row) => (row.finding === null ? [] : [row.finding]));
344
+ // Counted per action, never derived from the total: a deduped pass is neither enqueued nor
345
+ // blocked, and `rows.length - enqueued` reported it as blocked while `--json` reported it as
346
+ // deduped. `planToJson` is the same list, so the two renders now add up to the same run.
347
+ const tally = (action: BackfillAction): number =>
348
+ rows.filter((row) => row.action === action).length;
349
+ return {
350
+ ok: findings.length === 0,
351
+ command: 'db',
352
+ summary: write
353
+ ? msg('cli.db.backfill.planned', {
354
+ count: rows.length,
355
+ enqueued: tally('enqueued'),
356
+ deduped: tally('deduped'),
357
+ blocked: tally('blocked'),
358
+ })
359
+ : msg('cli.db.backfill.dryRun', { count: rows.length }),
360
+ findings,
361
+ lines: rows.length === 0 ? [] : renderPlanTable(rows).map((line) => ` ${line}`),
362
+ data: planToJson(rows),
363
+ };
187
364
  }
package/src/cmd-deploy.ts CHANGED
@@ -6,12 +6,35 @@ import { existsSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { requireAppRoot } from './app-root';
8
8
  import type { CliCommand, CommandContext } from './command';
9
- import { CliNotImplementedError } from './errors';
9
+ import { BadFlagError, CliNotImplementedError } from './errors';
10
10
  import { msg } from './messages';
11
11
  import type { CommandResult, JsonValue } from './output';
12
12
  import { flagBool, flagString } from './parse';
13
13
 
14
- export const DEPLOY_ROLES = ['migrate', 'web', 'sync', 'worker', 'scheduler'] as const;
14
+ /**
15
+ * Ordered, and the order is the design. `migrate` GATES — it runs to completion before anything
16
+ * serves, and a schema difference after it fails the deploy. `backfill` is last and TRIGGERS: a
17
+ * data sweep put inside a release gate holds the deploy open while a slow UPDATE runs against a
18
+ * database still serving the PREVIOUS release, so it runs after the new pods are up and the
19
+ * workers already draining the queue are what perform it. That is also why it is not wired into
20
+ * `runMigrations()` and never will be.
21
+ *
22
+ * `backfill` is a one-shot like `migrate`, so it takes the same `run --rm` shape; the compose
23
+ * service behind it runs `x db backfill --all --write --json` rather than a `ROLE`, because
24
+ * `@ultimat3/core`'s `ROLES` is a closed list of process shapes and a sweep trigger is a command.
25
+ *
26
+ * ORDER HERE IS NECESSARY AND NOT SUFFICIENT. `docker compose up -d` returns when a container has
27
+ * STARTED, not when the application inside it is serving, so this list alone puts the trigger after
28
+ * the serving roles were asked to start and not after they are ready. The barrier that makes
29
+ * "after" true is declarative and belongs to the compose file, not to this plan: the `backfill`
30
+ * service needs `depends_on: { web: { condition: service_healthy } }`, which `docker compose run`
31
+ * honours. Both compose definitions — `docker/docker-compose.prod.yml` and the one
32
+ * `templates/scaffold-container.ts` scaffolds — still owe that service and that condition.
33
+ */
34
+ export const DEPLOY_ROLES = ['migrate', 'web', 'sync', 'worker', 'scheduler', 'backfill'] as const;
35
+
36
+ /** The roles that run to completion and exit, as against the ones that stay up serving. */
37
+ const ONE_SHOT_ROLES: readonly string[] = ['migrate', 'backfill'];
15
38
 
16
39
  export interface DeployPlan {
17
40
  readonly image: string;
@@ -19,8 +42,39 @@ export interface DeployPlan {
19
42
  readonly steps: readonly { readonly role: string; readonly command: readonly string[] }[];
20
43
  }
21
44
 
45
+ /**
46
+ * The chart declares `image` as a MAP — `repository`, `tag`, `pullPolicy` — and `_helpers.tpl`
47
+ * renders `printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag)`.
48
+ * `--set image=<ref>` replaces that map with a string, so every workload template fails on
49
+ * `.repository` and the deploy that was asked to ship one image ships nothing. The reference is
50
+ * split into the two keys the chart actually reads; a reference with no tag sets only the
51
+ * repository, which leaves the chart's own `default .Chart.AppVersion` in force.
52
+ *
53
+ * The last `:` after the last `/`, because a registry may carry a port: `localhost:5000/app` is a
54
+ * repository with no tag and `localhost:5000/app:1.2.3` is the same repository with one.
55
+ */
56
+ export function helmImageOverrides(image: string): readonly string[] {
57
+ const colon = image.lastIndexOf(':');
58
+ const tag = colon > image.lastIndexOf('/') ? image.slice(colon + 1) : '';
59
+ const repository = tag === '' ? image : image.slice(0, colon);
60
+ return tag === ''
61
+ ? ['--set', `image.repository=${repository}`]
62
+ : ['--set', `image.repository=${repository}`, '--set', `image.tag=${tag}`];
63
+ }
64
+
22
65
  export function planDeploy(image: string, method: 'compose' | 'helm', root: string): DeployPlan {
23
66
  if (method === 'helm') {
67
+ // `repo@sha256:…` is a reference this chart cannot express: it renders `repository:tag` and
68
+ // has no digest branch, so passing one through would deploy `repo@sha256:…:<appVersion>` —
69
+ // a tag no registry has. Refused here rather than by a `helm upgrade` failing halfway.
70
+ if (image.lastIndexOf('@') > image.lastIndexOf('/')) {
71
+ throw new BadFlagError({
72
+ flag: 'image',
73
+ command: 'deploy',
74
+ reason: `"${image}" pins a digest, and docker/helm renders repository:tag with no digest branch`,
75
+ fix: `x deploy --method helm --image ${image.slice(0, image.lastIndexOf('@'))}:<tag> --json`,
76
+ });
77
+ }
24
78
  return {
25
79
  image,
26
80
  steps: [
@@ -32,8 +86,7 @@ export function planDeploy(image: string, method: 'compose' | 'helm', root: stri
32
86
  '--install',
33
87
  'app',
34
88
  join(root, 'docker', 'helm'),
35
- '--set',
36
- `image=${image}`,
89
+ ...helmImageOverrides(image),
37
90
  ],
38
91
  },
39
92
  ],
@@ -48,8 +101,8 @@ export function planDeploy(image: string, method: 'compose' | 'helm', root: stri
48
101
  'compose',
49
102
  '-f',
50
103
  join(root, 'docker', 'docker-compose.prod.yml'),
51
- role === 'migrate' ? 'run' : 'up',
52
- role === 'migrate' ? '--rm' : '-d',
104
+ ONE_SHOT_ROLES.includes(role) ? 'run' : 'up',
105
+ ONE_SHOT_ROLES.includes(role) ? '--rm' : '-d',
53
106
  role,
54
107
  ],
55
108
  })),