@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,92 @@
1
+ // Single responsibility: the `replicator` role under `x dev` — the one place the Postgres logical
2
+ // replication feed is selected, locked and pumped into the transport. Everything it starts is what
3
+ // a `ROLE=replicator` container starts; the only difference is that this process also runs the
4
+ // roles reading the other end of that transport.
5
+
6
+ import { describeEntities } from '@ultimat3/entity';
7
+ import type { Replicator, Transport } from '@ultimat3/realtime';
8
+ import {
9
+ createReplicator,
10
+ ReplicatorSlotHeldError,
11
+ replicatorLockKey,
12
+ selectChangeFeed,
13
+ } from '@ultimat3/realtime';
14
+ import type { DevServices, Env } from './dev-services';
15
+ import { BadFlagError } from './errors';
16
+
17
+ export interface StartReplicatorOptions {
18
+ readonly services: DevServices;
19
+ readonly env: Env;
20
+ readonly transport: Transport;
21
+ }
22
+
23
+ export interface RunningReplicator {
24
+ readonly replicator: Replicator;
25
+ /** The slot this process holds — the thing an operator greps for when two of these exist. */
26
+ readonly slot: string;
27
+ /** The env key that selected the feed, never the URL behind it: it carries a password. */
28
+ readonly detail: string;
29
+ stop(): Promise<void>;
30
+ }
31
+
32
+ /**
33
+ * PGlite is a single-process WASM database with no walsender, so `--role replicator` against the
34
+ * embedded default cannot ever work. Refused with the env var that makes it work rather than with
35
+ * "not a dev role", which is what this used to say and sent an agent looking for a flag instead of
36
+ * a database. Same code as before (`X_CLI_BAD_FLAG`) because it is the same invocation.
37
+ */
38
+ const embeddedRefusal = (): BadFlagError =>
39
+ new BadFlagError({
40
+ flag: 'role',
41
+ command: 'dev',
42
+ reason:
43
+ 'the replicator decodes a write-ahead log, and the embedded database is PGlite — it has ' +
44
+ 'no walsender to decode',
45
+ fix: 'DATABASE_URL=postgres://user:password@localhost:5432/app x dev --role replicator',
46
+ });
47
+
48
+ /**
49
+ * An entity list is the feed's filter, so an empty one is a replicator that decodes every change
50
+ * and forwards none. Refused here rather than inside the feed: this is the layer that knows the
51
+ * list came from the app's own registry, so it can name the command that adds to it.
52
+ */
53
+ const noEntitiesRefusal = (): BadFlagError =>
54
+ new BadFlagError({
55
+ flag: 'role',
56
+ command: 'dev',
57
+ reason: 'no entities are registered, so the replicator would decode changes nothing matches',
58
+ fix: 'x g entity Post title:text — then x dev --role replicator',
59
+ });
60
+
61
+ /**
62
+ * Lock first, feed second. A replicator that opened its slot before losing the lock race would
63
+ * have already consumed WAL the holder is responsible for, and `pg_try_advisory_lock` is the only
64
+ * thing standing between two containers and every change delivered twice.
65
+ */
66
+ export async function startReplicator(options: StartReplicatorOptions): Promise<RunningReplicator> {
67
+ if (options.services.db.mode === 'embedded') throw embeddedRefusal();
68
+ const entities = describeEntities().map((entity) => entity.name);
69
+ if (entities.length === 0) throw noEntitiesRefusal();
70
+
71
+ const selection = selectChangeFeed(options.env, { entities });
72
+ const slot = selection.slot ?? '';
73
+ const replicator = createReplicator({
74
+ feed: selection.feed,
75
+ transport: options.transport,
76
+ lock: selection.lock,
77
+ });
78
+ // `start()` answers `false` for the one condition that is not this process's fault. A dev boot
79
+ // has nothing to fail over to — the standby loop belongs to a container an orchestrator
80
+ // restarts — so it is terminal here, with the code the topology docs already promised.
81
+ if (!(await replicator.start())) {
82
+ throw new ReplicatorSlotHeldError({ key: replicatorLockKey(slot) });
83
+ }
84
+ return {
85
+ replicator,
86
+ slot,
87
+ detail: selection.detail,
88
+ async stop() {
89
+ await replicator.stop();
90
+ },
91
+ };
92
+ }
@@ -0,0 +1,246 @@
1
+ // Running the roles. In production these are separate containers selected by `ROLE`; `x dev`
2
+ // runs them in one process by starting the same framework objects each container starts, so a
3
+ // job that only works when awaited inline still fails here.
4
+ //
5
+ // `migrate` is absent on purpose: it is run-once (`x db migrate`), not a process. `replicator` is
6
+ // selectable but not default — it takes a replication slot on a shared database, which is not
7
+ // something every `x dev` in a team should do to the same server by simply starting.
8
+
9
+ import type { Role } from '@ultimat3/core';
10
+ import { createContext, isRole, logger, ROLES } from '@ultimat3/core';
11
+ import type { Route, ServerHandle } from '@ultimat3/http';
12
+ import { createServer, defineHttpConfig } from '@ultimat3/http';
13
+ import type { Scheduler, Worker } from '@ultimat3/jobs';
14
+ import { createScheduler, createWorker } from '@ultimat3/jobs';
15
+ import { listQueries } from '@ultimat3/query';
16
+ import {
17
+ ChannelHub,
18
+ createSyncNode,
19
+ LiveQueryRegistry,
20
+ listenSyncNode,
21
+ liveQueryDefinition,
22
+ PresenceRegistry,
23
+ RingChangeBuffer,
24
+ SocketRegistry,
25
+ } from '@ultimat3/realtime';
26
+ import { devHooks } from './dev-hooks';
27
+ import type { RunningReplicator } from './dev-replicator';
28
+ import { startReplicator } from './dev-replicator';
29
+ import type { RunningServices } from './dev-runtime';
30
+ import type { Env } from './dev-services';
31
+ import { BadFlagError } from './errors';
32
+
33
+ /** The roles `x dev` starts when `--role` names none, in boot order. */
34
+ export const DEV_ROLES: readonly Role[] = ['web', 'sync', 'worker', 'scheduler'];
35
+
36
+ /**
37
+ * What `--role` accepts. The replicator is here but not in `DEV_ROLES`: opt-in, because it takes
38
+ * the one replication slot a database has, and a default that did that would mean two developers
39
+ * pointed at one staging database silently fighting over it.
40
+ */
41
+ export const SELECTABLE_ROLES: readonly Role[] = [...DEV_ROLES, 'replicator'];
42
+
43
+ export interface StartRolesOptions {
44
+ readonly roles: readonly Role[];
45
+ readonly port: number;
46
+ readonly buildId: string;
47
+ readonly runtime: RunningServices;
48
+ /** Routes the web role serves: `/_x`, the actions, the pages. */
49
+ readonly routes: readonly Route[];
50
+ /** The process environment, for the roles that resolve a driver from it. */
51
+ readonly env: Env;
52
+ }
53
+
54
+ export interface RunningRoles {
55
+ readonly roles: readonly Role[];
56
+ /** `http://…` once the web role is up; null when it was not selected. */
57
+ readonly url: string | null;
58
+ /** Where the sync role accepts websockets; null when it was not selected. */
59
+ readonly syncUrl: string | null;
60
+ readonly server: ServerHandle | null;
61
+ readonly worker: Worker | null;
62
+ readonly scheduler: Scheduler | null;
63
+ /** The slot and feed this process holds; null when the replicator was not selected. */
64
+ readonly replicator: RunningReplicator | null;
65
+ stop(): Promise<void>;
66
+ }
67
+
68
+ /**
69
+ * `--role web,worker` picks a subset. An unknown or out-of-scope role is a flag error with the
70
+ * working invocation in the fix line, never a silently ignored value — which is what it was.
71
+ */
72
+ export function selectRoles(flag: string | undefined): readonly Role[] {
73
+ if (flag === undefined || flag.trim().length === 0) return DEV_ROLES;
74
+ const wanted = flag
75
+ .split(',')
76
+ .map((part) => part.trim())
77
+ .filter((part) => part.length > 0);
78
+ const selected: Role[] = [];
79
+ for (const name of wanted) {
80
+ if (!isRole(name)) {
81
+ throw new BadFlagError({
82
+ flag: 'role',
83
+ command: 'dev',
84
+ reason: `"${name}" is not a role (known: ${ROLES.join(', ')})`,
85
+ fix: `x dev --role ${DEV_ROLES.join(',')}`,
86
+ });
87
+ }
88
+ if (!SELECTABLE_ROLES.includes(name)) {
89
+ throw new BadFlagError({
90
+ flag: 'role',
91
+ command: 'dev',
92
+ reason: `"${name}" does not run under x dev (it runs once, as \`x db migrate\`)`,
93
+ fix: `x dev --role ${DEV_ROLES.join(',')}`,
94
+ });
95
+ }
96
+ if (!selected.includes(name)) selected.push(name);
97
+ }
98
+ return SELECTABLE_ROLES.filter((role) => selected.includes(role));
99
+ }
100
+
101
+ function startWeb(options: StartRolesOptions): ServerHandle {
102
+ return createServer({
103
+ routes: options.routes,
104
+ role: 'web',
105
+ hooks: devHooks(),
106
+ config: defineHttpConfig({
107
+ port: options.port,
108
+ dev: true,
109
+ buildId: options.buildId,
110
+ hostname: 'localhost',
111
+ }),
112
+ }).start();
113
+ }
114
+
115
+ /**
116
+ * Every read the app declared `live: true` becomes a subscribable query on this node, through
117
+ * `@ultimat3/realtime`'s own bridge. A registry with nothing in it answers every live `subscribe`
118
+ * with "no live query registered", which is a working socket serving no reads — and it is what
119
+ * kept the row gate that decides per subscriber from ever running outside a unit test.
120
+ *
121
+ * The context is the node's, and it carries no actor: it supplies the services and the clock the
122
+ * shared read needs, never an authority. Who may subscribe, and which rows they see, is decided
123
+ * per socket at subscribe time and again for every row of every delivery.
124
+ */
125
+ function registerLiveQueries(options: StartRolesOptions): LiveQueryRegistry {
126
+ const registry = new LiveQueryRegistry({
127
+ source: new RingChangeBuffer(),
128
+ // A withheld row is a metric, never a frame and never an error: telling a client "there is a
129
+ // row you may not see" is the leak the gate exists to prevent.
130
+ onRowDenied: (event) => logger.debug('live.rows_denied', { ...event }),
131
+ });
132
+ const ctx = createContext({ role: 'sync', buildId: options.buildId });
133
+ for (const target of listQueries()) {
134
+ if (target.isLive) registry.register(liveQueryDefinition(target, { ctx }));
135
+ }
136
+ return registry;
137
+ }
138
+
139
+ /**
140
+ * The sync role owns its own socket: websockets and the request pipeline drain differently.
141
+ *
142
+ * Port 0 is passed straight through rather than incremented — `+ 1` would ask the kernel for
143
+ * port 1 instead of an ephemeral one — and the reported url is the listener's own bound address,
144
+ * never a string built from the port that was requested.
145
+ */
146
+ async function startSync(
147
+ options: StartRolesOptions,
148
+ ): Promise<{ url: string; stop: () => Promise<void> }> {
149
+ const sockets = new SocketRegistry();
150
+ const hub = new ChannelHub({ transport: options.runtime.transport, sockets });
151
+ const node = createSyncNode({
152
+ hub,
153
+ registry: registerLiveQueries(options),
154
+ transport: options.runtime.transport,
155
+ buildId: options.buildId,
156
+ sockets,
157
+ // Tier 1 is presence, and without a registry the node answers a topic subscribe with no member
158
+ // list at all — the KV bucket the transport just created would hold nothing and every `sync`
159
+ // container would run a presence-less protocol. It reads and writes `transport.shared`, so it
160
+ // is exactly as multi-node as the transport behind it: in-process here, the bucket under NATS.
161
+ presence: new PresenceRegistry({
162
+ transport: options.runtime.transport,
163
+ hub,
164
+ ttlMs: options.runtime.presenceTtlMs,
165
+ }),
166
+ });
167
+ await node.start();
168
+ try {
169
+ const listener = listenSyncNode(node, { port: options.port === 0 ? 0 : options.port + 1 });
170
+ return {
171
+ url: listener.url,
172
+ stop: async () => {
173
+ listener.stop();
174
+ await node.stop();
175
+ },
176
+ };
177
+ } catch (error) {
178
+ await node.stop();
179
+ throw error;
180
+ }
181
+ }
182
+
183
+ export async function startRoles(options: StartRolesOptions): Promise<RunningRoles> {
184
+ const selected = options.roles;
185
+ // Roles bind sockets in order, so a role that fails to start has to release the ones before it.
186
+ // Without this a failed `sync` leaves the web server bound and unreachable by any caller.
187
+ const started: (() => Promise<void>)[] = [];
188
+ try {
189
+ const server = selected.includes('web') ? startWeb(options) : null;
190
+ if (server !== null) started.push(() => server.stop());
191
+
192
+ const sync = selected.includes('sync') ? await startSync(options) : null;
193
+ if (sync !== null) started.push(sync.stop);
194
+
195
+ const worker = selected.includes('worker')
196
+ ? createWorker({
197
+ driver: options.runtime.jobs,
198
+ context: () => createContext({ role: 'worker', buildId: options.buildId }),
199
+ })
200
+ : null;
201
+ worker?.start();
202
+ if (worker !== null) started.push(() => worker.stop('x dev stopped'));
203
+
204
+ const scheduler = selected.includes('scheduler')
205
+ ? createScheduler({ driver: options.runtime.jobs })
206
+ : null;
207
+ scheduler?.start();
208
+ if (scheduler !== null) started.push(() => scheduler.stop());
209
+
210
+ // Last, and only after the transport it publishes to exists: a replicator started ahead of the
211
+ // sync node would decode changes with nothing subscribed to receive them, and the slot it
212
+ // holds is the one resource here another process can be locked out of.
213
+ const replicator = selected.includes('replicator')
214
+ ? await startReplicator({
215
+ services: options.runtime.services,
216
+ env: options.env,
217
+ transport: options.runtime.transport,
218
+ })
219
+ : null;
220
+ if (replicator !== null) started.push(() => replicator.stop());
221
+
222
+ return {
223
+ roles: selected,
224
+ url: server === null ? null : server.url(),
225
+ syncUrl: sync?.url ?? null,
226
+ server,
227
+ worker,
228
+ scheduler,
229
+ replicator,
230
+ async stop() {
231
+ // Reverse boot order, so the slot is released before the bus it published to closes.
232
+ await replicator?.stop();
233
+ await scheduler?.stop();
234
+ await worker?.stop('x dev stopped');
235
+ await sync?.stop();
236
+ await server?.stop();
237
+ },
238
+ };
239
+ } catch (error) {
240
+ for (const stop of started.reverse()) {
241
+ // The role that refused to start is the failure worth reporting, not a stop on the way out.
242
+ await stop().catch(() => undefined);
243
+ }
244
+ throw error;
245
+ }
246
+ }
@@ -0,0 +1,203 @@
1
+ // Starting the services `dev-services.ts` resolved. Resolution answers "which database"; this
2
+ // answers "it is running, and every ambient accessor in the framework now points at it" — so
3
+ // `db()`, `jobDriver()`, `mailDriver()` and the realtime transport are the objects a production
4
+ // boot installs, only backed by embedded drivers.
5
+
6
+ import { mkdirSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import type { PurgeDriver } from '@ultimat3/cache';
9
+ import {
10
+ createCdnTier,
11
+ isNoopPurgeDriver,
12
+ registerTier,
13
+ resetTiers,
14
+ selectPurgeDriver,
15
+ } from '@ultimat3/cache';
16
+ import type { EventBus, JobDriver } from '@ultimat3/jobs';
17
+ import { createMemoryEventBus, setEventBus } from '@ultimat3/jobs';
18
+ import type { MailDriver } from '@ultimat3/mail';
19
+ import { isMemoryDriver, resetMailDriver, selectMailDriver, setMailDriver } from '@ultimat3/mail';
20
+ import type { Transport, TransportSelection } from '@ultimat3/realtime';
21
+ import { selectTransport } from '@ultimat3/realtime';
22
+ import type { Storage } from '@ultimat3/storage';
23
+ import { defineStorage, localDriver } from '@ultimat3/storage';
24
+ import type { DevDbClient } from './dev-queue';
25
+ import { startQueue } from './dev-queue';
26
+ import type { DevServices, Env } from './dev-services';
27
+ import { msg } from './messages';
28
+
29
+ export interface RunningServices {
30
+ readonly services: DevServices;
31
+ readonly db: DevDbClient;
32
+ readonly jobs: JobDriver;
33
+ readonly events: EventBus;
34
+ readonly transport: Transport;
35
+ readonly storage: Storage;
36
+ readonly mail: MailDriver;
37
+ /**
38
+ * Which env key selected the transport, or why nothing was selected. The credential itself is
39
+ * never carried: `SMTP_URL` holds a password, and this string reaches the boot line and `--json`.
40
+ */
41
+ readonly mailDetail: string;
42
+ /** Same rule as `mailDetail`: the env key that selected the bus, never the url behind it. */
43
+ readonly transportDetail: string;
44
+ /**
45
+ * What the sync role must give `PresenceRegistry`. It travels with the transport because the KV
46
+ * bucket's age limit was derived from it — a registry given a longer TTL than the bucket honours
47
+ * would show members leaving that never left.
48
+ */
49
+ readonly presenceTtlMs: number;
50
+ readonly purge: PurgeDriver;
51
+ /** Same rule as `mailDetail`: the env key that selected the CDN, never the token behind it. */
52
+ readonly purgeDetail: string;
53
+ stop(): Promise<void>;
54
+ }
55
+
56
+ /**
57
+ * `mail=embedded` is the honest report for a process that caught the message instead of sending
58
+ * it — the same vocabulary the other three bindings use, so an operator reading a boot line sees
59
+ * at a glance that this replica delivers nothing. This is the machine half: `x dev --json` carries
60
+ * it verbatim and `wiki/Configuration.md` documents it, so it is a fixed status value and NOT a
61
+ * catalog lookup — a translated boot line must never move a field a script parses. `mailLabel` is
62
+ * the human half.
63
+ */
64
+ export function describeMail(runtime: RunningServices): string {
65
+ return isMemoryDriver(runtime.mail)
66
+ ? 'mail=embedded'
67
+ : `mail=external(${runtime.mail.name} via ${runtime.mailDetail})`;
68
+ }
69
+
70
+ /**
71
+ * `cdn=none` rather than `cdn=embedded`: there is no embedded CDN, and a process with no edge in
72
+ * front of it purges nothing. Saying "embedded" would read as a fifth service this boot started.
73
+ * Machine half, same rule as `describeMail`; `cdnLabel` is what a human reads.
74
+ */
75
+ export function describeCdn(runtime: RunningServices): string {
76
+ return isNoopPurgeDriver(runtime.purge)
77
+ ? 'cdn=none'
78
+ : `cdn=external(${runtime.purge.name} via ${runtime.purgeDetail})`;
79
+ }
80
+
81
+ /**
82
+ * The boot line's mail label. Same fact as `describeMail`, through the catalog, because this string
83
+ * is rendered to a person and every rendered string in the CLI is a `messages.ts` key — the status
84
+ * value stays where `--json` can depend on it.
85
+ */
86
+ export function mailLabel(runtime: RunningServices): string {
87
+ return isMemoryDriver(runtime.mail)
88
+ ? msg('cli.dev.mail.embedded')
89
+ : msg('cli.dev.mail.external', { driver: runtime.mail.name, detail: runtime.mailDetail });
90
+ }
91
+
92
+ /** The boot line's CDN label, for the reason `mailLabel` gives. */
93
+ export function cdnLabel(runtime: RunningServices): string {
94
+ return isNoopPurgeDriver(runtime.purge)
95
+ ? msg('cli.dev.cdn.none')
96
+ : msg('cli.dev.cdn.external', { driver: runtime.purge.name, detail: runtime.purgeDetail });
97
+ }
98
+
99
+ const FILE_SCHEME = 'file://';
100
+
101
+ function startStorage(services: DevServices): Storage {
102
+ const binding = services.storage;
103
+ const root =
104
+ binding.mode === 'embedded'
105
+ ? binding.url.slice(FILE_SCHEME.length)
106
+ : join(services.stateDir, 'storage');
107
+ mkdirSync(root, { recursive: true });
108
+ return defineStorage({ disks: { local: localDriver({ root }) }, default: 'local' });
109
+ }
110
+
111
+ /**
112
+ * Release what has already started, newest first, and return every failure instead of throwing on
113
+ * the first: a step that rejects must not skip the ones after it, or one transport that will not
114
+ * close strands the CDN tier, the ambient mail driver and the queue in the next boot of this
115
+ * process. The two callers differ only in what they do with the failures.
116
+ */
117
+ async function release(steps: readonly (() => void | Promise<void>)[]): Promise<unknown[]> {
118
+ const failures: unknown[] = [];
119
+ for (const step of [...steps].reverse()) {
120
+ try {
121
+ await step();
122
+ } catch (error) {
123
+ failures.push(error);
124
+ }
125
+ }
126
+ return failures;
127
+ }
128
+
129
+ export async function startServices(services: DevServices, env: Env): Promise<RunningServices> {
130
+ // Before the queue: selection is pure — it parses `SMTP_URL` and builds a transport, it does
131
+ // not dial. A typo'd credential must fail on the spot rather than after PGlite has started and
132
+ // been unwound again, and it must fail at boot rather than on the first mail nobody receives.
133
+ const selection = selectMailDriver(env);
134
+ // Same reason, same place: building a purge driver reads env and dials nothing, so a half-set
135
+ // `FASTLY_API_TOKEN` without its service id fails here rather than on the first stale page.
136
+ const cdn = selectPurgeDriver(env);
137
+ // Third of the same kind. `NATS_URL` selects the bus rather than quietly keeping the in-process
138
+ // one — dev pointed at compose is a parity check, and a parity check that silently ran the
139
+ // embedded driver is worse than none. Which transport, which KV bucket and which presence TTL is
140
+ // `@ultimat3/realtime`'s decision, and it is the same call a `ROLE=sync` container makes, so this
141
+ // process cannot resolve the bus differently from the container it stands in for.
142
+ const bus: TransportSelection = selectTransport(env);
143
+ const queue = await startQueue(services);
144
+ const { db, jobs } = queue;
145
+ // Boot is a sequence of external resources, and every step after the first can reject — the
146
+ // queue is already up, so from here an unwind must release it exactly like everything after it.
147
+ const started: (() => void | Promise<void>)[] = [() => queue.stop()];
148
+ try {
149
+ const events = createMemoryEventBus();
150
+ setEventBus(events);
151
+ // Dialled here rather than at selection: an unreachable bus must fail at `x dev`, not on the
152
+ // first change nobody receives, and the socket is a resource the unwind below has to release.
153
+ await bus.connect();
154
+ started.push(() => bus.transport.close());
155
+ const storage = startStorage(services);
156
+ // With no credential this is the memory driver: caught, not sent, so the `/_x` mail panel can
157
+ // show what a template renders in every locale without a mailbox or a message escaping to a
158
+ // real address. `SMTP_URL` or `RESEND_API_KEY` makes it a real transport instead — the same
159
+ // "an unset variable means the embedded default" law the other three bindings follow.
160
+ const mail = selection.driver;
161
+ setMailDriver(mail);
162
+ started.push(() => resetMailDriver());
163
+ // Registered only when a credential named a real edge. A noop tier would put a `cdn` line in
164
+ // every invalidation report claiming keys an edge that does not exist had accepted — and the
165
+ // `/_x` cache panel renders those reports, so the lie would be the thing an agent reads.
166
+ // Released with `resetTiers()`, which drops the whole registry: this boot is the only thing
167
+ // that registers one, and a tier left behind would purge for a process that has stopped.
168
+ const purging = !isNoopPurgeDriver(cdn.driver);
169
+ if (purging) {
170
+ registerTier(createCdnTier({ purge: cdn.driver }));
171
+ started.push(() => resetTiers());
172
+ }
173
+
174
+ return {
175
+ services,
176
+ db,
177
+ jobs,
178
+ events,
179
+ transport: bus.transport,
180
+ storage,
181
+ mail,
182
+ mailDetail: selection.detail,
183
+ transportDetail: bus.detail,
184
+ presenceTtlMs: bus.presenceTtlMs,
185
+ purge: cdn.driver,
186
+ purgeDetail: cdn.detail,
187
+ // The same list the boot unwind uses, in the same reverse order, so a service added to the
188
+ // boot is released by both paths — a second copy of these steps is how one of them came to
189
+ // release three things and the other four. A stop that fails says so, unlike that unwind:
190
+ // the FIRST failure is rethrown because it is the cause and the rest are its consequences,
191
+ // and every step still runs, so a refused shutdown never leaks into the next boot.
192
+ async stop() {
193
+ const failures = await release(started);
194
+ if (failures.length > 0) throw failures[0];
195
+ },
196
+ };
197
+ } catch (error) {
198
+ // The rejection that started the unwind is the one worth reporting; a cleanup failure under it
199
+ // is noise, so these are collected and dropped rather than allowed to replace the cause.
200
+ await release(started);
201
+ throw error;
202
+ }
203
+ }
@@ -0,0 +1,75 @@
1
+ // Service resolution for `x dev`. No Docker, no env scavenger hunt: an unset variable means the
2
+ // embedded default, and the resolved set is printed at boot so there is never a question about
3
+ // which database a running process is talking to.
4
+
5
+ import { mkdirSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ export type ServiceMode = 'embedded' | 'external';
9
+
10
+ export interface ServiceBinding {
11
+ readonly name: 'db' | 'events' | 'storage';
12
+ readonly mode: ServiceMode;
13
+ readonly url: string;
14
+ /** What the embedded default is, so `x doctor` can explain the difference. */
15
+ readonly detail: string;
16
+ }
17
+
18
+ export interface DevServices {
19
+ readonly db: ServiceBinding;
20
+ readonly events: ServiceBinding;
21
+ readonly storage: ServiceBinding;
22
+ readonly stateDir: string;
23
+ }
24
+
25
+ export type Env = Readonly<Record<string, string | undefined>>;
26
+
27
+ const nonEmpty = (value: string | undefined): string | undefined =>
28
+ value === undefined || value.trim().length === 0 ? undefined : value;
29
+
30
+ /**
31
+ * Embedded Postgres is PGlite on disk under `.x/`, so a restart keeps the data and a `x db reset`
32
+ * is a directory delete rather than a container dance.
33
+ */
34
+ export function resolveServices(root: string, env: Env): DevServices {
35
+ const stateDir = join(root, '.x');
36
+ mkdirSync(stateDir, { recursive: true });
37
+ const databaseUrl = nonEmpty(env['DATABASE_URL']);
38
+ const natsUrl = nonEmpty(env['NATS_URL']);
39
+ const s3Endpoint = nonEmpty(env['S3_ENDPOINT']);
40
+ return {
41
+ stateDir,
42
+ db:
43
+ databaseUrl === undefined
44
+ ? {
45
+ name: 'db',
46
+ mode: 'embedded',
47
+ url: `pglite://${join(stateDir, 'pgdata')}`,
48
+ detail: 'PGlite in this process — set DATABASE_URL to use a real Postgres',
49
+ }
50
+ : { name: 'db', mode: 'external', url: databaseUrl, detail: 'DATABASE_URL' },
51
+ events:
52
+ natsUrl === undefined
53
+ ? {
54
+ name: 'events',
55
+ mode: 'embedded',
56
+ url: 'inproc://events',
57
+ detail: 'in-process fanout — set NATS_URL to use NATS',
58
+ }
59
+ : { name: 'events', mode: 'external', url: natsUrl, detail: 'NATS_URL' },
60
+ storage:
61
+ s3Endpoint === undefined
62
+ ? {
63
+ name: 'storage',
64
+ mode: 'embedded',
65
+ url: `file://${join(stateDir, 'storage')}`,
66
+ detail: 'local directory — set S3_ENDPOINT to use S3',
67
+ }
68
+ : { name: 'storage', mode: 'external', url: s3Endpoint, detail: 'S3_ENDPOINT' },
69
+ };
70
+ }
71
+
72
+ export const describeServices = (services: DevServices): string =>
73
+ [services.db, services.events, services.storage]
74
+ .map((binding) => `${binding.name}=${binding.mode}`)
75
+ .join(' ');