@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "25.0.0",
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.0.0",
43
- "@ultimat3/admin": "25.0.0",
44
- "@ultimat3/ai": "25.0.0",
45
- "@ultimat3/auth": "25.0.0",
46
- "@ultimat3/cache": "25.0.0",
47
- "@ultimat3/core": "25.0.0",
48
- "@ultimat3/db": "25.0.0",
49
- "@ultimat3/entity": "25.0.0",
50
- "@ultimat3/flags": "25.0.0",
51
- "@ultimat3/http": "25.0.0",
52
- "@ultimat3/i18n": "25.0.0",
53
- "@ultimat3/jobs": "25.0.0",
54
- "@ultimat3/mail": "25.0.0",
55
- "@ultimat3/manifest": "25.0.0",
56
- "@ultimat3/mcp": "25.0.0",
57
- "@ultimat3/money": "25.0.0",
58
- "@ultimat3/notify": "25.0.0",
59
- "@ultimat3/policy": "25.0.0",
60
- "@ultimat3/pwa": "25.0.0",
61
- "@ultimat3/query": "25.0.0",
62
- "@ultimat3/realtime": "25.0.0",
63
- "@ultimat3/render": "25.0.0",
64
- "@ultimat3/schema": "25.0.0",
65
- "@ultimat3/seo": "25.0.0",
66
- "@ultimat3/storage": "25.0.0",
67
- "@ultimat3/testing": "25.0.0",
68
- "@ultimat3/time": "25.0.0",
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
  }
@@ -5,7 +5,7 @@ import type { CommandSpec } from './parse';
5
5
 
6
6
  export const routesSpec: CommandSpec = {
7
7
  name: 'routes',
8
- summary: 'the route table: path, surface, render mode, hydrate, offline',
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 `@ultimat3/render`'s own `describePages()`: the CLI prints the route table, it
5
- // does not keep a second one.
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 { RouteDescriptor, Surface } from '@ultimat3/render';
8
- import { describePages, SURFACES } from '@ultimat3/render';
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, JsonValue } from './output';
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 = describePages().filter(
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 ? [] : renderRouteTable(routes).map((line) => ` ${line}`),
67
+ lines: routes.length === 0 ? [] : renderRouteRows(routes).map((line) => ` ${line}`),
111
68
  findings,
112
- data: { routes: routeJson(routes) },
69
+ data: { routes: routes.map((route) => route.json) },
113
70
  };
114
71
  },
115
72
  };
@@ -1,12 +1,14 @@
1
- // `x doctor`'s guard listing: the guards the framework ships that this app's `guards/` does not
2
- // hold, each with the command that adds it. A LISTING, never a finding: every shipped guard says
3
- // "delete this file to drop the rule", so an app without one may have decided — and a red
4
- // `x doctor` over a decision would be the framework overruling the convention it handed over.
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 MissingGuard[]> {
28
- return missingShippedGuards(await guardPaths(root));
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
- missing: readonly MissingGuard[],
81
+ entries: readonly GuardListingEntry[],
38
82
  ): CommandResult {
39
83
  const data = typeof result.data === 'object' && result.data !== null ? result.data : {};
40
- const listed: JsonValue = missing.map(({ name, add }) => ({ name, add }));
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: { missing: listed } },
44
- ...(missing.length === 0
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: () => describePages(),
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 = pgExecutorFor(options.runtime.db);
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: pgExecutorFor(db) });
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
+ }
@@ -11,17 +11,18 @@ import {
11
11
  resetIdempotency,
12
12
  setIdempotencyStore,
13
13
  } from '@ultimat3/action';
14
- import type { DbClient, PgliteClient, PostgresClient, SqlFragment } from '@ultimat3/db';
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, PgExecutor } from '@ultimat3/jobs';
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
- const executor = pgExecutorFor(client);
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: () => pgExecutorFor(currentTx() ?? client),
181
+ txExecutor: () => dbExecutor(() => currentTx() ?? client),
193
182
  });
194
183
  setJobsFacade(
195
184
  outboxJobsFacade({ store: outbox, driver }, () => currentTx() as unknown as Tx | undefined),
@@ -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 { pgExecutorFor, startQueue } from './runtime-queue';
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 = pgExecutorFor(db);
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