@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.
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +60 -0
- package/src/app-agents-md.ts +27 -0
- package/src/app-boundaries.ts +206 -0
- package/src/app-evals.ts +74 -0
- package/src/app-load.ts +136 -0
- package/src/app-manifest.ts +137 -0
- package/src/app-openapi.ts +12 -0
- package/src/app-root.ts +57 -0
- package/src/bin.ts +17 -0
- package/src/boundary-cuts.ts +219 -0
- package/src/budgets.ts +92 -0
- package/src/cmd-build.ts +109 -0
- package/src/cmd-db.ts +187 -0
- package/src/cmd-deploy.ts +124 -0
- package/src/cmd-dev.ts +286 -0
- package/src/cmd-doctor.ts +178 -0
- package/src/cmd-errors.ts +99 -0
- package/src/cmd-fix.ts +126 -0
- package/src/cmd-generate.ts +434 -0
- package/src/cmd-help.ts +94 -0
- package/src/cmd-i18n.ts +212 -0
- package/src/cmd-jobs.ts +237 -0
- package/src/cmd-manifest.ts +97 -0
- package/src/cmd-mcp.ts +176 -0
- package/src/cmd-new.ts +133 -0
- package/src/cmd-planned.ts +119 -0
- package/src/cmd-policy.ts +136 -0
- package/src/cmd-registries.ts +195 -0
- package/src/cmd-routes.ts +73 -0
- package/src/cmd-tasks.ts +151 -0
- package/src/cmd-test.ts +109 -0
- package/src/cmd-verify.ts +265 -0
- package/src/command.ts +33 -0
- package/src/dev-assets.ts +177 -0
- package/src/dev-dashboard.ts +242 -0
- package/src/dev-hooks.ts +51 -0
- package/src/dev-policy.ts +82 -0
- package/src/dev-queue.ts +109 -0
- package/src/dev-render.ts +129 -0
- package/src/dev-replicator.ts +92 -0
- package/src/dev-roles.ts +246 -0
- package/src/dev-runtime.ts +203 -0
- package/src/dev-services.ts +75 -0
- package/src/dev-traces.ts +141 -0
- package/src/dispatch.ts +98 -0
- package/src/drift.ts +86 -0
- package/src/error-catalog.ts +156 -0
- package/src/error-contract.ts +212 -0
- package/src/errors.ts +367 -0
- package/src/exec.ts +70 -0
- package/src/hold.ts +48 -0
- package/src/i18n-audit.ts +183 -0
- package/src/index.ts +179 -0
- package/src/jobs-drain.ts +151 -0
- package/src/jobs-json.ts +134 -0
- package/src/jobs-report.ts +132 -0
- package/src/jobs-table.ts +34 -0
- package/src/json-merge.ts +40 -0
- package/src/mcp-db-target.ts +50 -0
- package/src/mcp-errors.ts +99 -0
- package/src/mcp-host.ts +282 -0
- package/src/mcp-test-output.ts +57 -0
- package/src/messages.ts +119 -0
- package/src/output.ts +174 -0
- package/src/parse.ts +243 -0
- package/src/policy-facts.ts +196 -0
- package/src/policy-fixture.ts +71 -0
- package/src/registry.ts +73 -0
- package/src/scaffold-fixture.ts +69 -0
- package/src/scaffold-typecheck.ts +240 -0
- package/src/source-files.ts +38 -0
- package/src/table.ts +19 -0
- package/src/tasks-facts.ts +113 -0
- package/src/templates/action.ts +193 -0
- package/src/templates/admin.ts +46 -0
- package/src/templates/catalog-json.ts +17 -0
- package/src/templates/entity.ts +157 -0
- package/src/templates/index.ts +23 -0
- package/src/templates/job.ts +148 -0
- package/src/templates/locales.ts +93 -0
- package/src/templates/naming.ts +97 -0
- package/src/templates/policy.ts +120 -0
- package/src/templates/query.ts +116 -0
- package/src/templates/resource.ts +199 -0
- package/src/templates/route.ts +138 -0
- package/src/templates/scaffold-app.ts +320 -0
- package/src/templates/scaffold-docs.ts +156 -0
- package/src/templates/scaffold-i18n.ts +149 -0
- package/src/templates/scaffold-icon.ts +54 -0
- package/src/templates/scaffold-package-shape.ts +49 -0
- package/src/templates/scaffold-repo.ts +427 -0
- package/src/test-select.ts +130 -0
- package/src/test-shards.ts +188 -0
- package/src/thrown-by.ts +24 -0
- package/src/ts-scan.ts +217 -0
- package/src/verify-step.ts +83 -0
- package/src/verify-tests.ts +166 -0
- package/src/version-loader.ts +16 -0
- 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
|
+
}
|
package/src/dev-roles.ts
ADDED
|
@@ -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(' ');
|