@ultimat3/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/package.json +60 -0
  4. package/src/app-agents-md.ts +27 -0
  5. package/src/app-boundaries.ts +206 -0
  6. package/src/app-evals.ts +74 -0
  7. package/src/app-load.ts +136 -0
  8. package/src/app-manifest.ts +137 -0
  9. package/src/app-openapi.ts +12 -0
  10. package/src/app-root.ts +57 -0
  11. package/src/bin.ts +17 -0
  12. package/src/boundary-cuts.ts +219 -0
  13. package/src/budgets.ts +92 -0
  14. package/src/cmd-build.ts +109 -0
  15. package/src/cmd-db.ts +187 -0
  16. package/src/cmd-deploy.ts +124 -0
  17. package/src/cmd-dev.ts +286 -0
  18. package/src/cmd-doctor.ts +178 -0
  19. package/src/cmd-errors.ts +99 -0
  20. package/src/cmd-fix.ts +126 -0
  21. package/src/cmd-generate.ts +434 -0
  22. package/src/cmd-help.ts +94 -0
  23. package/src/cmd-i18n.ts +212 -0
  24. package/src/cmd-jobs.ts +237 -0
  25. package/src/cmd-manifest.ts +97 -0
  26. package/src/cmd-mcp.ts +176 -0
  27. package/src/cmd-new.ts +133 -0
  28. package/src/cmd-planned.ts +119 -0
  29. package/src/cmd-policy.ts +136 -0
  30. package/src/cmd-registries.ts +195 -0
  31. package/src/cmd-routes.ts +73 -0
  32. package/src/cmd-tasks.ts +151 -0
  33. package/src/cmd-test.ts +109 -0
  34. package/src/cmd-verify.ts +265 -0
  35. package/src/command.ts +33 -0
  36. package/src/dev-assets.ts +177 -0
  37. package/src/dev-dashboard.ts +242 -0
  38. package/src/dev-hooks.ts +51 -0
  39. package/src/dev-policy.ts +82 -0
  40. package/src/dev-queue.ts +109 -0
  41. package/src/dev-render.ts +129 -0
  42. package/src/dev-replicator.ts +92 -0
  43. package/src/dev-roles.ts +246 -0
  44. package/src/dev-runtime.ts +203 -0
  45. package/src/dev-services.ts +75 -0
  46. package/src/dev-traces.ts +141 -0
  47. package/src/dispatch.ts +98 -0
  48. package/src/drift.ts +86 -0
  49. package/src/error-catalog.ts +156 -0
  50. package/src/error-contract.ts +212 -0
  51. package/src/errors.ts +367 -0
  52. package/src/exec.ts +70 -0
  53. package/src/hold.ts +48 -0
  54. package/src/i18n-audit.ts +183 -0
  55. package/src/index.ts +179 -0
  56. package/src/jobs-drain.ts +151 -0
  57. package/src/jobs-json.ts +134 -0
  58. package/src/jobs-report.ts +132 -0
  59. package/src/jobs-table.ts +34 -0
  60. package/src/json-merge.ts +40 -0
  61. package/src/mcp-db-target.ts +50 -0
  62. package/src/mcp-errors.ts +99 -0
  63. package/src/mcp-host.ts +282 -0
  64. package/src/mcp-test-output.ts +57 -0
  65. package/src/messages.ts +119 -0
  66. package/src/output.ts +174 -0
  67. package/src/parse.ts +243 -0
  68. package/src/policy-facts.ts +196 -0
  69. package/src/policy-fixture.ts +71 -0
  70. package/src/registry.ts +73 -0
  71. package/src/scaffold-fixture.ts +69 -0
  72. package/src/scaffold-typecheck.ts +240 -0
  73. package/src/source-files.ts +38 -0
  74. package/src/table.ts +19 -0
  75. package/src/tasks-facts.ts +113 -0
  76. package/src/templates/action.ts +193 -0
  77. package/src/templates/admin.ts +46 -0
  78. package/src/templates/catalog-json.ts +17 -0
  79. package/src/templates/entity.ts +157 -0
  80. package/src/templates/index.ts +23 -0
  81. package/src/templates/job.ts +148 -0
  82. package/src/templates/locales.ts +93 -0
  83. package/src/templates/naming.ts +97 -0
  84. package/src/templates/policy.ts +120 -0
  85. package/src/templates/query.ts +116 -0
  86. package/src/templates/resource.ts +199 -0
  87. package/src/templates/route.ts +138 -0
  88. package/src/templates/scaffold-app.ts +320 -0
  89. package/src/templates/scaffold-docs.ts +156 -0
  90. package/src/templates/scaffold-i18n.ts +149 -0
  91. package/src/templates/scaffold-icon.ts +54 -0
  92. package/src/templates/scaffold-package-shape.ts +49 -0
  93. package/src/templates/scaffold-repo.ts +427 -0
  94. package/src/test-select.ts +130 -0
  95. package/src/test-shards.ts +188 -0
  96. package/src/thrown-by.ts +24 -0
  97. package/src/ts-scan.ts +217 -0
  98. package/src/verify-step.ts +83 -0
  99. package/src/verify-tests.ts +166 -0
  100. package/src/version-loader.ts +16 -0
  101. package/src/workspace-checks.ts +288 -0
