@ultimat3/cli 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +724 -0
- package/README.md +41 -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 +114 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +215 -0
- package/src/cmd-db.ts +332 -155
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +87 -17
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +64 -9
- 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 +13 -7
- 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 +17 -23
- package/src/cmd-verify.ts +177 -23
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +251 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +112 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +86 -20
- 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 +186 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +245 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +11 -3
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +37 -9
- package/src/error-catalog.ts +7 -18
- package/src/error-codes.ts +186 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +205 -140
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +56 -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 +87 -14
- 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 +120 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +81 -2
- package/src/metrics-endpoint.ts +73 -0
- 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 +202 -18
- 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 +21 -3
- package/src/test-workers.ts +47 -0
- package/src/ts-scan.ts +271 -13
- package/src/tsconfig-references.ts +78 -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
|
@@ -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 ──────────────────────────────────────────────────────────────────
|
package/src/mcp-db-target.ts
CHANGED
|
@@ -1,36 +1,64 @@
|
|
|
1
|
-
// Which database the MCP dev host is pointed at
|
|
2
|
-
// decides from
|
|
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
|
-
|
|
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
|
|
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`
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
20
|
-
: {
|
|
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
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
}
|