@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.
- package/CLAUDE.md +761 -0
- package/README.md +42 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +134 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +219 -0
- package/src/cmd-db.ts +458 -153
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +92 -18
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +74 -10
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +14 -8
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +29 -24
- package/src/cmd-verify.ts +197 -25
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +269 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +144 -0
- package/src/db-seed.ts +294 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +108 -23
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +167 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +247 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +37 -7
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +78 -10
- package/src/error-catalog.ts +8 -18
- package/src/error-codes.ts +192 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +201 -138
- package/src/exec.ts +42 -8
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +67 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +92 -15
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +128 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +93 -2
- package/src/metrics-endpoint.ts +64 -16
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +185 -13
- package/src/shell-quote.ts +15 -0
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +20 -11
- package/src/test-workers.ts +50 -0
- package/src/ts-scan.ts +284 -15
- package/src/tsconfig-references.ts +103 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- 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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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,
|
|
176
|
-
export
|
|
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,
|
|
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
|
|
package/src/jobs-report.ts
CHANGED
|
@@ -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
|
|
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 ──────────────────────────────────────────────────────────────────
|