@@ -0,0 +1,242 @@
1
+ // Mounting `@ultimat3/admin`'s `/_x` dashboard in the `x dev` process. The CLI contributes only
2
+ // what no registry holds — a SQL runner on the live dev database, the caught outbox, the committed
3
+ // manifest, and two panels of process facts — and projects the dashboard onto HTTP routes.
4
+ // A panel implemented here instead of in `admin` would be the second copy this seam exists to ban.
5
+
6
+ import type {
7
+ DevPanel,
8
+ DevSources,
9
+ InvalidationFact,
10
+ MailFact,
11
+ ManifestFact,
12
+ PolicyFact,
13
+ RequestTrace,
14
+ SqlResult,
15
+ } from '@ultimat3/admin/dev';
16
+ import { DEV_BASE_PATH, DEV_PANELS, defaultDevSources, devDashboard } from '@ultimat3/admin/dev';
17
+ import { recentInvalidations } from '@ultimat3/cache';
18
+ import type { Role } from '@ultimat3/core';
19
+ import type { Route, UltimateRequest } from '@ultimat3/http';
20
+ import { json as jsonResponse } from '@ultimat3/http';
21
+ import type { MemoryMailDriver } from '@ultimat3/mail';
22
+ import { isMemoryDriver } from '@ultimat3/mail';
23
+ import type { Manifest } from '@ultimat3/manifest';
24
+ import { checkAppBoundaries } from './app-boundaries';
25
+ import { appManifest, readAppManifest } from './app-manifest';
26
+ import { devPolicyMatrix } from './dev-policy';
27
+ import type { RunningServices } from './dev-runtime';
28
+ import type { DevServices } from './dev-services';
29
+ import type { TraceRecorder } from './dev-traces';
30
+ import type { Finding } from './output';
31
+
32
+ export interface DevStatus {
33
+ readonly url: string;
34
+ readonly services: DevServices;
35
+ readonly roles: readonly Role[];
36
+ readonly findings: readonly Finding[];
37
+ readonly reloads: number;
38
+ }
39
+
40
+ export interface DevDashboardInput {
41
+ readonly root: string;
42
+ readonly runtime: RunningServices;
43
+ /** Read at request time: the process's live facts change while the dashboard is mounted. */
44
+ status(): DevStatus;
45
+ /** NODE_ENV/X_ENV as x dev saw it; `devDashboard` refuses to mount in production. */
46
+ readonly env?: string | undefined;
47
+ /** The spans this process recorded. Absent when `x dev` did not install the exporter. */
48
+ readonly traces?: TraceRecorder | undefined;
49
+ }
50
+
51
+ /**
52
+ * Read-only is already enforced by `assertReadOnly` inside `dbPanel`, before `runSql` is ever
53
+ * reached. A second gate here would be a second authz: two places to update when `x db psql
54
+ * --write` changes what is allowed, and one of them would eventually disagree.
55
+ */
56
+ async function runSql(input: DevDashboardInput, sql: string): Promise<SqlResult> {
57
+ const started = performance.now();
58
+ const rows = await input.runtime.db.query<Readonly<Record<string, unknown>>>({
59
+ text: sql,
60
+ values: [],
61
+ });
62
+ const elapsedMs = Math.round(performance.now() - started);
63
+ // Columns come from the first row because the driver returns objects, not a described result
64
+ // set; no rows means no columns to name, which the panel renders as an empty grid.
65
+ const columns = Object.keys(rows[0] ?? {});
66
+ return { columns, rows: rows.map((row) => columns.map((column) => row[column])), elapsedMs };
67
+ }
68
+
69
+ /** `MailMessage.locale` is non-optional in `@ultimat3/mail`, so the panel never has to guess. */
70
+ function mailFacts(outbox: MemoryMailDriver): readonly MailFact[] {
71
+ return outbox.outbox().map((entry) => ({
72
+ id: entry.result.id,
73
+ to: entry.message.to.join(', '),
74
+ subject: entry.message.subject,
75
+ locale: entry.message.locale,
76
+ html: entry.message.html,
77
+ text: entry.message.text,
78
+ sentAt: entry.at.toISOString(),
79
+ }));
80
+ }
81
+
82
+ /** Top-level keys only: that is the granularity `manifestPanel` splits into added/removed/changed. */
83
+ const topLevel = (manifest: Manifest | undefined): ReadonlyMap<string, unknown> =>
84
+ new Map<string, unknown>(manifest === undefined ? [] : Object.entries(manifest));
85
+
86
+ /**
87
+ * A side that is missing stays `undefined` rather than becoming `null`: `manifestPanel` reads
88
+ * exactly that distinction to tell an added key from a changed one.
89
+ */
90
+ function manifestDiff(emitted: Manifest, committed: Manifest | undefined): ManifestFact['diff'] {
91
+ const left = topLevel(emitted);
92
+ const right = topLevel(committed);
93
+ return [...new Set([...left.keys(), ...right.keys()])]
94
+ .filter((key) => JSON.stringify(left.get(key)) !== JSON.stringify(right.get(key)))
95
+ .map((key) => ({ path: key, emitted: left.get(key), committed: right.get(key) }));
96
+ }
97
+
98
+ async function manifestFact(root: string): Promise<ManifestFact> {
99
+ const [{ manifest: emitted }, committed] = await Promise.all([
100
+ appManifest(root),
101
+ readAppManifest(root),
102
+ ]);
103
+ return { emitted, committed: committed ?? null, diff: manifestDiff(emitted, committed) };
104
+ }
105
+
106
+ /**
107
+ * `@ultimat3/cache` keeps the report of every `invalidateTags` fan-out; the panel reads it back
108
+ * as its log. Only the shape differs — the cache owns the facts, /_x owns the rendering.
109
+ */
110
+ const invalidationFacts = (): readonly InvalidationFact[] =>
111
+ recentInvalidations().map((event) => ({
112
+ at: event.at,
113
+ tags: event.tags,
114
+ busted: event.busted,
115
+ source: event.source,
116
+ }));
117
+
118
+ /**
119
+ * Every source only this process can answer. It owns the SQL connection, the caught outbox, the
120
+ * committed manifest on disk, the span exporter and the app's registries — no other host can.
121
+ *
122
+ * `subscribers` is the one left unwired, and stays that way until `@ultimat3/realtime` records a
123
+ * subscriber's matcher decision: `LiveSubscriberFact.trace` is the live panel's whole question,
124
+ * and nothing in the registry retains it. `defaultDevSources` rejects it with `X_NOT_IMPLEMENTED`
125
+ * and the wiring line, which the live panel degrades into its own `dev.live.no-sync-node` note —
126
+ * a bare empty list would claim nobody is subscribed, which is a different and unearned answer.
127
+ */
128
+ export function devSources(input: DevDashboardInput): DevSources {
129
+ const traces = input.traces;
130
+ // Only the memory driver retains what it accepted. Once a credential selects a real transport
131
+ // the messages are at the provider, so the hook is omitted rather than answered with `[]` —
132
+ // an empty outbox claims nobody was mailed, which is a different and unearned answer.
133
+ const outbox = isMemoryDriver(input.runtime.mail) ? input.runtime.mail : undefined;
134
+ return defaultDevSources({
135
+ hooks: {
136
+ runSql: (sql: string): Promise<SqlResult> => runSql(input, sql),
137
+ ...(outbox === undefined
138
+ ? {}
139
+ : { mail: (): Promise<readonly MailFact[]> => Promise.resolve(mailFacts(outbox)) }),
140
+ manifest: (): Promise<ManifestFact> => manifestFact(input.root),
141
+ invalidations: (): Promise<readonly InvalidationFact[]> =>
142
+ Promise.resolve(invalidationFacts()),
143
+ // Read through the app's own policies at request time, so a reload that changes a rule
144
+ // changes the matrix without a remount.
145
+ policyMatrix: (): Promise<readonly PolicyFact[]> => Promise.resolve(devPolicyMatrix()),
146
+ // Spread, never passed as `undefined`: a host with no recorder must fall back to the
147
+ // refusal `defaultDevSources` already carries, not to an empty timeline.
148
+ ...(traces === undefined
149
+ ? {}
150
+ : { traces: (): Promise<readonly RequestTrace[]> => Promise.resolve(traces.traces()) }),
151
+ },
152
+ });
153
+ }
154
+
155
+ interface ServicesPanelData extends DevStatus {
156
+ readonly stateDir: string;
157
+ }
158
+
159
+ /**
160
+ * Both CLI panels ignore the `DevSources` argument, and must: these are facts about this
161
+ * process — which port it bound, which roles it started, which files would not import — not
162
+ * introspection of the app's registries. No registry could answer them.
163
+ */
164
+ const servicesPanel = (input: DevDashboardInput): DevPanel<ServicesPanelData> => ({
165
+ key: 'services',
166
+ titleKey: 'dev.panel.services',
167
+ question: 'which services is this process talking to, and did anything fail to load?',
168
+ data(): Promise<ServicesPanelData> {
169
+ const status = input.status();
170
+ return Promise.resolve({ ...status, stateDir: status.services.stateDir });
171
+ },
172
+ });
173
+
174
+ interface BoundariesPanelData {
175
+ readonly findings: readonly Finding[];
176
+ }
177
+
178
+ const boundariesPanel = (input: DevDashboardInput): DevPanel<BoundariesPanelData> => ({
179
+ key: 'boundaries',
180
+ titleKey: 'dev.panel.boundaries',
181
+ question: 'does any file import across a boundary the build will reject?',
182
+ async data(): Promise<BoundariesPanelData> {
183
+ return { findings: await checkAppBoundaries(input.root) };
184
+ },
185
+ });
186
+
187
+ export function devPanels(input: DevDashboardInput): readonly DevPanel[] {
188
+ return [...DEV_PANELS, servicesPanel(input), boundariesPanel(input)];
189
+ }
190
+
191
+ /**
192
+ * `handle` answers `null` only for a path outside `basePath`, and every path below was generated
193
+ * from it — unreachable, answered anyway. A `!` here would turn a future `basePath` change into a
194
+ * runtime crash instead of a payload that names the mismatch.
195
+ */
196
+ const notClaimed = (path: string): Response =>
197
+ jsonResponse(
198
+ {
199
+ panel: path,
200
+ ok: false,
201
+ error: {
202
+ code: 'X_ROUTE_NOT_FOUND',
203
+ cause: `x dev mounted ${path} but the /_x dashboard did not claim it`,
204
+ fix: 'x dev --json # then report the DEV_BASE_PATH / route table mismatch',
205
+ },
206
+ },
207
+ { status: 404 },
208
+ );
209
+
210
+ const devRoute = (path: string, name: string, handler: Route['handler']): Route => ({
211
+ method: 'GET',
212
+ path,
213
+ // Public: /_x exists to be read without credentials by whatever drives the dev loop, and
214
+ // `devDashboard` refuses to construct at all outside development.
215
+ meta: { name, auth: 'public', tags: ['_x'] },
216
+ handler,
217
+ });
218
+
219
+ /**
220
+ * One route for the base path plus one per panel, because the router matches exact paths. The
221
+ * dashboard is built once — its sources close over this process, and rebuilding per request would
222
+ * re-run `assertDevOnly` on every hit for no new answer.
223
+ */
224
+ export function devDashboardRoutes(input: DevDashboardInput): readonly Route[] {
225
+ const panels = devPanels(input);
226
+ const dashboard = devDashboard({
227
+ basePath: DEV_BASE_PATH,
228
+ panels,
229
+ sources: devSources(input),
230
+ ...(input.env === undefined ? {} : { env: input.env }),
231
+ });
232
+
233
+ const handler = async (request: UltimateRequest): Promise<Response> =>
234
+ (await dashboard.handle(request.raw)) ?? notClaimed(request.pathname);
235
+
236
+ return [
237
+ devRoute(DEV_BASE_PATH, 'dev._x', handler),
238
+ ...panels.map((panel) =>
239
+ devRoute(`${DEV_BASE_PATH}/${panel.key}`, `dev._x.${panel.key}`, handler),
240
+ ),
241
+ ];
242
+ }
@@ -0,0 +1,51 @@
1
+ // The two seams `@ultimat3/http` leaves open, bound to the packages that own them. `authorize`
2
+ // decides for pages only, from the SAME `Policy` object every other surface evaluates — the route
3
+ // table's declared permission — so a denial in `x dev` is the one production produces.
4
+
5
+ import { actorOf } from '@ultimat3/action';
6
+ import type { AuthzDecision, ServerHooks } from '@ultimat3/http';
7
+ import { asCtx } from '@ultimat3/http';
8
+ import type { KnownPermission, Policy } from '@ultimat3/policy';
9
+ import { can, evaluate } from '@ultimat3/policy';
10
+ import { routeFor } from '@ultimat3/render';
11
+
12
+ /**
13
+ * `RouteGuard.permission` is a bare string — `@ultimat3/render` keeps policy structural on
14
+ * purpose. `can()` checks the name against the registry; this only checks the shape, so a
15
+ * malformed guard denies with "no policy registered" instead of throwing inside the pipeline.
16
+ */
17
+ const isPermission = (value: string): value is KnownPermission => /^[^:]+:[^:]+$/.test(value);
18
+
19
+ /** A page carries only the permission label, because that is all `RouteGuard` keeps. */
20
+ function policyFor(path: string): Policy<unknown, unknown> | undefined {
21
+ const permission = routeFor(path)?.config.policy?.permission;
22
+ return permission !== undefined && isPermission(permission) ? can(permission) : undefined;
23
+ }
24
+
25
+ export function devHooks(): ServerHooks {
26
+ return {
27
+ authorize: (route, _request, ctx): AuthzDecision => {
28
+ // An action route never arrives here: it carries `enforcedBy: 'handler'`, so the pipeline
29
+ // never asks. `invoke` is its one evaluation, and the only one holding the row a row-level
30
+ // rule reads — reconstructing it here would be a second authz system, one row short.
31
+ const policy = policyFor(route.path);
32
+ if (policy === undefined) {
33
+ return {
34
+ allowed: false,
35
+ reason: `no policy is registered under ${route.meta.policy ?? route.meta.name}`,
36
+ };
37
+ }
38
+ // `actorOf` is the framework's own anonymous → null mapping, so "nobody" denies with
39
+ // X_UNAUTHENTICATED here exactly as it does inside `invoke`. The decision is passed
40
+ // straight through: `PolicyDecision` and `AuthzDecision` are the same shape by design,
41
+ // and an adapter here would be the beginning of a second authz model.
42
+ const context = asCtx(ctx);
43
+ return evaluate(policy, {
44
+ input: ctx.input,
45
+ actor: actorOf(context),
46
+ row: null,
47
+ ctx: context,
48
+ }).decision;
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,82 @@
1
+ // The `/_x` policy panel's source: the app's own policies, decided by `@ultimat3/policy`'s own
2
+ // `policyMatrix()` — the same function `x g policy` generates a test against. The CLI supplies
3
+ // only the two things no registry holds: which actors to ask about, and which capability each
4
+ // policy gates. Re-deriving a verdict here would be the second authz the framework bans.
5
+
6
+ import { listActions } from '@ultimat3/action';
7
+ import type { PolicyFact } from '@ultimat3/admin/dev';
8
+ import type { NamedActor, Policy } from '@ultimat3/policy';
9
+ import { policyMatrix, roleDefinitions, testActor } from '@ultimat3/policy';
10
+ import { listQueries } from '@ultimat3/query';
11
+
12
+ /**
13
+ * One capability and the policy that decides it. A primitive's `capability` IS its policy's own
14
+ * label (`policyCapability` returns exactly that), so the capability identifies the gate: two
15
+ * primitives reporting the same one are two call sites of one rule, not two answers to one cell.
16
+ */
17
+ interface PolicyGate {
18
+ readonly permission: string;
19
+ readonly policy: Policy;
20
+ /** The primitives that gate on it — the panel's answer to "where is this enforced?". */
21
+ readonly usedBy: readonly string[];
22
+ }
23
+
24
+ /**
25
+ * Every actor the matrix is computed for: one per role the app declared with `defineRoles`, plus
26
+ * the anonymous caller. Derived rather than flagged, because the roles ARE the app's declaration
27
+ * of who exists — a `--actor` flag would be a second place to keep that list.
28
+ */
29
+ export function devActors(): readonly NamedActor[] {
30
+ const roles = Object.keys(roleDefinitions()).sort();
31
+ return [
32
+ { name: 'anonymous', actor: null },
33
+ ...roles.map((role) => testActor(role, { roles: [role] })),
34
+ ];
35
+ }
36
+
37
+ /** Every gated capability in the app, actions and queries alike, sorted for a stable panel. */
38
+ export function devPolicyGates(): readonly PolicyGate[] {
39
+ const gates = new Map<string, { permission: string; policy: Policy; usedBy: string[] }>();
40
+ const add = (permission: string, policy: Policy, primitive: string): void => {
41
+ // A policy that gates on nothing reports an empty capability; there is no cell to draw for it.
42
+ if (permission.length === 0) return;
43
+ const existing = gates.get(permission);
44
+ if (existing === undefined) gates.set(permission, { permission, policy, usedBy: [primitive] });
45
+ else existing.usedBy.push(primitive);
46
+ };
47
+
48
+ for (const target of listActions())
49
+ add(target.describe().capability, target.policy, `action:${target.name}`);
50
+ for (const target of listQueries())
51
+ add(target.describe().capability, target.policy, `query:${target.name}`);
52
+
53
+ return [...gates.values()].sort((a, b) => a.permission.localeCompare(b.permission));
54
+ }
55
+
56
+ /**
57
+ * The matrix, actor by actor and capability by capability.
58
+ *
59
+ * Evaluated with no row on purpose: `/_x` asks whether an actor may reach a capability at all, and
60
+ * there is no row to hand a row-level rule outside a real request. A rule that needs one therefore
61
+ * shows its no-row verdict, and the trace says so rather than letting the cell read as a flat deny.
62
+ */
63
+ export function devPolicyMatrix(): readonly PolicyFact[] {
64
+ const actors = devActors();
65
+ return devPolicyGates().flatMap((gate) => {
66
+ const matrix = policyMatrix(gate.policy, { actors, input: {} });
67
+ return matrix.rows.map(
68
+ (row): PolicyFact => ({
69
+ permission: gate.permission,
70
+ actorId: row.actor,
71
+ allowed: row.allowed,
72
+ trace: [
73
+ `${gate.policy.label}: ${row.allowed ? 'allow' : 'deny'}`,
74
+ ...(row.deciding === null ? [] : [`deciding rule: ${row.deciding}`]),
75
+ ...(row.reason === null ? [] : [`reason: ${row.reason}`]),
76
+ `enforced in: ${gate.usedBy.join(', ')}`,
77
+ 'evaluated with no row — a row-level rule decides again on the real request',
78
+ ],
79
+ }),
80
+ );
81
+ });
82
+ }
@@ -0,0 +1,109 @@
1
+ // The database and the job queue, started together and released together. Split from
2
+ // `dev-runtime.ts` because `x jobs` needs exactly this pair and nothing else — and because a
3
+ // process that installs two ambient accessors (`db()`, `jobDriver()`) must have one place that
4
+ // takes both back, or the next command in the same process inherits a driver over a closed socket.
5
+
6
+ import type { PgliteClient, PostgresClient, SqlFragment } from '@ultimat3/db';
7
+ import {
8
+ createPgliteClient,
9
+ createPostgresClient,
10
+ pgliteDataDir,
11
+ raw,
12
+ setDbClient,
13
+ } from '@ultimat3/db';
14
+ import type { JobDriver, PgExecutor } from '@ultimat3/jobs';
15
+ import { createPgDriver, resetJobDriver, SQL_JOBS_TABLE, setJobDriver } from '@ultimat3/jobs';
16
+ import type { DevServices } from './dev-services';
17
+
18
+ /** Both embedded and external clients boot lazily and close explicitly. */
19
+ export type DevDbClient = PgliteClient | PostgresClient;
20
+
21
+ export interface RunningQueue {
22
+ readonly db: DevDbClient;
23
+ readonly jobs: JobDriver;
24
+ stop(): Promise<void>;
25
+ }
26
+
27
+ function startDb(services: DevServices): DevDbClient {
28
+ const binding = services.db;
29
+ const client =
30
+ binding.mode === 'embedded'
31
+ ? // `pgliteDataDir` is `@ultimat3/db`'s own reader of the `pglite://` form; a second parser
32
+ // here is a second thing to keep right when the form changes.
33
+ createPgliteClient({ dataDir: pgliteDataDir(binding.url) })
34
+ : createPostgresClient({ url: binding.url });
35
+ setDbClient(client);
36
+ return client;
37
+ }
38
+
39
+ /**
40
+ * `@ultimat3/jobs` deliberately depends on no database package: Postgres reaches it as an
41
+ * injected `PgExecutor`. Boot code is what supplies one, and this is the boot.
42
+ *
43
+ * The fragment is assembled by hand rather than through `sql`` ` because the driver hands over
44
+ * `$1..$n` text it wrote itself plus already-bound values — there is no interpolation to guard.
45
+ */
46
+ function executorFor(client: DevDbClient): PgExecutor {
47
+ return {
48
+ query: <R>(text: string, values: readonly unknown[]): Promise<readonly R[]> =>
49
+ client.query<R>({ text, values } satisfies SqlFragment),
50
+ };
51
+ }
52
+
53
+ /**
54
+ * The dev queue is the real Postgres queue on the embedded Postgres — claiming, leases and the
55
+ * one-live-job-per-key index all behave here exactly as in production. A memory queue in dev
56
+ * would hide every bug this driver exists to make impossible.
57
+ *
58
+ * PGlite speaks the extended protocol, which carries one statement per round trip, so the DDL
59
+ * is applied statement by statement. Safe to split on `;`: `SQL_JOBS_TABLE` is a fixed constant
60
+ * with no semicolon inside a literal, and `driver-pg-sql.test.ts` is where that stays true.
61
+ */
62
+ async function startJobs(client: DevDbClient): Promise<JobDriver> {
63
+ for (const statement of SQL_JOBS_TABLE.split(';')) {
64
+ if (statement.trim().length > 0) await client.execute(raw(statement));
65
+ }
66
+ const driver = createPgDriver({ executor: executorFor(client) });
67
+ setJobDriver(driver);
68
+ return driver;
69
+ }
70
+
71
+ /**
72
+ * Release both ambient accessors, then the resources behind them, in that order: a driver reset
73
+ * after its database is closed leaves a window where `jobDriver()` answers over a dead socket.
74
+ * A stale driver is worse than none — the next command sees one installed and skips queue
75
+ * startup entirely, so every query it makes fails on a connection this process already dropped.
76
+ */
77
+ async function releaseQueue(db: DevDbClient, jobs: JobDriver | undefined): Promise<void> {
78
+ resetJobDriver();
79
+ setDbClient(undefined);
80
+ await jobs?.close?.();
81
+ await db.close();
82
+ }
83
+
84
+ /**
85
+ * The db + jobs half of `startServices`, alone: `x jobs` needs a real queue and nothing else —
86
+ * no transport, no storage, no mail — and booting those for a command that never reports on them
87
+ * would pay for services it cannot even use. `startServices` builds on this so there is one boot
88
+ * path for "which database" and "which queue", not two.
89
+ */
90
+ export async function startQueue(services: DevServices): Promise<RunningQueue> {
91
+ const db = startDb(services);
92
+ try {
93
+ // Pay the Postgres boot here, so the first request is not the slow one and a broken database
94
+ // fails at boot rather than on some later query.
95
+ await db.ping();
96
+ const jobs = await startJobs(db);
97
+ return { db, jobs, stop: () => releaseQueue(db, jobs) };
98
+ } catch (error) {
99
+ // `db.ping()` or `startJobs` is where a broken database is supposed to fail. Without this,
100
+ // the caller exits holding the PGlite lock and the ambient accessors, and nothing is left to
101
+ // release them. The rejection that started the unwind is the one worth reporting.
102
+ try {
103
+ await releaseQueue(db, undefined);
104
+ } catch {
105
+ // Cleanup noise never replaces the boot failure.
106
+ }
107
+ throw error;
108
+ }
109
+ }
@@ -0,0 +1,129 @@
1
+ // Projecting the route table onto HTTP routes `x dev` can serve. Every mode goes through
2
+ // `@ultimat3/render`'s own function for that mode — the CLI picks the mode and supplies the
3
+ // document, it never decides what a mode means or what headers it earns.
4
+ //
5
+ // The document is head + shell. Islands are the compiled client graph's, and there is no
6
+ // compiled graph before `x build`, so a dev page serves its real `<head>`, its real status and
7
+ // its real cache headers around an empty root — never a 404.
8
+
9
+ import type { Ctx } from '@ultimat3/core';
10
+ import type { RouteMeta as HttpRouteMeta, Route, RouteParams } from '@ultimat3/http';
11
+ import { asCtx, html, stream } from '@ultimat3/http';
12
+ import type { IsrController, RenderResult, RouteEntry } from '@ultimat3/render';
13
+ import {
14
+ contentHash,
15
+ createIsrController,
16
+ headFromMeta,
17
+ renderHead,
18
+ renderSpa,
19
+ renderSsr,
20
+ routeEntries,
21
+ SPA_ROOT_ID,
22
+ seoRenderers,
23
+ staticHeaders,
24
+ streamResult,
25
+ } from '@ultimat3/render';
26
+
27
+ export interface DevRenderOptions {
28
+ readonly buildId: string;
29
+ /** Injected so a test can drive the ISR store without a timer. */
30
+ readonly isr?: IsrController;
31
+ }
32
+
33
+ /** What a route's `meta(data)` is given. `url` is a string because that is what `ld.*` embeds. */
34
+ export interface DevRouteData extends Record<string, unknown> {
35
+ readonly url: string;
36
+ readonly params: RouteParams;
37
+ }
38
+
39
+ const LANG = 'en';
40
+
41
+ const headFor = async (entry: RouteEntry, data: DevRouteData): Promise<string> =>
42
+ renderHead(
43
+ headFromMeta(await entry.config.meta(data), seoRenderers({ path: new URL(data.url).pathname })),
44
+ );
45
+
46
+ async function documentFor(entry: RouteEntry, data: DevRouteData): Promise<string> {
47
+ return shellFor(await headFor(entry, data));
48
+ }
49
+
50
+ const shellFor = (head: string): string =>
51
+ `<!doctype html><html lang="${LANG}"><head>${head}</head>` +
52
+ `<body><div id="${SPA_ROOT_ID}"></div></body></html>`;
53
+
54
+ async function resultFor(
55
+ entry: RouteEntry,
56
+ data: DevRouteData,
57
+ options: DevRenderOptions,
58
+ isr: IsrController,
59
+ ctx: Ctx,
60
+ ): Promise<RenderResult> {
61
+ const url = new URL(data.url);
62
+ switch (entry.config.render) {
63
+ case 'static': {
64
+ // Not `renderStatic`: that enumerates every prerendered path for the build. A request
65
+ // names exactly one, and it earns the same content-hashed headers.
66
+ const body = await documentFor(entry, data);
67
+ return { status: 200, headers: staticHeaders(contentHash(body), options.buildId), body };
68
+ }
69
+ case 'isr': {
70
+ const served = await isr.serve(url.pathname, () => documentFor(entry, data));
71
+ return served.result;
72
+ }
73
+ case 'spa':
74
+ return renderSpa({
75
+ entry,
76
+ buildId: options.buildId,
77
+ head: await headFor(entry, data),
78
+ chunks: [],
79
+ lang: LANG,
80
+ });
81
+ case 'stream': {
82
+ const head = await headFor(entry, data);
83
+ return streamResult(
84
+ {
85
+ head: `<!doctype html><html lang="${LANG}"><head>${head}</head><body>`,
86
+ shell: `<div id="${SPA_ROOT_ID}"></div>`,
87
+ holes: [],
88
+ },
89
+ { buildId: options.buildId },
90
+ );
91
+ }
92
+ default:
93
+ return renderSsr({ entry, params: data.params, url, ctx }, () => documentFor(entry, data), {
94
+ buildId: options.buildId,
95
+ });
96
+ }
97
+ }
98
+
99
+ const responseOf = (result: RenderResult): Response =>
100
+ typeof result.body === 'string'
101
+ ? html(result.body, { status: result.status, headers: result.headers })
102
+ : stream(result.body, { status: result.status, headers: result.headers });
103
+
104
+ /**
105
+ * `auth` follows the route's own guard, so a gated page is gated in dev by the same pipeline
106
+ * stage that gates it in production. A route that declares no policy is public by declaration.
107
+ */
108
+ const metaOf = (entry: RouteEntry): HttpRouteMeta => ({
109
+ name: entry.file,
110
+ auth: entry.config.policy === undefined ? 'public' : 'required',
111
+ render: entry.config.render,
112
+ tags: [entry.surface],
113
+ ...(entry.config.policy === undefined ? {} : { policy: entry.config.policy.permission }),
114
+ });
115
+
116
+ /** One HTTP route per registered `route` primitive, in the table's own order. */
117
+ export function appRoutes(options: DevRenderOptions): readonly Route[] {
118
+ const isr = options.isr ?? createIsrController({ buildId: options.buildId });
119
+ return routeEntries().map((entry) => ({
120
+ method: 'GET' as const,
121
+ path: entry.path,
122
+ meta: metaOf(entry),
123
+ // `ctx.params` is the router's own match — the CLI never re-parses a path it did not match.
124
+ handler: async (request, ctx): Promise<Response> => {
125
+ const data: DevRouteData = { url: request.url.href, params: ctx.params };
126
+ return responseOf(await resultFor(entry, data, options, isr, asCtx(ctx)));
127
+ },
128
+ }));
129
+ }