@ultimat3/cli 1.2.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 +83 -16
  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 +165 -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 +201 -138
  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 +84 -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 +4 -3
  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 +170 -10
  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,166 @@
1
+ // The island chunk table: every `*.island.tsx` in the app compiled as its OWN bundle entry point,
2
+ // content-hashed, plus the resolver that turns a page's `src` specifier into the URL its
3
+ // `data-x-entry` carries. One entry point per island is axiom 6 made mechanical — the page's graph
4
+ // never reaches an island, so a `site/` document stays at 0kb whatever the island imports.
5
+
6
+ // Bun ships no path API. `posix` does the specifier arithmetic (an app-relative route file is
7
+ // POSIX by construction), `join`/`basename` the filesystem side.
8
+ import { basename, join, posix, relative, sep } from 'node:path';
9
+ import {
10
+ contentHash,
11
+ ISLAND_EXTENSION,
12
+ IslandInvalidError,
13
+ islandModuleId,
14
+ } from '@ultimat3/render';
15
+ import { IslandBuildFailedError } from './errors';
16
+
17
+ /**
18
+ * Where a chunk is served from, in `x dev`, in the container and in a static export — one base
19
+ * path, because the URL is minted by one resolver and baked into the document. Sits beside
20
+ * `ICON_BASE_PATH` and `MEDIA_BASE_PATH`, deliberately outside the dev-only `/_x` namespace.
21
+ */
22
+ export const ISLAND_BASE_PATH = '/islands';
23
+
24
+ /**
25
+ * `shared/` is in and `api/` is out: an island is markup, and an API route has no document to put
26
+ * it in. The glob is the whole discovery rule — a file ships to the browser if and only if its
27
+ * name says so, which is what makes "what ships JS?" answerable without opening a file.
28
+ */
29
+ export const ISLAND_GLOB = `apps/*/{site,app,shared}/**/*${ISLAND_EXTENSION}`;
30
+
31
+ export interface IslandChunk {
32
+ /** App-root-relative POSIX path of the client entry it was built from. */
33
+ readonly file: string;
34
+ /** `islandModuleId` of the filename — the id the document, the budget and a finding all name. */
35
+ readonly moduleId: string;
36
+ /** Immutable, content-addressed URL. What `data-x-entry` carries and what a route serves. */
37
+ readonly url: string;
38
+ /** The built JavaScript. Held in memory so `x dev` and the container serve without a disk hop. */
39
+ readonly code: string;
40
+ readonly bytes: number;
41
+ }
42
+
43
+ export interface IslandBundle {
44
+ readonly chunks: readonly IslandChunk[];
45
+ /**
46
+ * The `resolve` a collector is built with, bound to the route file the specifier is relative to.
47
+ * Every island on that page goes through it, so an unbuildable specifier fails the render rather
48
+ * than emitting a `data-x-entry` no browser can import.
49
+ */
50
+ resolverFor(routeFile: string): (src: string) => string;
51
+ /** The chunk a URL names — for serving it, and for naming the island a budget finding blames. */
52
+ chunkAt(url: string): IslandChunk | undefined;
53
+ }
54
+
55
+ /** App-root-relative POSIX paths of every client entry, sorted, so a build is reproducible. */
56
+ export async function discoverIslands(root: string): Promise<readonly string[]> {
57
+ const files: string[] = [];
58
+ for await (const absolute of new Bun.Glob(ISLAND_GLOB).scan({ cwd: root, absolute: true })) {
59
+ if (absolute.includes('node_modules')) continue;
60
+ files.push(relative(root, absolute).split(sep).join('/'));
61
+ }
62
+ return files.sort();
63
+ }
64
+
65
+ /**
66
+ * One `Bun.build` per island, never one call with N entry points: splitting is off, so each chunk
67
+ * is self-contained and its size is the whole answer to "what does booting this island cost?" —
68
+ * a shared chunk would make the honest number a graph walk, and the budget compares against bytes.
69
+ */
70
+ async function buildOne(root: string, file: string): Promise<IslandChunk> {
71
+ // `Bun.build` REJECTS on a failed bundle, it does not answer `success: false` — so the catch is
72
+ // the real path here and the `success` test below is the belt for a future default.
73
+ let built: Awaited<ReturnType<typeof Bun.build>>;
74
+ try {
75
+ built = await Bun.build({
76
+ entrypoints: [join(root, file)],
77
+ target: 'browser',
78
+ format: 'esm',
79
+ splitting: false,
80
+ minify: true,
81
+ });
82
+ } catch (error) {
83
+ throw new IslandBuildFailedError({ file, logs: describeBuildError(error) });
84
+ }
85
+ const output = built.outputs.find((artifact) => artifact.kind === 'entry-point');
86
+ if (!built.success || output === undefined) {
87
+ throw new IslandBuildFailedError({
88
+ file,
89
+ logs: built.logs.map((log) => String(log)).join('; '),
90
+ });
91
+ }
92
+ const code = await output.text();
93
+ const moduleId = islandModuleId(basename(file));
94
+ return {
95
+ file,
96
+ moduleId,
97
+ // Hashed with the framework's own `contentHash`, the function that already stamps an ETag and
98
+ // a precache revision — one identity for a byte string, not a third.
99
+ url: `${ISLAND_BASE_PATH}/${moduleId}-${contentHash(code)}.js`,
100
+ code,
101
+ bytes: new TextEncoder().encode(code).byteLength,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * The bundler's own diagnostics, kept verbatim. An `AggregateError` holds one entry per unresolved
107
+ * import or syntax error, and flattening them is what puts the line number in the cause instead of
108
+ * the word "Bundle failed".
109
+ */
110
+ function describeBuildError(error: unknown): string {
111
+ if (error instanceof AggregateError) {
112
+ return error.errors.map((one: unknown) => String(one)).join('; ');
113
+ }
114
+ return error instanceof Error ? error.message : String(error);
115
+ }
116
+
117
+ /** Build every island in the app. An app with none returns an empty bundle and costs one glob. */
118
+ export async function buildIslands(root: string): Promise<IslandBundle> {
119
+ const files = await discoverIslands(root);
120
+ const chunks = await Promise.all(files.map((file) => buildOne(root, file)));
121
+ return islandBundle(chunks);
122
+ }
123
+
124
+ export function islandBundle(chunks: readonly IslandChunk[]): IslandBundle {
125
+ const byFile = new Map(chunks.map((chunk) => [chunk.file, chunk]));
126
+ const byUrl = new Map(chunks.map((chunk) => [chunk.url, chunk]));
127
+ return {
128
+ chunks,
129
+ resolverFor(routeFile: string): (src: string) => string {
130
+ const dir = posix.dirname(routeFile);
131
+ return (src: string): string => {
132
+ const target = posix.normalize(posix.join(dir, src));
133
+ const chunk = byFile.get(target);
134
+ if (chunk === undefined) throw entryMissing(routeFile, src, target, chunks);
135
+ return chunk.url;
136
+ };
137
+ },
138
+ chunkAt: (url: string): IslandChunk | undefined => byUrl.get(url),
139
+ };
140
+ }
141
+
142
+ /**
143
+ * The specifier named no file the build could bundle. `X_ISLAND_INVALID` is render's and is
144
+ * borrowed rather than renamed here: "this src cannot become a client entry" is the condition that
145
+ * code already means, and a second name for it is a second thing to look up.
146
+ */
147
+ function entryMissing(
148
+ routeFile: string,
149
+ src: string,
150
+ target: string,
151
+ chunks: readonly IslandChunk[],
152
+ ): IslandInvalidError {
153
+ const known = chunks.map((chunk) => chunk.file);
154
+ return new IslandInvalidError(
155
+ `${routeFile} declares island src ${JSON.stringify(src)}, which resolves to ${target} — a ` +
156
+ `file this build did not bundle (${known.length === 0 ? 'it found no islands at all' : `it found ${known.join(', ')}`})`,
157
+ `x g island ${posix.basename(target, ISLAND_EXTENSION)} --at ${posix.dirname(target)}`,
158
+ );
159
+ }
160
+
161
+ /** Write every chunk under the static export, at the same URL the documents already carry. */
162
+ export async function writeIslands(bundle: IslandBundle, out: string): Promise<void> {
163
+ for (const chunk of bundle.chunks) {
164
+ await Bun.write(join(out, chunk.url.slice(1)), chunk.code);
165
+ }
166
+ }
@@ -0,0 +1,50 @@
1
+ // Serving the island chunks a build produced. `x dev` and the container mount the same route over
2
+ // the same table, because a chunk URL is baked into the document by one resolver — a dev-only path
3
+ // would be a page that boots in `x dev` and 404s in the image.
4
+
5
+ import type { Route, UltimateRequest } from '@ultimat3/http';
6
+ import { applyCacheHeaders, json } from '@ultimat3/http';
7
+ import type { IslandBundle } from './island-bundle';
8
+ import { ISLAND_BASE_PATH } from './island-bundle';
9
+
10
+ /**
11
+ * A getter, not the bundle: `x dev` rebuilds the chunks on the same watcher tick that rebuilds the
12
+ * manifest, and a table captured when the route was mounted would serve the island as it was at
13
+ * boot for the rest of the session.
14
+ */
15
+ export type IslandSource = () => IslandBundle;
16
+
17
+ /**
18
+ * The URL is content-addressed, so the bytes behind it never change and the answer is immutable.
19
+ * A miss can only be a document older than this process's chunks — which is a fact worth stating,
20
+ * not a bare 404 whose meaning an agent has to guess.
21
+ */
22
+ export function islandRoutes(source: IslandSource): readonly Route[] {
23
+ return [
24
+ {
25
+ method: 'GET',
26
+ path: `${ISLAND_BASE_PATH}/*file`,
27
+ meta: { name: 'assets.island', auth: 'public', tags: ['assets'] },
28
+ handler: (request: UltimateRequest): Response => {
29
+ const chunk = source().chunkAt(request.pathname);
30
+ if (chunk === undefined) {
31
+ return json(
32
+ {
33
+ ok: false,
34
+ error: {
35
+ code: 'X_ROUTE_NOT_FOUND',
36
+ cause: `no island chunk is built at ${request.pathname} — the document that asked for it was rendered against an older build`,
37
+ fix: 'x build --target static --json # then reload, so the page carries this build’s chunk URLs',
38
+ },
39
+ },
40
+ { status: 404 },
41
+ );
42
+ }
43
+ return applyCacheHeaders(
44
+ new Response(chunk.code, { headers: { 'content-type': 'text/javascript' } }),
45
+ { mode: 'immutable' },
46
+ );
47
+ },
48
+ },
49
+ ];
50
+ }
@@ -0,0 +1,33 @@
1
+ // The one place a CLI command gets hold of the app's job queue. `x jobs` and `x db backfill`
2
+ // both need a `JobDriver` and neither owns one — a second copy of this boot would be two answers
3
+ // to "which queue is this command talking to", which is the drift axiom 1 refuses.
4
+
5
+ import type { JobDriver } from '@ultimat3/jobs';
6
+ import { jobDriver } from '@ultimat3/jobs';
7
+ import type { CommandContext } from './command';
8
+ import { startQueue } from './dev-queue';
9
+ import { resolveServices } from './dev-services';
10
+ import type { CommandResult } from './output';
11
+
12
+ /**
13
+ * `x jobs` needs the app's real driver. Reuse an already-running one first — inside `x dev` or
14
+ * `x mcp serve`, `jobDriver()` is already set and booting a second queue on top of it would talk
15
+ * to the wrong database. Otherwise boot just the db + jobs half (`startQueue`, not the full
16
+ * `startServices`: these commands touch no transport, storage or mail) and always release it, or
17
+ * a CLI that exits holding the PGlite lock breaks the next command run against this app.
18
+ */
19
+ export async function withJobDriver(
20
+ root: string,
21
+ ctx: CommandContext,
22
+ fn: (driver: JobDriver) => Promise<CommandResult>,
23
+ ): Promise<CommandResult> {
24
+ const ambient = jobDriver();
25
+ if (ambient !== undefined) return fn(ambient);
26
+ const services = resolveServices(root, ctx.env);
27
+ const queue = await startQueue(services);
28
+ try {
29
+ return await fn(queue.jobs);
30
+ } finally {
31
+ await queue.stop();
32
+ }
33
+ }
package/src/jobs-json.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // enforceable if the projections sit together where one missing `?? null` is visible.
4
4
 
5
5
  import type {
6
+ BackfillProgress,
6
7
  DeadLetterEntry,
7
8
  JobRecord,
8
9
  JobTrace,
@@ -26,6 +27,26 @@ function stepTraceToJson(step: StepTrace): JsonValue {
26
27
  };
27
28
  }
28
29
 
30
+ /**
31
+ * One `x_backfills` row. Every absent value is already `null` at the source (`toBackfillProgress`
32
+ * makes it so), which is what lets this be a straight field list — and it is spelled out rather
33
+ * than spread so a field added upstream arrives here as a decision, not as untyped passthrough.
34
+ */
35
+ export function backfillToJson(progress: BackfillProgress): JsonValue {
36
+ return {
37
+ runId: progress.runId,
38
+ name: progress.name,
39
+ status: progress.status,
40
+ checksum: progress.checksum,
41
+ appVersion: progress.appVersion,
42
+ rows: progress.rows,
43
+ cursor: progress.cursor,
44
+ startedAt: progress.startedAt,
45
+ completedAt: progress.completedAt,
46
+ durationMs: progress.durationMs,
47
+ };
48
+ }
49
+
29
50
  export function jobTraceToJson(trace: JobTrace): JsonValue {
30
51
  return {
31
52
  id: trace.id,
@@ -41,6 +62,9 @@ export function jobTraceToJson(trace: JobTrace): JsonValue {
41
62
  tenantId: trace.tenantId,
42
63
  steps: trace.steps.map(stepTraceToJson),
43
64
  retryDelaysMs: trace.retryDelaysMs.map((ms) => ms),
65
+ // `null` for every job that is not a `backfill()` pass. Dropped, `x jobs show <id> --json`
66
+ // would answer "how far has it got" with silence for the one job kind that can say.
67
+ backfill: trace.backfill === null ? null : backfillToJson(trace.backfill),
44
68
  };
45
69
  }
46
70
 
@@ -3,6 +3,7 @@
3
3
  // alone. Drain is `jobs-drain.ts`, the `--json` shapes `jobs-json.ts`, the table `jobs-table.ts`.
4
4
 
5
5
  import type {
6
+ BackfillProgress,
6
7
  DeadLetterEntry,
7
8
  JobDriver,
8
9
  JobFilter,
@@ -12,6 +13,7 @@ import type {
12
13
  QueueDepthReport,
13
14
  } from '@ultimat3/jobs';
14
15
  import {
16
+ inspectBackfills,
15
17
  inspectDeadLetters,
16
18
  inspectJob,
17
19
  inspectJobList,
@@ -48,15 +50,18 @@ export function parseStateFlag(value: string | undefined): JobState | undefined
48
50
  * A digit string is not yet a limit: past `Number.MAX_SAFE_INTEGER` the parse silently lands on a
49
51
  * different integer, and `1e400`-shaped input yields `Infinity`. Either way the driver would be
50
52
  * handed a bound other than the one typed, so the safe-integer check is the flag's real contract.
53
+ *
54
+ * `command` exists because `x db backfill --list` takes the same `--limit` and must not report it
55
+ * as a flag on `x jobs` — one parser, and the error still names the command that was typed.
51
56
  */
52
- export function parseLimitFlag(value: string | undefined): number | undefined {
57
+ export function parseLimitFlag(value: string | undefined, command = 'jobs'): number | undefined {
53
58
  if (value === undefined) return undefined;
54
59
  const digits = value.trim();
55
60
  const limit = /^\d+$/.test(digits) ? Number(digits) : Number.NaN;
56
61
  if (!Number.isSafeInteger(limit) || limit <= 0) {
57
62
  throw new BadFlagError({
58
63
  flag: 'limit',
59
- command: 'jobs',
64
+ command,
60
65
  reason: `expects an integer from 1 to ${Number.MAX_SAFE_INTEGER}, got "${value}"`,
61
66
  });
62
67
  }
@@ -76,12 +81,19 @@ export interface JobsListResult {
76
81
  readonly depth: QueueDepthReport;
77
82
  readonly rows: readonly JobRecord[];
78
83
  readonly deadLetters: readonly DeadLetterEntry[];
84
+ /** The sweeps still in flight — see below for why finished ones are not this command's answer. */
85
+ readonly backfills: readonly BackfillProgress[];
79
86
  }
80
87
 
81
88
  /**
82
89
  * The depth report AND the filtered rows, plus dead letters unconditionally: a dead job that a
83
90
  * `--state ready` filter (or the default 100-row cap) pushes out of view is the exact failure
84
91
  * mode this command exists to prevent.
92
+ *
93
+ * Backfills are read `running` only. `x jobs ls` is a LIVE view of the queue — a pass that
94
+ * finished last week is history, and `x db backfill --list` is where that question is asked and
95
+ * answered with the whole ledger. A driver with no ledger answers `[]` rather than throwing, so
96
+ * the queue view never fails over a fact nobody asked about.
85
97
  */
86
98
  export async function listJobs(
87
99
  driver: JobDriver,
@@ -95,12 +107,13 @@ export async function listJobs(
95
107
  ...(state === undefined ? {} : { state }),
96
108
  ...(limit === undefined ? {} : { limit }),
97
109
  };
98
- const [depth, rows, deadLetters] = await Promise.all([
110
+ const [depth, rows, deadLetters, backfills] = await Promise.all([
99
111
  inspectQueues(driver),
100
112
  inspectJobList(driver, jobFilter),
101
113
  inspectDeadLetters(driver),
114
+ inspectBackfills(driver, { status: 'running' }),
102
115
  ]);
103
- return { depth, rows, deadLetters };
116
+ return { depth, rows, deadLetters, backfills };
104
117
  }
105
118
 
106
119
  // ── show ──────────────────────────────────────────────────────────────────
@@ -1,36 +1,64 @@
1
- // Which database the MCP dev host is pointed at, and whether that database is a branch. `db.migrate`
2
- // decides from this alone, so the reading has to be exact: a wrong `branch` is a migration against a
3
- // database somebody else is using.
1
+ // Which database the MCP dev host is pointed at: whether it is a branch, and whether this is
2
+ // production. `db.migrate` decides from these two alone, so both readings have to be exact — a
3
+ // wrong `branch` is a migration against a database somebody else is using, and a `production` that
4
+ // is always false is a refusal that never runs.
4
5
 
5
- import { basename, join } from 'node:path';
6
+ // `node:path` for the joiner Bun has no equivalent of — the same reason `db-branch.ts` reaches for
7
+ // it, and the state directory it builds a path under is a real one on disk.
8
+ import { join } from 'node:path';
9
+ import { tryResolveEnvironment } from '@ultimat3/core';
6
10
  import { pgliteDataDir } from '@ultimat3/db';
7
11
  import type { DatabaseTarget } from '@ultimat3/mcp';
8
- import type { DevServices } from './dev-services';
12
+ import { branchNameOf, pgliteBranchName } from './db-branch';
13
+ import type { DevServices, Env } from './dev-services';
14
+ import { safeUrlLabel } from './safe-url-label';
9
15
 
10
16
  /**
11
- * `production` is always false: this target is whatever `x dev` resolved — embedded PGlite under
12
- * `.x/`, or the `DATABASE_URL` of a developer's shell. Production is reached through `ROLE=migrate`
13
- * in a deploy hook, never through MCP. What actually stops a migration against a shared database is
14
- * `branch`, which is null unless the name says otherwise.
17
+ * `production` was the literal `false` in both arms until `As of 2026-08`, and this is the only
18
+ * place a `DatabaseTarget` is ever built — so `assertBranchDatabase`'s FIRST refusal, the one
19
+ * meaning "production is never migratable from MCP at all", could not run for any database this
20
+ * CLI produced. A production database refused it only incidentally, because its name lacked
21
+ * `_branch_`; one named `shop_branch_hotfix`, or a container whose data directory is
22
+ * `pgdata-hotfix`, read as a branch and was migratable through an MCP tool call.
23
+ *
24
+ * The environment is read the way `x doctor` reads it (`cmd-doctor.ts`), through core's one key —
25
+ * so `production` is a fact about the deploy and `branch` stays a fact about the database, and
26
+ * `@ultimat3/mcp` keeps receiving two answers rather than re-deriving either.
15
27
  */
16
- export function databaseTarget(services: DevServices): DatabaseTarget {
28
+ export function databaseTarget(services: DevServices, env: Env): DatabaseTarget {
17
29
  const url = services.db.url;
30
+ const production = isProduction(env);
18
31
  return services.db.mode === 'embedded'
19
- ? { label: url, branch: pgliteBranch(url, services.stateDir), production: false }
20
- : { label: safeLabel(url), branch: postgresBranch(url), production: false };
32
+ ? { label: url, branch: pgliteBranch(url, services.stateDir), production }
33
+ : {
34
+ // An external `DATABASE_URL` may carry credentials, and this string gets printed.
35
+ label: safeUrlLabel(url, 'external database'),
36
+ branch: postgresBranch(url),
37
+ production,
38
+ };
21
39
  }
22
40
 
23
- /** An external `DATABASE_URL` may carry credentials, and this string gets printed. */
24
- function safeLabel(url: string): string {
25
- try {
26
- const parsed = new URL(url);
27
- return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
28
- } catch {
29
- return 'external database';
30
- }
31
- }
41
+ /**
42
+ * The non-throwing read, because `ULTIMATE_ENV` is in no env schema — nothing validates it at boot,
43
+ * and a tool call is not where a typo should surface as a crash (`cmd-doctor.ts` chose the same
44
+ * variant for the same reason).
45
+ *
46
+ * `undefined` is answered **true**, and that is the whole point of using it here. It means exactly
47
+ * one thing: `ULTIMATE_ENV` is set to something that is not an environment. Reading a typo as "not
48
+ * production" would let the single misconfiguration this guard exists to survive defeat it — so an
49
+ * environment that cannot be read is treated as the most dangerous one it could be.
50
+ */
51
+ const isProduction = (env: Env): boolean => {
52
+ const environment = tryResolveEnvironment({ env });
53
+ return environment === undefined || environment === 'production';
54
+ };
32
55
 
33
- /** `x db branch <name>` names an external clone `<source>_branch_<name>` (`branchDatabaseName`). */
56
+ /**
57
+ * `x db branch create <name>` names an external clone `<source>_branch_<name>`. Both readings come
58
+ * from `db-branch.ts` — the module that also WRITES those names — because a target that disagrees
59
+ * with `x db branch ls` about what a branch is would let `db.migrate` run against a shared
60
+ * database on the strength of a naming rule one of the two had drifted away from.
61
+ */
34
62
  function postgresBranch(url: string): string | null {
35
63
  let database: string;
36
64
  try {
@@ -38,13 +66,10 @@ function postgresBranch(url: string): string | null {
38
66
  } catch {
39
67
  return null;
40
68
  }
41
- return /_branch_(.+)$/.exec(database)?.[1] ?? null;
69
+ return branchNameOf(database);
42
70
  }
43
71
 
44
72
  /** `branchPglite` copies `<stateDir>/pgdata` to `<stateDir>/pgdata-<name>`; the dev dir is no branch. */
45
73
  function pgliteBranch(url: string, stateDir: string): string | null {
46
- const dir = pgliteDataDir(url);
47
- const dev = join(stateDir, 'pgdata');
48
- if (dir === dev || basename(dir) === basename(dev)) return null;
49
- return dir.startsWith(`${dev}-`) ? dir.slice(dev.length + 1) : null;
74
+ return pgliteBranchName(pgliteDataDir(url), join(stateDir, 'pgdata'));
50
75
  }