@ultimat3/cli 25.0.0 → 25.2.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/package.json +28 -28
- package/src/cmd-routes-spec.ts +1 -1
- package/src/cmd-routes.ts +9 -52
- package/src/doctor-guards.ts +74 -19
- package/src/index.ts +6 -6
- package/src/mcp-host.ts +4 -2
- package/src/messages.ts +2 -0
- package/src/role-start.ts +2 -2
- package/src/role-wake.ts +2 -3
- package/src/route-table.ts +113 -0
- package/src/runtime-queue.ts +7 -18
- package/src/runtime-services.ts +3 -2
- package/src/templates/scaffold-guards.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "25.
|
|
3
|
+
"version": "25.2.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -39,33 +39,33 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@babel/core": "^8.0.1",
|
|
42
|
-
"@ultimat3/action": "25.
|
|
43
|
-
"@ultimat3/admin": "25.
|
|
44
|
-
"@ultimat3/ai": "25.
|
|
45
|
-
"@ultimat3/auth": "25.
|
|
46
|
-
"@ultimat3/cache": "25.
|
|
47
|
-
"@ultimat3/core": "25.
|
|
48
|
-
"@ultimat3/db": "25.
|
|
49
|
-
"@ultimat3/entity": "25.
|
|
50
|
-
"@ultimat3/flags": "25.
|
|
51
|
-
"@ultimat3/http": "25.
|
|
52
|
-
"@ultimat3/i18n": "25.
|
|
53
|
-
"@ultimat3/jobs": "25.
|
|
54
|
-
"@ultimat3/mail": "25.
|
|
55
|
-
"@ultimat3/manifest": "25.
|
|
56
|
-
"@ultimat3/mcp": "25.
|
|
57
|
-
"@ultimat3/money": "25.
|
|
58
|
-
"@ultimat3/notify": "25.
|
|
59
|
-
"@ultimat3/policy": "25.
|
|
60
|
-
"@ultimat3/pwa": "25.
|
|
61
|
-
"@ultimat3/query": "25.
|
|
62
|
-
"@ultimat3/realtime": "25.
|
|
63
|
-
"@ultimat3/render": "25.
|
|
64
|
-
"@ultimat3/schema": "25.
|
|
65
|
-
"@ultimat3/seo": "25.
|
|
66
|
-
"@ultimat3/storage": "25.
|
|
67
|
-
"@ultimat3/testing": "25.
|
|
68
|
-
"@ultimat3/time": "25.
|
|
42
|
+
"@ultimat3/action": "25.2.0",
|
|
43
|
+
"@ultimat3/admin": "25.2.0",
|
|
44
|
+
"@ultimat3/ai": "25.2.0",
|
|
45
|
+
"@ultimat3/auth": "25.2.0",
|
|
46
|
+
"@ultimat3/cache": "25.2.0",
|
|
47
|
+
"@ultimat3/core": "25.2.0",
|
|
48
|
+
"@ultimat3/db": "25.2.0",
|
|
49
|
+
"@ultimat3/entity": "25.2.0",
|
|
50
|
+
"@ultimat3/flags": "25.2.0",
|
|
51
|
+
"@ultimat3/http": "25.2.0",
|
|
52
|
+
"@ultimat3/i18n": "25.2.0",
|
|
53
|
+
"@ultimat3/jobs": "25.2.0",
|
|
54
|
+
"@ultimat3/mail": "25.2.0",
|
|
55
|
+
"@ultimat3/manifest": "25.2.0",
|
|
56
|
+
"@ultimat3/mcp": "25.2.0",
|
|
57
|
+
"@ultimat3/money": "25.2.0",
|
|
58
|
+
"@ultimat3/notify": "25.2.0",
|
|
59
|
+
"@ultimat3/policy": "25.2.0",
|
|
60
|
+
"@ultimat3/pwa": "25.2.0",
|
|
61
|
+
"@ultimat3/query": "25.2.0",
|
|
62
|
+
"@ultimat3/realtime": "25.2.0",
|
|
63
|
+
"@ultimat3/render": "25.2.0",
|
|
64
|
+
"@ultimat3/schema": "25.2.0",
|
|
65
|
+
"@ultimat3/seo": "25.2.0",
|
|
66
|
+
"@ultimat3/storage": "25.2.0",
|
|
67
|
+
"@ultimat3/testing": "25.2.0",
|
|
68
|
+
"@ultimat3/time": "25.2.0",
|
|
69
69
|
"babel-preset-solid": "^1.9.15"
|
|
70
70
|
}
|
|
71
71
|
}
|
package/src/cmd-routes-spec.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { CommandSpec } from './parse';
|
|
|
5
5
|
|
|
6
6
|
export const routesSpec: CommandSpec = {
|
|
7
7
|
name: 'routes',
|
|
8
|
-
summary: '
|
|
8
|
+
summary: 'every served route: pages, then actions, queries and runtime.ts routes',
|
|
9
9
|
usage: 'x routes [--surface site|app|api|shared] [--json]',
|
|
10
10
|
requiresApp: true,
|
|
11
11
|
flags: [{ name: 'surface', type: 'string', summary: 'filter by surface' }],
|
package/src/cmd-routes.ts
CHANGED
|
@@ -1,63 +1,20 @@
|
|
|
1
1
|
// `x routes` — the route table as a table, or as JSON. Replaces grepping a router directory, which
|
|
2
2
|
// is what an agent does when the framework has no answer to "what URLs exist".
|
|
3
3
|
//
|
|
4
|
-
// The rows are
|
|
5
|
-
//
|
|
4
|
+
// The rows are `route-table.ts`'s — the one builder the MCP dev tool `routes.list` reads too — so
|
|
5
|
+
// the CLI keeps no second table.
|
|
6
6
|
|
|
7
|
-
import type {
|
|
8
|
-
import {
|
|
7
|
+
import type { Surface } from '@ultimat3/render';
|
|
8
|
+
import { SURFACES } from '@ultimat3/render';
|
|
9
9
|
import { loadApp } from './app-load';
|
|
10
10
|
import { requireAppRoot } from './app-root';
|
|
11
11
|
import { routesSpec } from './cmd-routes-spec';
|
|
12
12
|
import type { CliCommand, CommandContext } from './command';
|
|
13
13
|
import { BadFlagError, UnknownCommandError } from './errors';
|
|
14
14
|
import { msg } from './messages';
|
|
15
|
-
import type { CommandResult
|
|
15
|
+
import type { CommandResult } from './output';
|
|
16
16
|
import { flagString } from './parse';
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Where a route comes from. A file route names its file; a MOUNTED one names the call that
|
|
20
|
-
* mounted it and every permission that gates it — there is no `page.tsx` to name, and printing the
|
|
21
|
-
* package would send a reader looking for a route file inside `node_modules`.
|
|
22
|
-
*/
|
|
23
|
-
const declaredBy = (route: RouteDescriptor): string =>
|
|
24
|
-
route.mount === undefined
|
|
25
|
-
? route.file
|
|
26
|
-
: `${route.mount.by}() · ${route.mount.permissions.join(' + ')}`;
|
|
27
|
-
|
|
28
|
-
/** Fixed-width columns so the output diffs cleanly between runs and between machines. */
|
|
29
|
-
export function renderRouteTable(routes: readonly RouteDescriptor[]): readonly string[] {
|
|
30
|
-
const rows = routes.map((route) => [
|
|
31
|
-
route.path,
|
|
32
|
-
route.surface,
|
|
33
|
-
route.mode,
|
|
34
|
-
route.hydrate,
|
|
35
|
-
route.offline,
|
|
36
|
-
declaredBy(route),
|
|
37
|
-
]);
|
|
38
|
-
const header = ['path', 'surface', 'render', 'hydrate', 'offline', 'file'];
|
|
39
|
-
const widths = header.map((title, index) =>
|
|
40
|
-
Math.max(title.length, ...rows.map((row) => (row[index] ?? '').length)),
|
|
41
|
-
);
|
|
42
|
-
const line = (cells: readonly string[]): string =>
|
|
43
|
-
cells.map((value, index) => value.padEnd(widths[index] ?? 0)).join(' ');
|
|
44
|
-
return [line(header), ...rows.map(line)];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const routeJson = (routes: readonly RouteDescriptor[]): JsonValue =>
|
|
48
|
-
routes.map((route) => ({
|
|
49
|
-
path: route.path,
|
|
50
|
-
surface: route.surface,
|
|
51
|
-
file: route.file,
|
|
52
|
-
render: route.mode,
|
|
53
|
-
hydrate: route.hydrate,
|
|
54
|
-
offline: route.offline,
|
|
55
|
-
budget: { js: route.budgetJs },
|
|
56
|
-
// Absent on a file route, never `null`: only a mounted route has the fact.
|
|
57
|
-
...(route.mount === undefined
|
|
58
|
-
? {}
|
|
59
|
-
: { mount: { by: route.mount.by, permissions: [...route.mount.permissions] } }),
|
|
60
|
-
}));
|
|
17
|
+
import { plainAppRoutes, renderRouteRows, routeRows } from './route-table';
|
|
61
18
|
|
|
62
19
|
/**
|
|
63
20
|
* A closed set, because the filter was a bare `===`: `x routes --surface App` and `--surface pages`
|
|
@@ -97,7 +54,7 @@ export const routesCommand: CliCommand = {
|
|
|
97
54
|
// `--transport` already follows.
|
|
98
55
|
const surface = readSurfaceFilter(flagString(ctx.args, 'surface'));
|
|
99
56
|
const { findings } = await loadApp(root);
|
|
100
|
-
const routes =
|
|
57
|
+
const routes = routeRows(await plainAppRoutes(root)).filter(
|
|
101
58
|
(route) => surface === undefined || route.surface === surface,
|
|
102
59
|
);
|
|
103
60
|
return {
|
|
@@ -107,9 +64,9 @@ export const routesCommand: CliCommand = {
|
|
|
107
64
|
routes.length === 0
|
|
108
65
|
? msg('cli.routes.empty')
|
|
109
66
|
: msg('cli.routes.count', { count: routes.length }),
|
|
110
|
-
lines: routes.length === 0 ? [] :
|
|
67
|
+
lines: routes.length === 0 ? [] : renderRouteRows(routes).map((line) => ` ${line}`),
|
|
111
68
|
findings,
|
|
112
|
-
data: { routes:
|
|
69
|
+
data: { routes: routes.map((route) => route.json) },
|
|
113
70
|
};
|
|
114
71
|
},
|
|
115
72
|
};
|
package/src/doctor-guards.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
// `x doctor`'s guard listing: the guards
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// `x doctor`'s guard listing: the shipped guards this app's `guards/` does not hold, and the ones
|
|
2
|
+
// it holds in a form that is not the current template — each with the command that writes it. A
|
|
3
|
+
// LISTING, never a finding: every shipped guard is the app's copy ("delete this file to drop the
|
|
4
|
+
// rule"), so a missing or edited one may be a decision the framework must not overrule.
|
|
5
5
|
|
|
6
|
+
// why: Bun exposes no path-join primitive; Bun.file takes one already joined.
|
|
7
|
+
import { join } from 'node:path';
|
|
6
8
|
import { GUARD_DIR, guardPaths } from './guards';
|
|
7
9
|
import { msg } from './messages';
|
|
8
10
|
import type { CommandResult, JsonValue } from './output';
|
|
9
|
-
import { SHIPPED_GUARD_NAMES } from './templates/scaffold-guards';
|
|
11
|
+
import { SHIPPED_GUARD_NAMES, shippedGuardSource } from './templates/scaffold-guards';
|
|
10
12
|
|
|
11
13
|
export interface MissingGuard {
|
|
12
14
|
readonly name: string;
|
|
@@ -23,9 +25,51 @@ export function missingShippedGuards(present: readonly string[]): readonly Missi
|
|
|
23
25
|
}));
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
export interface DifferingGuard {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
/** Rewrites the guard and its test from the current template. Review the diff, then keep it. */
|
|
31
|
+
readonly refresh: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One line of the listing: a shipped guard the app lacks, or holds in another form. */
|
|
35
|
+
export type GuardListingEntry = MissingGuard | DifferingGuard;
|
|
36
|
+
|
|
37
|
+
/** A guard file as the app holds it, path relative to the app root. */
|
|
38
|
+
export interface HeldGuard {
|
|
39
|
+
readonly path: string;
|
|
40
|
+
readonly source: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A checkout on Windows may write CRLF; the rule is the same text either way. */
|
|
44
|
+
const sameText = (a: string, b: string): boolean =>
|
|
45
|
+
a.replaceAll('\r\n', '\n') === b.replaceAll('\r\n', '\n');
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The rule, pure: which held guards carry a shipped name and are not that guard's current
|
|
49
|
+
* template. A guard with a name the framework never shipped is the app's own, and has no template.
|
|
50
|
+
*/
|
|
51
|
+
export function differingShippedGuards(held: readonly HeldGuard[]): readonly DifferingGuard[] {
|
|
52
|
+
const differing: DifferingGuard[] = [];
|
|
53
|
+
for (const { path, source } of held) {
|
|
54
|
+
const name = path.slice(`${GUARD_DIR}/`.length).replace(/\.tsx?$/, '');
|
|
55
|
+
const template = shippedGuardSource(name);
|
|
56
|
+
if (template === undefined || sameText(source, template)) continue;
|
|
57
|
+
differing.push({ name, refresh: `x g guard ${name} --force` });
|
|
58
|
+
}
|
|
59
|
+
return differing;
|
|
60
|
+
}
|
|
61
|
+
|
|
26
62
|
/** The I/O half: the same enumeration the `boundaries` step runs, so the two cannot disagree. */
|
|
27
|
-
export async function shippedGuardsProbe(root: string): Promise<readonly
|
|
28
|
-
|
|
63
|
+
export async function shippedGuardsProbe(root: string): Promise<readonly GuardListingEntry[]> {
|
|
64
|
+
const paths = await guardPaths(root);
|
|
65
|
+
const held = await Promise.all(
|
|
66
|
+
paths.map(async (path) => ({ path, source: await Bun.file(join(root, path)).text() })),
|
|
67
|
+
);
|
|
68
|
+
const entries: GuardListingEntry[] = [
|
|
69
|
+
...missingShippedGuards(paths),
|
|
70
|
+
...differingShippedGuards(held),
|
|
71
|
+
];
|
|
72
|
+
return entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
29
73
|
}
|
|
30
74
|
|
|
31
75
|
/**
|
|
@@ -34,21 +78,32 @@ export async function shippedGuardsProbe(root: string): Promise<readonly Missing
|
|
|
34
78
|
*/
|
|
35
79
|
export function withGuardListing(
|
|
36
80
|
result: CommandResult,
|
|
37
|
-
|
|
81
|
+
entries: readonly GuardListingEntry[],
|
|
38
82
|
): CommandResult {
|
|
39
83
|
const data = typeof result.data === 'object' && result.data !== null ? result.data : {};
|
|
40
|
-
const
|
|
84
|
+
const missing = entries.filter((entry): entry is MissingGuard => 'add' in entry);
|
|
85
|
+
const differs = entries.filter((entry): entry is DifferingGuard => 'refresh' in entry);
|
|
86
|
+
const lines = [
|
|
87
|
+
...(missing.length === 0
|
|
88
|
+
? []
|
|
89
|
+
: [
|
|
90
|
+
msg('cli.doctor.guards', { count: missing.length }),
|
|
91
|
+
...missing.map(({ add }) => ` ${add}`),
|
|
92
|
+
]),
|
|
93
|
+
...(differs.length === 0
|
|
94
|
+
? []
|
|
95
|
+
: [
|
|
96
|
+
msg('cli.doctor.guardsDiffer', { count: differs.length }),
|
|
97
|
+
...differs.map(({ refresh }) => ` ${refresh}`),
|
|
98
|
+
]),
|
|
99
|
+
];
|
|
100
|
+
const guards: JsonValue = {
|
|
101
|
+
missing: missing.map(({ name, add }) => ({ name, add })),
|
|
102
|
+
differs: differs.map(({ name, refresh }) => ({ name, refresh })),
|
|
103
|
+
};
|
|
41
104
|
return {
|
|
42
105
|
...result,
|
|
43
|
-
data: { ...(data as Record<string, JsonValue>), guards
|
|
44
|
-
...(
|
|
45
|
-
? {}
|
|
46
|
-
: {
|
|
47
|
-
lines: [
|
|
48
|
-
...(result.lines ?? []),
|
|
49
|
-
msg('cli.doctor.guards', { count: missing.length }),
|
|
50
|
-
...missing.map(({ add }) => ` ${add}`),
|
|
51
|
-
],
|
|
52
|
-
}),
|
|
106
|
+
data: { ...(data as Record<string, JsonValue>), guards },
|
|
107
|
+
...(lines.length === 0 ? {} : { lines: [...(result.lines ?? []), ...lines] }),
|
|
53
108
|
};
|
|
54
109
|
}
|
package/src/index.ts
CHANGED
|
@@ -31,12 +31,6 @@ export { GENERATORS, generate, writeFiles } from './cmd-generate';
|
|
|
31
31
|
export { JOBS_SUBCOMMANDS } from './cmd-jobs';
|
|
32
32
|
export { newCommand, planNewApp, writeNewApp } from './cmd-new';
|
|
33
33
|
export { PLANNED_COMMANDS, PLANNED_SUBCOMMANDS, plannedSubcommand } from './cmd-planned';
|
|
34
|
-
// `shotCommand`, `prCommand` and `ciCommand` are deliberately NOT re-exported here. They reach
|
|
35
|
-
// `x` through `registry.ts`, which is the only thing that makes a command exist — and the barrel
|
|
36
|
-
// is the surface an APP imports. Exporting them would put the browser driver and the GitHub client
|
|
37
|
-
// in the module graph of every app that imports `@ultimat3/cli`, for tools it never calls. The app
|
|
38
|
-
// path does not pay for the tool path.
|
|
39
|
-
export { renderRouteTable } from './cmd-routes';
|
|
40
34
|
export { runVerify, VERIFY_STEPS, verifyCommand, verifyStepNames } from './cmd-verify';
|
|
41
35
|
export type { CliCommand } from './command';
|
|
42
36
|
export { failed, ok } from './command';
|
|
@@ -130,6 +124,12 @@ export { loadPwaArtifacts } from './pwa-artifacts';
|
|
|
130
124
|
export { COMMANDS, cliVersion, SPECS } from './registry';
|
|
131
125
|
export type { RunningRoles, WebBinding } from './role-start';
|
|
132
126
|
export { DEV_BINDING, DEV_ROLES, startRoles } from './role-start';
|
|
127
|
+
// `shotCommand`, `prCommand` and `ciCommand` are deliberately NOT re-exported here. They reach
|
|
128
|
+
// `x` through `registry.ts`, which is the only thing that makes a command exist — and the barrel
|
|
129
|
+
// is the surface an APP imports. Exporting them would put the browser driver and the GitHub client
|
|
130
|
+
// in the module graph of every app that imports `@ultimat3/cli`, for tools it never calls. The app
|
|
131
|
+
// path does not pay for the tool path.
|
|
132
|
+
export { renderRouteTable } from './route-table';
|
|
133
133
|
export { assetRoutes, MEDIA_BASE_PATH } from './runtime-assets';
|
|
134
134
|
export { resolveServices } from './runtime-bindings';
|
|
135
135
|
export { devHooks } from './runtime-hooks';
|
package/src/mcp-host.ts
CHANGED
|
@@ -36,7 +36,6 @@ import type {
|
|
|
36
36
|
VerifyStep,
|
|
37
37
|
} from '@ultimat3/mcp';
|
|
38
38
|
import { DEV_SCOPES, devHost, devMcpServer, frameworkIntrospection } from '@ultimat3/mcp';
|
|
39
|
-
import { describePages } from '@ultimat3/render';
|
|
40
39
|
import { loadApp } from './app-load';
|
|
41
40
|
import { appManifest, policyFacts } from './app-manifest';
|
|
42
41
|
import { runVerify, VERIFY_STEPS } from './cmd-verify';
|
|
@@ -50,6 +49,7 @@ import { testRunOf } from './mcp-test-run';
|
|
|
50
49
|
import { type UiCapabilities, uiCapabilities } from './mcp-ui';
|
|
51
50
|
import { readMigrations } from './migrations';
|
|
52
51
|
import { retryMemo } from './retry-memo';
|
|
52
|
+
import { plainAppRoutes, routeRows } from './route-table';
|
|
53
53
|
import type { DevServices, Env } from './runtime-bindings';
|
|
54
54
|
import { resolveServices } from './runtime-bindings';
|
|
55
55
|
import type { RunningServices } from './runtime-services';
|
|
@@ -346,10 +346,12 @@ export async function createDevMcpServer(input: DevHostInput): Promise<CliMcpSer
|
|
|
346
346
|
// scratch boot re-imported the app after declaring — rendered. Idempotent; a set key stays.
|
|
347
347
|
declareDevEnvironment(input.env);
|
|
348
348
|
await Promise.all([loadApp(input.root), loadCodeFixes()]);
|
|
349
|
+
// The plain `runtime.ts` routes, read once: `routes.list` answers the table `x routes` prints.
|
|
350
|
+
const plain = await plainAppRoutes(input.root);
|
|
349
351
|
const lazy = lazyServices(input);
|
|
350
352
|
const ui = uiCapabilities({ root: input.root, env: input.env });
|
|
351
353
|
const introspection = frameworkIntrospection({
|
|
352
|
-
routes: () =>
|
|
354
|
+
routes: () => routeRows(plain).map((row) => row.json),
|
|
353
355
|
policies: () => policyFacts(),
|
|
354
356
|
});
|
|
355
357
|
const server = devMcpServer({ host: devHost(introspection, capabilities(input, lazy, ui)) });
|
package/src/messages.ts
CHANGED
|
@@ -102,6 +102,8 @@ const CATALOG = {
|
|
|
102
102
|
'cli.doctor.findings': '{count} finding(s)',
|
|
103
103
|
'cli.doctor.guards':
|
|
104
104
|
'{count} shipped guard(s) this app does not hold — each line adds one, then x verify reports what it finds:',
|
|
105
|
+
'cli.doctor.guardsDiffer':
|
|
106
|
+
'{count} shipped guard(s) differ from the current template — an older copy or your own edit; each line rewrites one and its test, review the diff before keeping it:',
|
|
105
107
|
// `x doctor`'s other listing: what the coverage floor in x.verify.json does not see, and why.
|
|
106
108
|
'cli.doctor.coverageExclude':
|
|
107
109
|
'{count} path(s) x.verify.json excludes from the coverage floor — each with the reason it gives:',
|
package/src/role-start.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Role } from '@ultimat3/core';
|
|
10
10
|
import { ctxOf, isRole, logger, markReady, ROLES } from '@ultimat3/core';
|
|
11
|
+
import { dbExecutor } from '@ultimat3/db';
|
|
11
12
|
import type { RateLimitStore, Route, ServerHandle, WebSocketMount } from '@ultimat3/http';
|
|
12
13
|
import {
|
|
13
14
|
adoptRateLimitStore,
|
|
@@ -43,7 +44,6 @@ import { startWorkerWake } from './role-wake';
|
|
|
43
44
|
import { devHooks } from './runtime-hooks';
|
|
44
45
|
import { workerOptionsFor } from './runtime-jobs';
|
|
45
46
|
import { startLiveFeed } from './runtime-live-feed';
|
|
46
|
-
import { pgExecutorFor } from './runtime-queue';
|
|
47
47
|
import { type RunningServices, releaseOrThrow } from './runtime-services';
|
|
48
48
|
import { inlineScriptSources } from './script-csp';
|
|
49
49
|
import { inlineStyleSources } from './style-csp';
|
|
@@ -359,7 +359,7 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
359
359
|
// SESSION-scoped, and the session ends the moment the connection goes back to the pool, so
|
|
360
360
|
// every node reads itself as leader anyway. An expiring row is correct on the executor this
|
|
361
361
|
// package is actually handed.
|
|
362
|
-
const executor =
|
|
362
|
+
const executor = dbExecutor(() => options.runtime.db);
|
|
363
363
|
const scheduler = selected.includes('scheduler')
|
|
364
364
|
? jobScheduler({
|
|
365
365
|
driver: options.runtime.jobs,
|
package/src/role-wake.ts
CHANGED
|
@@ -5,10 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import { gauge } from '@ultimat3/core';
|
|
7
7
|
import type { DbClient } from '@ultimat3/db';
|
|
8
|
-
import { canListen } from '@ultimat3/db';
|
|
8
|
+
import { canListen, dbExecutor } from '@ultimat3/db';
|
|
9
9
|
import type { QueueWake } from '@ultimat3/jobs';
|
|
10
10
|
import { startQueueWake } from '@ultimat3/jobs';
|
|
11
|
-
import { pgExecutorFor } from './runtime-queue';
|
|
12
11
|
|
|
13
12
|
// The wake the gauge reads. One observer for the life of the process: a gauge redeclared with a
|
|
14
13
|
// different `observe` is refused, and `x dev` starts the roles again on every restart-in-place.
|
|
@@ -32,7 +31,7 @@ export function startWorkerWake(db: DbClient): QueueWake | null {
|
|
|
32
31
|
observe: wakeLive,
|
|
33
32
|
});
|
|
34
33
|
}
|
|
35
|
-
const wake = startQueueWake({ listener: db, executor:
|
|
34
|
+
const wake = startQueueWake({ listener: db, executor: dbExecutor(() => db) });
|
|
36
35
|
current = wake;
|
|
37
36
|
return {
|
|
38
37
|
live: () => wake.live(),
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The served route table, as rows: the pages (`describePages()`), then every API route — the
|
|
2
|
+
// action and query projections `apiRoutes()` builds for `x dev` and the container, and the plain
|
|
3
|
+
// routes of `apps/<app>/runtime.ts`. `x routes` and the MCP dev tool `routes.list` both read it.
|
|
4
|
+
|
|
5
|
+
import type { Route } from '@ultimat3/http';
|
|
6
|
+
import type { RouteDescriptor, Surface } from '@ultimat3/render';
|
|
7
|
+
import { describePages } from '@ultimat3/render';
|
|
8
|
+
import { apiRoutes } from './api-routes';
|
|
9
|
+
import { loadAppRuntime } from './app-runtime';
|
|
10
|
+
import type { JsonValue } from './output';
|
|
11
|
+
|
|
12
|
+
/** One printed and one JSON row per served route: a page, a mounted page, or an API route. */
|
|
13
|
+
export interface RouteRow {
|
|
14
|
+
readonly surface: Surface;
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly cells: readonly string[];
|
|
17
|
+
readonly json: JsonValue;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where a route comes from. A file route names its file; a MOUNTED one names the call that
|
|
22
|
+
* mounted it and every permission that gates it — there is no `page.tsx` to name, and printing the
|
|
23
|
+
* package would send a reader looking for a route file inside `node_modules`.
|
|
24
|
+
*/
|
|
25
|
+
const declaredBy = (route: RouteDescriptor): string =>
|
|
26
|
+
route.mount === undefined
|
|
27
|
+
? route.file
|
|
28
|
+
: `${route.mount.by}() · ${route.mount.permissions.join(' + ')}`;
|
|
29
|
+
|
|
30
|
+
const pageRow = (route: RouteDescriptor): RouteRow => ({
|
|
31
|
+
surface: route.surface,
|
|
32
|
+
path: route.path,
|
|
33
|
+
cells: [
|
|
34
|
+
'GET',
|
|
35
|
+
route.path,
|
|
36
|
+
route.surface,
|
|
37
|
+
route.mode,
|
|
38
|
+
route.hydrate,
|
|
39
|
+
route.offline,
|
|
40
|
+
declaredBy(route),
|
|
41
|
+
],
|
|
42
|
+
json: {
|
|
43
|
+
method: 'GET',
|
|
44
|
+
path: route.path,
|
|
45
|
+
surface: route.surface,
|
|
46
|
+
file: route.file,
|
|
47
|
+
render: route.mode,
|
|
48
|
+
hydrate: route.hydrate,
|
|
49
|
+
offline: route.offline,
|
|
50
|
+
budget: { js: route.budgetJs },
|
|
51
|
+
// Absent on a file route, never `null`: only a mounted route has the fact.
|
|
52
|
+
...(route.mount === undefined
|
|
53
|
+
? {}
|
|
54
|
+
: { mount: { by: route.mount.by, permissions: [...route.mount.permissions] } }),
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* An action or query projection, or a plain `runtime.ts` route (`primitive: null`). It renders
|
|
60
|
+
* nothing, so the page columns read `-`; where a page names its file this names what declared it
|
|
61
|
+
* and the permission its policy asks for.
|
|
62
|
+
*/
|
|
63
|
+
const apiRow = (route: Route): RouteRow => {
|
|
64
|
+
const primitive = route.meta.primitive ?? null;
|
|
65
|
+
const policy = route.meta.policy ?? null;
|
|
66
|
+
const from = primitive === null ? 'runtime.ts routes' : primitive;
|
|
67
|
+
const by = `${from} ${route.meta.name}${policy === null ? '' : ` · ${policy}`}`;
|
|
68
|
+
return {
|
|
69
|
+
surface: 'api',
|
|
70
|
+
path: route.path,
|
|
71
|
+
cells: [route.method, route.path, 'api', '-', '-', '-', by],
|
|
72
|
+
json: {
|
|
73
|
+
method: route.method,
|
|
74
|
+
path: route.path,
|
|
75
|
+
surface: 'api',
|
|
76
|
+
primitive,
|
|
77
|
+
name: route.meta.name,
|
|
78
|
+
auth: route.meta.auth,
|
|
79
|
+
policy,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const byPath = (a: RouteRow, b: RouteRow): number =>
|
|
85
|
+
a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
86
|
+
|
|
87
|
+
/** Pages first, in the page table's order; then the API, by path, so the listing diffs cleanly. */
|
|
88
|
+
export function routeRows(plain: readonly Route[] = []): readonly RouteRow[] {
|
|
89
|
+
return [...describePages().map(pageRow), ...[...apiRoutes(), ...plain].map(apiRow).sort(byPath)];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The plain HTTP routes `apps/<app>/runtime.ts` declares — the ones `X_BODY_INVALID` is about,
|
|
94
|
+
* since an action or query validates its own input. Loaded as `x dev` loads them.
|
|
95
|
+
*/
|
|
96
|
+
export async function plainAppRoutes(root: string): Promise<readonly Route[]> {
|
|
97
|
+
return (await loadAppRuntime(root))?.routes ?? [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Fixed-width columns so the output diffs cleanly between runs and between machines. */
|
|
101
|
+
export function renderRouteTable(routes: readonly RouteDescriptor[]): readonly string[] {
|
|
102
|
+
return renderRouteRows(routes.map(pageRow));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function renderRouteRows(rows: readonly RouteRow[]): readonly string[] {
|
|
106
|
+
const header = ['method', 'path', 'surface', 'render', 'hydrate', 'offline', 'declared by'];
|
|
107
|
+
const widths = header.map((title, index) =>
|
|
108
|
+
Math.max(title.length, ...rows.map((row) => (row.cells[index] ?? '').length)),
|
|
109
|
+
);
|
|
110
|
+
const line = (cells: readonly string[]): string =>
|
|
111
|
+
cells.map((value, index) => value.padEnd(widths[index] ?? 0)).join(' ');
|
|
112
|
+
return [line(header), ...rows.map((row) => line(row.cells))];
|
|
113
|
+
}
|
package/src/runtime-queue.ts
CHANGED
|
@@ -11,17 +11,18 @@ import {
|
|
|
11
11
|
resetIdempotency,
|
|
12
12
|
setIdempotencyStore,
|
|
13
13
|
} from '@ultimat3/action';
|
|
14
|
-
import type {
|
|
14
|
+
import type { PgliteClient, PostgresClient } from '@ultimat3/db';
|
|
15
15
|
import {
|
|
16
16
|
baseClient,
|
|
17
17
|
currentTx,
|
|
18
|
+
dbExecutor,
|
|
18
19
|
pgliteClient,
|
|
19
20
|
pgliteDataDir,
|
|
20
21
|
postgresClient,
|
|
21
22
|
setDbClient,
|
|
22
23
|
} from '@ultimat3/db';
|
|
23
24
|
import type { Tx } from '@ultimat3/entity';
|
|
24
|
-
import type { EventBus, JobDriver, OutboxStore
|
|
25
|
+
import type { EventBus, JobDriver, OutboxStore } from '@ultimat3/jobs';
|
|
25
26
|
import {
|
|
26
27
|
outboxJobsFacade,
|
|
27
28
|
postgresEventBus,
|
|
@@ -109,20 +110,6 @@ export function startDb(services: DevServices, env: ReplicaEnv): StartedDb {
|
|
|
109
110
|
return { client, replica: attached.replica };
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
/**
|
|
113
|
-
* `@ultimat3/jobs` deliberately depends on no database package: Postgres reaches it as an
|
|
114
|
-
* injected `PgExecutor`. Boot code is what supplies one, and this is the boot.
|
|
115
|
-
*
|
|
116
|
-
* The fragment is assembled by hand rather than through `sql`` ` because the driver hands over
|
|
117
|
-
* `$1..$n` text it wrote itself plus already-bound values — there is no interpolation to guard.
|
|
118
|
-
*/
|
|
119
|
-
export function pgExecutorFor(client: DbClient): PgExecutor {
|
|
120
|
-
return {
|
|
121
|
-
query: <R>(text: string, values: readonly unknown[]): Promise<readonly R[]> =>
|
|
122
|
-
client.query<R>({ text, values } satisfies SqlFragment),
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
113
|
/**
|
|
127
114
|
* Every table this process's framework packages own, applied before anything reads one.
|
|
128
115
|
*
|
|
@@ -183,13 +170,15 @@ async function startJobs(
|
|
|
183
170
|
schema: SchemaMode,
|
|
184
171
|
): Promise<RunningQueue> {
|
|
185
172
|
await applySchema(client, schema);
|
|
186
|
-
|
|
173
|
+
// Bound to `client`, never the ambient `db()`: the queue, the event bus and the idempotency
|
|
174
|
+
// reservations stay on the boot's pool whatever transaction is open.
|
|
175
|
+
const executor = dbExecutor(() => client);
|
|
187
176
|
const driver = overrides?.jobs ?? postgresJobDriver({ executor });
|
|
188
177
|
setJobDriver(driver);
|
|
189
178
|
const outbox = postgresOutboxStore({
|
|
190
179
|
executor,
|
|
191
180
|
// The open transaction is a client on its own connection; the `Tx` token is not that object.
|
|
192
|
-
txExecutor: () =>
|
|
181
|
+
txExecutor: () => dbExecutor(() => currentTx() ?? client),
|
|
193
182
|
});
|
|
194
183
|
setJobsFacade(
|
|
195
184
|
outboxJobsFacade({ store: outbox, driver }, () => currentTx() as unknown as Tx | undefined),
|
package/src/runtime-services.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
resolveEnvironment,
|
|
16
16
|
systemClock,
|
|
17
17
|
} from '@ultimat3/core';
|
|
18
|
+
import { dbExecutor } from '@ultimat3/db';
|
|
18
19
|
import type { RateLimitStore } from '@ultimat3/http';
|
|
19
20
|
import { adoptRateLimitStore, postgresRateLimitStore } from '@ultimat3/http';
|
|
20
21
|
import type { EventBus, JobDriver, OutboxStore } from '@ultimat3/jobs';
|
|
@@ -40,7 +41,7 @@ import { inboxRetentionOf } from './runtime-notify-retention';
|
|
|
40
41
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
41
42
|
import { installRetentionSweep } from './runtime-purge';
|
|
42
43
|
import type { DevDbClient, SchemaMode } from './runtime-queue';
|
|
43
|
-
import {
|
|
44
|
+
import { startQueue } from './runtime-queue';
|
|
44
45
|
import { realtimeConfigOf } from './runtime-realtime';
|
|
45
46
|
|
|
46
47
|
export interface RunningServices {
|
|
@@ -351,7 +352,7 @@ export async function startServices(
|
|
|
351
352
|
// The same executor the jobs driver, the outbox, the event bus and the idempotency store run
|
|
352
353
|
// on — one pool, one `Bun.sql` that does NOT satisfy `PgExecutor` (`Bun.sql.query` is
|
|
353
354
|
// `undefined`), one wrapper.
|
|
354
|
-
const executor =
|
|
355
|
+
const executor = dbExecutor(() => db);
|
|
355
356
|
const rateLimitStore = postgresRateLimitStore({ executor });
|
|
356
357
|
// Boot is a sequence of external resources, and every step after the first can reject — the
|
|
357
358
|
// queue is already up, so from here an unwind must release it exactly like everything after it.
|
|
@@ -75,6 +75,18 @@ export const SHIPPED_GUARD_NAMES: readonly string[] = SHIPPED.map(([name]) => na
|
|
|
75
75
|
export const shippedGuardFiles = (name: string): readonly GeneratedFile[] | undefined =>
|
|
76
76
|
SHIPPED.find(([shipped]) => shipped === name)?.[1]();
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The current text of a shipped guard's rule file (`guards/<name>.ts`), or `undefined` when the
|
|
80
|
+
* name was never shipped — what `x doctor` compares an app's copy against.
|
|
81
|
+
*/
|
|
82
|
+
export const shippedGuardSource = (name: string): string | undefined => {
|
|
83
|
+
const contents = shippedGuardFiles(name)?.find(
|
|
84
|
+
(file) => file.path === `guards/${name}.ts`,
|
|
85
|
+
)?.contents;
|
|
86
|
+
// A guard is source text; bytes would be a template bug, and comparing them as text would hide it.
|
|
87
|
+
return typeof contents === 'string' ? contents : undefined;
|
|
88
|
+
};
|
|
89
|
+
|
|
78
90
|
/**
|
|
79
91
|
* Every guard `x new` ships, each with its test. One module per guard, because an app deletes a
|
|
80
92
|
* rule it does not want by deleting one file — and a guard the app then writes for itself is
|