@ultimat3/cli 11.3.0 → 13.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/src/dev-queue.ts CHANGED
@@ -8,10 +8,8 @@ import {
8
8
  type PostgresIdempotencyStore,
9
9
  postgresIdempotencyStore,
10
10
  resetIdempotency,
11
- SQL_IDEMPOTENCY_TABLE,
12
11
  setIdempotencyStore,
13
12
  } from '@ultimat3/action';
14
- import { SQL_AUTH_LIMIT_TABLES } from '@ultimat3/auth';
15
13
  import type { DbClient, PgliteClient, PostgresClient, SqlFragment } from '@ultimat3/db';
16
14
  import {
17
15
  createPgliteClient,
@@ -22,7 +20,6 @@ import {
22
20
  setDbClient,
23
21
  } from '@ultimat3/db';
24
22
  import type { Tx } from '@ultimat3/entity';
25
- import { SQL_RATE_LIMIT_TABLE } from '@ultimat3/http';
26
23
  import type { EventBus, JobDriver, OutboxStore, PgExecutor } from '@ultimat3/jobs';
27
24
  import {
28
25
  createJobsFacade,
@@ -31,17 +28,24 @@ import {
31
28
  createPgOutboxStore,
32
29
  resetJobDriver,
33
30
  resetJobsFacade,
34
- SQL_JOBS_TABLE,
35
31
  setEventBus,
36
32
  setJobDriver,
37
33
  setJobsFacade,
38
34
  } from '@ultimat3/jobs';
35
+ import { attachReplica, type ReplicaEnv, replicaUrlFor } from './dev-replica';
39
36
  import type { DevServices } from './dev-services';
37
+ import { applyFrameworkSchema } from './framework-schema';
40
38
  import type { RuntimeOverrides } from './runtime-overrides';
41
39
 
42
40
  /** Both embedded and external clients boot lazily and close explicitly. */
43
41
  export type DevDbClient = PgliteClient | PostgresClient;
44
42
 
43
+ /** The primary this boot owns, plus the standby pool it opened — `stop()` closes both. */
44
+ interface StartedDb {
45
+ readonly client: DevDbClient;
46
+ readonly replica: PostgresClient | undefined;
47
+ }
48
+
45
49
  export interface RunningQueue {
46
50
  readonly db: DevDbClient;
47
51
  readonly jobs: JobDriver;
@@ -62,7 +66,20 @@ export interface RunningQueue {
62
66
  stop(): Promise<void>;
63
67
  }
64
68
 
65
- function startDb(services: DevServices): DevDbClient {
69
+ /**
70
+ * The boot's own client, and the AMBIENT one, which are deliberately not always the same object.
71
+ *
72
+ * `setDbClient` receives the replicated pair when `DATABASE_REPLICA_URL` names a standby, so an
73
+ * app repository reading through `db()` inside an open `withReplicaReads` scope can be served by
74
+ * it. Everything this file does itself — `applySchema`, the `PgExecutor` behind the queue, the
75
+ * outbox, `ping()`, `close()` — keeps the PRIMARY: DDL, a claim and a migration are writes by
76
+ * definition, and routing one would be `25006` at best.
77
+ *
78
+ * Before this, `defaultClient()` was the only composer of a replicated pair in the framework and
79
+ * it runs only from `baseClient()` — the client an app installed NONE for. This line installs one,
80
+ * so `DATABASE_REPLICA_URL` was read by no booted process at all.
81
+ */
82
+ function startDb(services: DevServices, env: ReplicaEnv = process.env): StartedDb {
66
83
  const binding = services.db;
67
84
  const client =
68
85
  binding.mode === 'embedded'
@@ -70,8 +87,9 @@ function startDb(services: DevServices): DevDbClient {
70
87
  // here is a second thing to keep right when the form changes.
71
88
  createPgliteClient({ dataDir: pgliteDataDir(binding.url) })
72
89
  : createPostgresClient({ url: binding.url });
73
- setDbClient(client);
74
- return client;
90
+ const attached = attachReplica(client, replicaUrlFor(binding, env));
91
+ setDbClient(attached.client);
92
+ return { client, replica: attached.replica };
75
93
  }
76
94
 
77
95
  /**
@@ -91,33 +109,18 @@ export function pgExecutorFor(client: DbClient): PgExecutor {
91
109
  /**
92
110
  * Every table this process's framework packages own, applied before anything reads one.
93
111
  *
94
- * PGlite speaks the extended protocol, which carries one statement per round trip, so the DDL is
95
- * applied statement by statement. Safe to split on `;`: every constant is fixed, with no semicolon
96
- * inside a literal, and each package's own SQL test is where that stays true.
112
+ * The LIST is `FRAMEWORK_SCHEMA` and lives in `framework-schema.ts`, not here: this function is on
113
+ * every boot path the framework has — `x dev`, each served role, `x jobs`, `x db backfill`,
114
+ * `x mcp serve` and `ROLE=migrate` all reach it through `startQueue` — so the list it reads is the
115
+ * one place a framework table can be forgotten, and it is worth being a table somebody can read
116
+ * rather than an array literal inside a boot function.
97
117
  *
98
- * `SQL_IDEMPOTENCY_TABLE`, `SQL_RATE_LIMIT_TABLE` and `SQL_AUTH_LIMIT_TABLES` are here and not in
99
- * `@ultimat3/action`, `@ultimat3/http` or `@ultimat3/auth` because a package that holds no
100
- * database dependency cannot apply its own schema
101
- * — the same reason `SQL_JOBS_TABLE` is applied here. Each one absent is the same failure at a
102
- * different door: a retried `POST /api/payments/charge` charges the card twice, and the FIRST
103
- * request a `rateLimitStore` deployment serves dies on a missing `x_rate_limit` relation. The
104
- * table is installed whether or not this boot passes `runtime.rateLimitStore` — `create table if
105
- * not exists` on an unused table costs one round trip at boot, and a store installed later must
106
- * not be the thing that discovers the schema was never applied. The auth pair is the strongest
107
- * case for that rule: `defineAuth` builds its limiter when the APP's modules import, which is
108
- * after this, so the first failed sign-in would otherwise be what discovers the missing relation.
118
+ * Each package's DDL is here and not in `@ultimat3/action`, `@ultimat3/http`, `@ultimat3/auth` or
119
+ * `@ultimat3/notify` because a package that holds no database dependency cannot apply its own
120
+ * schema — the same reason `SQL_JOBS_TABLE` is applied by the boot.
109
121
  */
110
122
  async function applySchema(client: DevDbClient): Promise<void> {
111
- for (const ddl of [
112
- SQL_JOBS_TABLE,
113
- SQL_IDEMPOTENCY_TABLE,
114
- SQL_RATE_LIMIT_TABLE,
115
- SQL_AUTH_LIMIT_TABLES,
116
- ]) {
117
- for (const statement of ddl.split(';')) {
118
- if (statement.trim().length > 0) await client.execute(raw(statement));
119
- }
120
- }
123
+ await applyFrameworkSchema((statement) => client.execute(raw(statement)));
121
124
  }
122
125
 
123
126
  /**
@@ -148,7 +151,11 @@ async function applySchema(client: DevDbClient): Promise<void> {
148
151
  * `startServices` runs before `loadApp`, so the store is in place before `registerAction`
149
152
  * evaluates a `scope: 'shared'` declaration against it.
150
153
  */
151
- async function startJobs(client: DevDbClient, overrides?: RuntimeOverrides): Promise<RunningQueue> {
154
+ async function startJobs(
155
+ client: DevDbClient,
156
+ replica: PostgresClient | undefined,
157
+ overrides?: RuntimeOverrides,
158
+ ): Promise<RunningQueue> {
152
159
  await applySchema(client);
153
160
  const executor = pgExecutorFor(client);
154
161
  const driver = overrides?.jobs ?? createPgDriver({ executor });
@@ -171,7 +178,7 @@ async function startJobs(client: DevDbClient, overrides?: RuntimeOverrides): Pro
171
178
  outbox,
172
179
  events,
173
180
  idempotency,
174
- stop: () => releaseQueue(client, driver),
181
+ stop: () => releaseQueue(client, driver, replica),
175
182
  };
176
183
  }
177
184
 
@@ -184,7 +191,11 @@ async function startJobs(client: DevDbClient, overrides?: RuntimeOverrides): Pro
184
191
  * The facade goes with the driver for the same reason: an enqueue routed through a store bound to
185
192
  * a closed client is a staged row nothing will ever publish.
186
193
  */
187
- async function releaseQueue(db: DevDbClient, jobs: JobDriver | undefined): Promise<void> {
194
+ async function releaseQueue(
195
+ db: DevDbClient,
196
+ jobs: JobDriver | undefined,
197
+ replica?: PostgresClient,
198
+ ): Promise<void> {
188
199
  // The idempotency store goes back to the memory default for the same reason the facade does: it
189
200
  // holds this client, and the next command in this process would reserve keys over a closed one.
190
201
  resetIdempotency();
@@ -193,6 +204,9 @@ async function releaseQueue(db: DevDbClient, jobs: JobDriver | undefined): Promi
193
204
  setDbClient(undefined);
194
205
  await jobs?.close?.();
195
206
  await db.close();
207
+ // After the primary, and never instead of it: a standby pool left open is a connection slot on
208
+ // the other server that nothing in this process can reach again.
209
+ await replica?.close();
196
210
  }
197
211
 
198
212
  /**
@@ -205,18 +219,18 @@ export async function startQueue(
205
219
  services: DevServices,
206
220
  overrides?: RuntimeOverrides,
207
221
  ): Promise<RunningQueue> {
208
- const db = startDb(services);
222
+ const { client: db, replica } = startDb(services);
209
223
  try {
210
224
  // Pay the Postgres boot here, so the first request is not the slow one and a broken database
211
225
  // fails at boot rather than on some later query.
212
226
  await db.ping();
213
- return await startJobs(db, overrides);
227
+ return await startJobs(db, replica, overrides);
214
228
  } catch (error) {
215
229
  // `db.ping()` or `startJobs` is where a broken database is supposed to fail. Without this,
216
230
  // the caller exits holding the PGlite lock and the ambient accessors, and nothing is left to
217
231
  // release them. The rejection that started the unwind is the one worth reporting.
218
232
  try {
219
- await releaseQueue(db, undefined);
233
+ await releaseQueue(db, undefined, replica);
220
234
  } catch {
221
235
  // Cleanup noise never replaces the boot failure.
222
236
  }
@@ -0,0 +1,99 @@
1
+ // Read-replica routing, WIRED. `@ultimat3/db` ships both halves and a booted process reached
2
+ // neither, so the capability was shipped and unactivated — the "declared and never wired" class
3
+ // this release exists to eliminate.
4
+ //
5
+ // Two things had to be false for it to route, and both were:
6
+ //
7
+ // 1. `defaultClient()` is the one place db composes `replicatedClient(primary, replica)` from
8
+ // `DATABASE_REPLICA_URL`, and it runs only from `baseClient()` — "the client an app installed
9
+ // none for". Every process the framework boots installs one: `dev-queue.ts` calls
10
+ // `setDbClient(createPgliteClient(…) | createPostgresClient({ url }))`, so `defaultClient()` was
11
+ // unreachable from `x dev`, from `apps/web/server.ts` and from every container role.
12
+ // 2. Routing needs an OPEN scope as well as a configured replica, and nothing opened one.
13
+ //
14
+ // This file answers both from the boot, which is the only tier that may know about a request AND
15
+ // about a pool. It is deliberately NOT a change to `@ultimat3/http`'s pipeline: that would make the
16
+ // HTTP tier depend on `@ultimat3/db` — legal downward, and still a package that would then know
17
+ // what a database is.
18
+
19
+ import type { DbClient, PostgresClient } from '@ultimat3/db';
20
+ import {
21
+ createPostgresClient,
22
+ REPLICA_URL_ENV,
23
+ replicatedClient,
24
+ withReplicaReads,
25
+ } from '@ultimat3/db';
26
+ import type { Middleware } from '@ultimat3/http';
27
+ import type { ServiceBinding } from './dev-services';
28
+ import type { RuntimeOverrides } from './runtime-overrides';
29
+
30
+ export type ReplicaEnv = Readonly<Record<string, string | undefined>>;
31
+
32
+ /**
33
+ * The replica url this boot should use, or `undefined`. Two conditions, and the second is the one
34
+ * a homework app depends on: an EMBEDDED database is PGlite in this process, which has no standby
35
+ * and never will, so a `DATABASE_REPLICA_URL` left over in a shell must not silently open a second
36
+ * pool beside it.
37
+ */
38
+ export function replicaUrlFor(binding: ServiceBinding, env: ReplicaEnv): string | undefined {
39
+ if (binding.mode !== 'external') return undefined;
40
+ const url = env[REPLICA_URL_ENV];
41
+ return url === undefined || url.trim() === '' ? undefined : url;
42
+ }
43
+
44
+ /** What a boot has to hold on to: the ambient client, and the pool `stop()` must close. */
45
+ export interface ReplicaAttachment {
46
+ /** What `setDbClient()` receives — the pair when one is configured, the primary otherwise. */
47
+ readonly client: DbClient;
48
+ /** The standby pool this boot opened, or nothing. `ReplicatedClient` has no `close()`. */
49
+ readonly replica: PostgresClient | undefined;
50
+ }
51
+
52
+ /**
53
+ * The primary as it is, or the primary with a standby behind it. Composed here rather than by
54
+ * calling `defaultClient()`, because that function builds its own primary from `DATABASE_URL` and
55
+ * this boot has already RESOLVED which database it is talking to (`resolveServices`) — asking the
56
+ * environment a second question is how a process ends up with two answers to "which database is
57
+ * this". The pieces are db's own, so the routing rule is still stated in one place.
58
+ */
59
+ export function attachReplica(
60
+ primary: DbClient,
61
+ replicaUrl: string | undefined,
62
+ ): ReplicaAttachment {
63
+ if (replicaUrl === undefined) return { client: primary, replica: undefined };
64
+ const replica = createPostgresClient({ url: replicaUrl, applicationName: 'ultimate-replica' });
65
+ return { client: replicatedClient(primary, replica), replica };
66
+ }
67
+
68
+ /**
69
+ * The scope, opened once per request, OUTSIDE the handler — so a write early in a request pins
70
+ * every later read in it to the primary, which is what read-your-writes means. `compose()` wraps
71
+ * each route handler, and every read and write a request makes happens inside one.
72
+ *
73
+ * EMPTY when no replica is configured, and that is the whole cost argument: a homework app
74
+ * installs no middleware, allocates no scope object and enters no `AsyncLocalStorage` run per
75
+ * request. With one configured, the list is a single frame.
76
+ */
77
+ export function replicaMiddleware(binding: ServiceBinding, env: ReplicaEnv): readonly Middleware[] {
78
+ if (replicaUrlFor(binding, env) === undefined) return [];
79
+ return [(request, ctx, next) => withReplicaReads(() => next(request, ctx))];
80
+ }
81
+
82
+ /**
83
+ * The boot's `RuntimeOverrides` with the scope middleware laid in FRONT of whatever a host
84
+ * supplied — `compose([a, b])` runs `a` outermost, and the scope must open outside every read and
85
+ * write the request makes or a write early in it cannot pin the reads after it.
86
+ *
87
+ * Returns the caller's own value UNCHANGED when no replica is configured, which is what keeps a
88
+ * homework app from paying for this: no key added, no empty array, nothing for `startRoles` to
89
+ * forward.
90
+ */
91
+ export function replicaOverrides(
92
+ overrides: RuntimeOverrides | undefined,
93
+ binding: ServiceBinding,
94
+ env: ReplicaEnv,
95
+ ): RuntimeOverrides | undefined {
96
+ const middleware = replicaMiddleware(binding, env);
97
+ if (middleware.length === 0) return overrides;
98
+ return { ...overrides, middleware: [...middleware, ...(overrides?.middleware ?? [])] };
99
+ }
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { noopPurgeDriver } from '@ultimat3/cache';
9
9
  import { resetLifecycle } from '@ultimat3/core';
10
+ import { resetHttpConfig } from '@ultimat3/http';
10
11
  import {
11
12
  createMemoryDriver,
12
13
  createMemoryEventBus,
@@ -64,4 +65,10 @@ export function resetDevRolesState(): void {
64
65
  resetJobsFacade();
65
66
  resetTasks();
66
67
  resetLifecycle();
68
+ // `configureHttp()` is a process-global registration made at MODULE scope, so one file that
69
+ // loads an app leaves that app's CORS origins, body limit and buckets standing for every later
70
+ // file in the same `bun test` process — and a scaffolded app now ships such a module
71
+ // (`apps/web/app/http.ts`). Nothing is broken today; this is the same rule the four resets above
72
+ // already follow, applied to the fifth global before it is the one that costs an afternoon.
73
+ resetHttpConfig();
67
74
  }
package/src/dev-roles.ts CHANGED
@@ -9,7 +9,13 @@
9
9
  import type { Role } from '@ultimat3/core';
10
10
  import { createContext, isRole, logger, ROLES } from '@ultimat3/core';
11
11
  import type { RateLimitStore, Route, ServerHandle, ServerHooks } from '@ultimat3/http';
12
- import { configuredAuthenticator, createServer, defineHttpConfig } from '@ultimat3/http';
12
+ import {
13
+ configuredAuthenticator,
14
+ configuredHttp,
15
+ createServer,
16
+ defineHttpConfig,
17
+ mergeHttpConfig,
18
+ } from '@ultimat3/http';
13
19
  import type { OutboxRelay, Scheduler, Worker } from '@ultimat3/jobs';
14
20
  import {
15
21
  createOutboxRelay,
@@ -257,37 +263,45 @@ function startWeb(options: StartRolesOptions): ServerHandle {
257
263
  ? {}
258
264
  : { middleware: options.overrides.middleware }),
259
265
  ...(store === undefined ? {} : { rateLimitStore: store }),
260
- config: defineHttpConfig({
261
- port: options.port,
262
- dev: binding.dev,
263
- buildId: options.buildId,
264
- hostname: binding.hostname,
265
- signInPath: options.signInPath ?? null,
266
- // One declaration, never half of one: `defineHttpConfig` refuses `trustProxy` without hops.
267
- ...(hops === null ? {} : { trustProxy: true, trustedProxyHops: hops }),
268
- // `scope` is mandatory since @ultimat3/http made an undeclared limiter a boot error, and it
269
- // is DERIVED from the store rather than hardcoded — a literal here would be a second
270
- // declaration quietly contradicting the object beside it, and `assertRateLimitScope` holds
271
- // the two halves together. It answered `'process'` on every real boot until `startServices`
272
- // resolved a store, so the shipped chart's three `web` replicas enforced `login: { limit: 5 }`
273
- // as fifteen attempts, with `x verify` green.
274
- rateLimit: { scope: store?.scope ?? 'process' },
275
- // Hashes, never `'unsafe-inline'`: a `render: 'static'` page is a file on disk, so
276
- // nothing can stamp a per-response nonce into it, but its body is fixed and a hash is a
277
- // function of that body. Read after `loadApp` — importing the app IS what registered them.
278
- // BOTH directives, and the script half is the one that was missing: the hydration runtime
279
- // is emitted inline in every document that carries an island, so `script-src 'self'` meant
280
- // no island booted anywhere the policy is enforced — which is every container, and never
281
- // `x dev`, where it is report-only.
282
- security: {
283
- csp: {
284
- extend: {
285
- 'style-src': inlineStyleSources(options.inlineStyles ?? []),
286
- 'script-src': inlineScriptSources(),
266
+ // The app's own declaration UNDERNEATH, this boot's facts on top. Without the first half the
267
+ // entire HTTP tuning surface was unreachable from a shipped app — this literal was its only
268
+ // construction, so `cors.origins` stayed `[]` in every deployment (no cross-origin call could
269
+ // ever succeed), `bodyLimitBytes` stayed 1 MiB and `requestTimeoutMs` 30s for a bank and a
270
+ // blog alike. The ORDER is not a preference: `buildId`, the port, the CSP hashes of what this
271
+ // process emits and the scope of the store it installed are facts only the boot has.
272
+ config: defineHttpConfig(
273
+ mergeHttpConfig(configuredHttp(), {
274
+ port: options.port,
275
+ dev: binding.dev,
276
+ buildId: options.buildId,
277
+ hostname: binding.hostname,
278
+ signInPath: options.signInPath ?? null,
279
+ // One declaration, never half of one: `defineHttpConfig` refuses `trustProxy` without hops.
280
+ ...(hops === null ? {} : { trustProxy: true, trustedProxyHops: hops }),
281
+ // `scope` is mandatory since @ultimat3/http made an undeclared limiter a boot error, and it
282
+ // is DERIVED from the store rather than hardcoded — a literal here would be a second
283
+ // declaration quietly contradicting the object beside it, and `assertRateLimitScope` holds
284
+ // the two halves together. It answered `'process'` on every real boot until `startServices`
285
+ // resolved a store, so the shipped chart's three `web` replicas enforced
286
+ // `login: { limit: 5 }` as fifteen attempts, with `x verify` green.
287
+ rateLimit: { scope: store?.scope ?? 'process' },
288
+ // Hashes, never `'unsafe-inline'`: a `render: 'static'` page is a file on disk, so
289
+ // nothing can stamp a per-response nonce into it, but its body is fixed and a hash is a
290
+ // function of that body. Read after `loadApp` — importing the app IS what registered them.
291
+ // BOTH directives, and the script half is the one that was missing: the hydration runtime
292
+ // is emitted inline in every document that carries an island, so `script-src 'self'` meant
293
+ // no island booted anywhere the policy is enforced — which is every container, and never
294
+ // `x dev`, where it is report-only.
295
+ security: {
296
+ csp: {
297
+ extend: {
298
+ 'style-src': inlineStyleSources(options.inlineStyles ?? []),
299
+ 'script-src': inlineScriptSources(),
300
+ },
287
301
  },
288
302
  },
289
- },
290
- }),
303
+ }),
304
+ ),
291
305
  }).start();
292
306
  }
293
307
 
package/src/dev-sync.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from '@ultimat3/realtime/server';
17
17
  import type { StartRolesOptions } from './dev-roles';
18
18
  import { neighbouringPort, PORT_RANGE } from './flag-number';
19
+ import { portFree } from './port-probe';
19
20
  import { syncAuthenticator } from './sync-authenticator';
20
21
 
21
22
  /**
@@ -35,6 +36,46 @@ class SyncPortUnavailableError extends UltimateError {
35
36
  }
36
37
  }
37
38
 
39
+ /**
40
+ * The neighbour was already listening. `X_PORT_IN_USE` is this package's own and is exactly what
41
+ * `x doctor` reports for the same condition, so one taken port has one name wherever it is found.
42
+ *
43
+ * What shipped instead: `listenSyncNode`'s `Bun.serve` threw, `startSync` re-threw, and the
44
+ * dispatcher rendered the caught value into `X_CLI_UNEXPECTED`'s cause —
45
+ * `cause: Error: Failed to start server. Is port 4000 in use?`, `fix: x doctor --json`, from a
46
+ * command that had just printed `web listening on 3999`. Three defects in one output: an
47
+ * unstable code, a caught value rendered into a refusal, and a `fix:` that answered
48
+ * "no findings — environment is shippable" when run (#F5).
49
+ */
50
+ class SyncPortInUseError extends UltimateError {
51
+ constructor(input: { port: number; webPort: number }) {
52
+ super({
53
+ code: 'X_PORT_IN_USE',
54
+ cause: `the sync role binds PORT + 1, so \`x dev --port ${input.webPort}\` needs port ${input.port} and something is already listening on it`,
55
+ fix: `x dev --port ${neighbouringPort(input.webPort)} # or free port ${input.port}: lsof -nP -iTCP:${input.port} -sTCP:LISTEN`,
56
+ meta: { port: input.port, webPort: input.webPort },
57
+ });
58
+ }
59
+ }
60
+
61
+ /**
62
+ * What a failed `listenSyncNode` really was, ASKED rather than read off the caught value: the
63
+ * thrown thing is `Bun.serve`'s own English and interpolating it into a `cause:` is what
64
+ * `scripts/catch-render.ts` refuses. `undefined` means "not a taken port" and the original value
65
+ * is re-thrown untouched — a catch-all that renamed every listener failure would be worse than
66
+ * the bare one it replaced.
67
+ *
68
+ * `probe` is injected so a test can be exactly "the port was taken" without racing a real socket.
69
+ */
70
+ export async function syncBindRefusal(
71
+ webPort: number,
72
+ port: number,
73
+ probe: (value: number) => Promise<boolean> = portFree,
74
+ ): Promise<UltimateError | undefined> {
75
+ if (await probe(port)) return undefined;
76
+ return new SyncPortInUseError({ port, webPort });
77
+ }
78
+
38
79
  /**
39
80
  * The port the sync node listens on. `PORT + 1`, and `0` stays `0` — the kernel picks, and adding
40
81
  * one to it would pick a specific port instead.
@@ -131,8 +172,9 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
131
172
  }),
132
173
  });
133
174
  await node.start();
175
+ const port = syncPortFor(options.port);
134
176
  try {
135
- const listener = listenSyncNode(node, { port: syncPortFor(options.port) });
177
+ const listener = listenSyncNode(node, { port });
136
178
  return {
137
179
  url: listener.url,
138
180
  stop: async () => {
@@ -142,6 +184,8 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
142
184
  };
143
185
  } catch (error) {
144
186
  await node.stop();
187
+ const refusal = await syncBindRefusal(options.port, port);
188
+ if (refusal !== undefined) throw refusal;
145
189
  throw error;
146
190
  }
147
191
  }
@@ -30,6 +30,7 @@ export const CATALOG_PACKAGES = [
30
30
  '@ultimat3/manifest',
31
31
  '@ultimat3/mcp',
32
32
  '@ultimat3/money',
33
+ '@ultimat3/notify',
33
34
  '@ultimat3/policy',
34
35
  '@ultimat3/pwa',
35
36
  '@ultimat3/query',
@@ -49,9 +49,20 @@ export const CLI_OWNED_ERROR_CODES = [
49
49
  // reading — the hole that let `scripts/` hold seven type errors under a green gate.
50
50
  'X_PACKAGE_UNREFERENCED',
51
51
  'X_RELEASE_VERSION_SKEW',
52
+ // The framework's own tables, refused by name rather than by the driver's rejection: a raw
53
+ // `permission denied for schema public` says which statement failed and neither which framework
54
+ // table it was creating nor which package wants it.
55
+ 'X_FRAMEWORK_SCHEMA_FAILED',
52
56
  'X_STORAGE_UNWRITABLE',
53
57
  'X_STORAGE_SECRET_DEV',
54
58
  'X_MANIFEST_STALE',
59
+ // The other half of the same file, and the one that was silence: `x manifest` writes
60
+ // `x.manifest.json`, `AGENTS.md` tells an agent that facts live in it, `x dev` prints its path —
61
+ // and nothing ever ran the command, so the gate's `manifest` step reported green over a file
62
+ // that has never existed in any app `x new` produced. Its own code because the repair differs
63
+ // from both neighbours: `X_MANIFEST_DRIFT` means the committed file disagrees with the code and
64
+ // `X_MANIFEST_STALE` means `openapi.json` does; this one means there is nothing there at all.
65
+ 'X_MANIFEST_MISSING',
55
66
  'X_BUDGET_UNMEASURED',
56
67
  // The other half of #271, and the half no runtime can raise: a route reads a live hook and boots
57
68
  // no module in a browser, so its rows have nowhere to arrive and the page renders its loading
@@ -146,6 +157,14 @@ export const CLI_OWNED_ERROR_CODES = [
146
157
  */
147
158
  export const CLI_BORROWED_ERROR_CODES = [
148
159
  'X_NOT_IMPLEMENTED',
160
+ // `@ultimat3/policy`'s, and the gate's `policy` step reports it verbatim — cause, fix and the
161
+ // nearest declared name all come from `permissionUnknown`. A CLI-owned twin would be a second
162
+ // wording for the condition `can()` already refuses at run time, and the two would drift.
163
+ 'X_PERMISSION_UNKNOWN',
164
+ // `@ultimat3/db`'s, reported by `x doctor`'s database probe with that package's own two-branch
165
+ // fix. The CLI is the half that asks BEFORE a pool is opened; the condition and the remedy are
166
+ // both db's, and a CLI twin would be one unreachable database with two names.
167
+ 'X_DB_UNAVAILABLE',
149
168
  'X_CONFIG_INVALID',
150
169
  'X_ENV_MISSING',
151
170
  'X_ENV_EXAMPLE_DRIFT',
@@ -183,6 +202,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
183
202
  X_ERROR_CODE_UNDOCUMENTED: 'a shipped error code has no row in the error reference',
184
203
  X_ERROR_CODE_UNREGISTERED: 'the error reference documents a code no package registers',
185
204
  X_ERROR_CODE_UNRESOLVED: 'an error code is written as a name this repository cannot resolve',
205
+ X_FRAMEWORK_SCHEMA_FAILED: 'a framework table could not be created at boot',
186
206
  X_STORAGE_UNWRITABLE: 'the storage disk this process needs cannot be written to',
187
207
  X_STORAGE_SECRET_DEV: 'upload grants would be signed with the shipped development key',
188
208
  X_CLI_UNEXPECTED: 'the CLI itself failed',
@@ -195,6 +215,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
195
215
  X_PACKAGE_UNREFERENCED: 'a published workspace is not in the root tsconfig build graph',
196
216
  X_RELEASE_VERSION_SKEW: 'a workspace is not at the lockstep version',
197
217
  X_MANIFEST_STALE: 'openapi.json is stale',
218
+ X_MANIFEST_MISSING: 'the app ships no x.manifest.json',
198
219
  X_BUDGET_UNMEASURED: 'a route declares a budget the build never measured',
199
220
  X_LIVE_ROUTE_NO_ISLAND: 'a route reads live rows and boots nothing that could receive them',
200
221
  X_BUILD_FAILED: 'x build failed',