@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/index.ts CHANGED
@@ -2,6 +2,8 @@
2
2
  // on these, and a barrel that re-exports everything would make every internal a compatibility
3
3
  // promise.
4
4
 
5
+ /** The app's own API over HTTP — the one table `x dev` and a container both mount. */
6
+ export { apiRoutes } from './api-routes';
5
7
  export type { BoundaryCode, SourceFile } from './app-boundaries';
6
8
  export {
7
9
  appImportGraph,
@@ -32,7 +34,8 @@ export {
32
34
  readTarget,
33
35
  requireEntry,
34
36
  } from './cmd-build';
35
- export { branchDatabaseName, branchSql, dbCommand, previewUrl } from './cmd-db';
37
+ export { dbCommand } from './cmd-db';
38
+ export { runBranchCommand } from './cmd-db-branch';
36
39
  export type { DeployPlan } from './cmd-deploy';
37
40
  export { deployCommand, planDeploy } from './cmd-deploy';
38
41
  export type { DevServer, StartDevOptions } from './cmd-dev';
@@ -50,14 +53,30 @@ export type { McpHttpServer } from './cmd-mcp';
50
53
  export { mcpCommand, startMcpHttp } from './cmd-mcp';
51
54
  export type { NewAppOptions, WrittenApp } from './cmd-new';
52
55
  export { newCommand, planNewApp, writeNewApp } from './cmd-new';
53
- export type { PlannedCommand } from './cmd-planned';
54
- export { PLANNED_COMMANDS, plannedCommands } from './cmd-planned';
56
+ export type { PlannedCommand, PlannedSubcommand } from './cmd-planned';
57
+ export {
58
+ PLANNED_COMMANDS,
59
+ PLANNED_SUBCOMMANDS,
60
+ plannedCommands,
61
+ plannedSubcommand,
62
+ } from './cmd-planned';
55
63
  export { actionsCommand, entitiesCommand, queriesCommand } from './cmd-registries';
56
64
  export { renderRouteTable, routesCommand } from './cmd-routes';
57
- export { availableCpus, testCommand } from './cmd-test';
65
+ export { testCommand } from './cmd-test';
58
66
  export { runVerify, VERIFY_STEPS, verifyCommand, verifyStepNames } from './cmd-verify';
59
67
  export type { CliCommand, CommandContext } from './command';
60
68
  export { failed, ok } from './command';
69
+ export type { BranchRow, BranchSubcommand } from './db-branch';
70
+ export {
71
+ BRANCH_SUBCOMMANDS,
72
+ branchDatabaseName,
73
+ branchNameOf,
74
+ isBranchSubcommand,
75
+ pgliteBranchName,
76
+ previewUrl,
77
+ } from './db-branch';
78
+ export type { GeneratedFiles, GenerateMigrationOptions, GenerateOutcome } from './db-generate';
79
+ export { generateAppMigration, migrationSql } from './db-generate';
61
80
  export type { AssetRoutesOptions } from './dev-assets';
62
81
  export {
63
82
  assetRoutes,
@@ -80,7 +99,14 @@ export type { DevServices, ServiceBinding } from './dev-services';
80
99
  export { describeServices, resolveServices } from './dev-services';
81
100
  export type { DispatchOptions } from './dispatch';
82
101
  export { dispatch } from './dispatch';
83
- export { checkDrift, recordedHashes, schemaHash, writeSchemaHash } from './drift';
102
+ export type { DeclaredEntityCount, HashReconciliation } from './drift';
103
+ export {
104
+ checkSourceDrift,
105
+ reconcileSchemaHash,
106
+ recordedHashes,
107
+ schemaHash,
108
+ writeSchemaHash,
109
+ } from './drift';
84
110
  export type { ErrorCatalog } from './error-catalog';
85
111
  export {
86
112
  buildErrorCatalog,
@@ -89,6 +115,8 @@ export {
89
115
  registeredErrorCodes,
90
116
  resetErrorCatalog,
91
117
  } from './error-catalog';
118
+ export type { CliErrorCode } from './error-codes';
119
+ export { CLI_ERROR_CODES, CLI_ERROR_TITLES } from './error-codes';
92
120
  export {
93
121
  BANNED_PHRASES,
94
122
  COMMAND_TOKENS,
@@ -102,19 +130,26 @@ export {
102
130
  RESERVED_HEADING,
103
131
  staticFix,
104
132
  } from './error-contract';
105
- export type { CliErrorCode } from './errors';
133
+ export type { CodeFixIndex, CodeFixScan } from './error-fixes';
134
+ export {
135
+ codeFixes,
136
+ codeFixScan,
137
+ loadCodeFixes,
138
+ resetCodeFixes,
139
+ scanScopeFixes,
140
+ } from './error-fixes';
106
141
  export {
107
142
  BadFlagError,
108
143
  BuildEntryMissingError,
109
144
  BunVersionError,
110
145
  CatalogExistsError,
111
- CLI_ERROR_CODES,
112
- CLI_ERROR_TITLES,
113
146
  CliNotImplementedError,
114
147
  DeclarationUnknownError,
115
148
  ErrorCodeUnknownError,
116
149
  FixTargetUnknownError,
117
150
  JobUnknownError,
151
+ MissingPositionalError,
152
+ MissingSubcommandError,
118
153
  NoTestFilesError,
119
154
  NotInAppError,
120
155
  PortInvalidError,
@@ -124,6 +159,16 @@ export {
124
159
  } from './errors';
125
160
  export type { ExecOptions, ExecResult, Runner } from './exec';
126
161
  export { exec, execOutput } from './exec';
162
+ export type { CitationFault, CitationRules, CommandCatalog, FixCitation } from './fix-command';
163
+ export {
164
+ citationFault,
165
+ citationProblem,
166
+ citedCommandProblem,
167
+ fixCitations,
168
+ loadCommandCatalog,
169
+ } from './fix-command';
170
+ export type { Guard } from './guards';
171
+ export { findingProblem, GUARD_DIR, guardFindings, guardPaths } from './guards';
127
172
  export type { DrainFailure, DrainOutcome, DrainSkip } from './jobs-drain';
128
173
  export { drainJobs } from './jobs-drain';
129
174
  export type { JobsListFilter, JobsListResult } from './jobs-report';
@@ -134,7 +179,14 @@ export { createDevMcpServer, DEV_TOOL_SCOPES, localCaller } from './mcp-host';
134
179
  export { messageKeys, msg } from './messages';
135
180
  export type { MetricsEndpoint, MetricsEndpointOptions } from './metrics-endpoint';
136
181
  export { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
137
- export { MIGRATIONS_DIR, migrationName, parseMigrationSql, readMigrations } from './migrations';
182
+ export {
183
+ hashFileName,
184
+ MIGRATIONS_DIR,
185
+ migrationName,
186
+ parseMigrationSql,
187
+ readMigrations,
188
+ snapshotFileName,
189
+ } from './migrations';
138
190
  export type { CommandResult, Finding, JsonValue, StepResult } from './output';
139
191
  export {
140
192
  exitCodeFor,
@@ -148,9 +200,14 @@ export {
148
200
  } from './output';
149
201
  export type { CommandSpec, FlagSpec, ParsedArgs } from './parse';
150
202
  export { flagBool, flagList, flagString, GLOBAL_FLAGS, nearest, parseArgs } from './parse';
151
- export type { PrerenderedPage, PrerenderOptions, PrerenderReport } from './prerender';
203
+ export type {
204
+ PrerenderedPage,
205
+ PrerenderOptions,
206
+ PrerenderReport,
207
+ UnmeasuredRoute,
208
+ } from './prerender';
152
209
  export { DEFAULT_ORIGIN, isPrerenderable, prerenderSite } from './prerender';
153
- export { CLI_VERSION, COMMANDS, commandFor, SPECS } from './registry';
210
+ export { COMMANDS, cliVersion, commandFor, SPECS } from './registry';
154
211
  export type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve';
155
212
  export {
156
213
  CONTAINER_BINDING,
@@ -162,6 +219,7 @@ export {
162
219
  runRole,
163
220
  serveApp,
164
221
  } from './serve';
222
+ export { quoteArg } from './shell-quote';
165
223
  export {
166
224
  eachSourceFile,
167
225
  isGenerated,
@@ -169,19 +227,36 @@ export {
169
227
  isVendored,
170
228
  SOURCE_GLOBS,
171
229
  } from './source-files';
230
+ export type { TestCounts } from './test-counts';
231
+ export { countsOf } from './test-counts';
172
232
  export type { TestFile } from './test-select';
173
233
  export { belongsToType, discoverTests, sampleFiles } from './test-select';
174
234
  export type { ReproduceOptions, RunShardsOptions, Shard } from './test-shards';
175
- export { planShards, quoteArg, reproduceFor, runShards, shardArgs } from './test-shards';
176
- export type { CodeSite, FixSite, SourceSite } from './ts-scan';
235
+ export { planShards, reproduceFor, runShards, shardArgs } from './test-shards';
236
+ export { availableCpus, defaultWorkers, WORKER_CEILING } from './test-workers';
237
+ export type { CodeFixSite, CodeSite, FixSite, SourceSite } from './ts-scan';
177
238
  export {
178
239
  isCodeRegistry,
179
240
  maskLiterals,
180
241
  scanBorrowedCodes,
242
+ scanCodeFixSites,
181
243
  scanCodes,
182
244
  scanFixes,
183
245
  stripComments,
184
246
  } from './ts-scan';
247
+ // The one spelling rule for a `references` entry. Exported because the two gate scripts ask the
248
+ // same question this package's `package-shape` step does, and three answers is a duplicate entry.
249
+ export { normalizeReferencePath } from './tsconfig-references';
250
+ export type { VerifyFloor } from './verify-floor';
251
+ export {
252
+ floorProblemFindings,
253
+ floorRequires,
254
+ parseVerifyFloor,
255
+ readVerifyFloor,
256
+ skippedSuiteFinding,
257
+ VERIFY_FLOOR_FILE,
258
+ vanishedSuiteFinding,
259
+ } from './verify-floor';
185
260
  export type {
186
261
  HostCheck,
187
262
  StepOutcome,
@@ -191,8 +266,8 @@ export type {
191
266
  } from './verify-step';
192
267
  export { VERIFY_STEP_NAMES } from './verify-step';
193
268
  export type { TestType } from './verify-tests';
194
- export { TEST_STEPS, TEST_TYPES, testStepCommand, typeFilterOf } from './verify-tests';
195
- export type { ManifestFacts } from './workspace-checks';
269
+ export { TEST_STEPS, TEST_TYPES, testStepCommand, typeFiltersOf } from './verify-tests';
270
+ export type { ManifestFacts, PackageShapeOptions } from './workspace-checks';
196
271
  export {
197
272
  checkFileSizes,
198
273
  checkLockstep,
@@ -201,5 +276,7 @@ export {
201
276
  hasWorkspacePackages,
202
277
  LINE_CEILING,
203
278
  PACKAGE_FILES,
279
+ SEMVER,
204
280
  workspacePackages,
205
281
  } from './workspace-checks';
282
+ export { writeLine } from './write-line';
@@ -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 ──────────────────────────────────────────────────────────────────