@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/CLAUDE.md +50 -1
- package/README.md +4 -4
- package/package.json +29 -28
- package/src/app-permissions.ts +0 -0
- package/src/cmd-dev.ts +6 -0
- package/src/cmd-doctor.ts +74 -16
- package/src/cmd-errors.ts +6 -0
- package/src/cmd-generate.ts +2 -27
- package/src/cmd-i18n.ts +16 -1
- package/src/dev-queue.ts +51 -37
- package/src/dev-replica.ts +99 -0
- package/src/dev-roles-fixture.ts +7 -0
- package/src/dev-roles.ts +44 -30
- package/src/dev-sync.ts +45 -1
- package/src/error-catalog.ts +1 -0
- package/src/error-codes.ts +21 -0
- package/src/framework-schema.ts +152 -0
- package/src/i18n-index.ts +40 -0
- package/src/i18n-registration.ts +43 -1
- package/src/index.ts +10 -0
- package/src/mcp-errors.ts +15 -0
- package/src/messages.ts +6 -1
- package/src/parse.ts +39 -6
- package/src/port-probe.ts +22 -0
- package/src/schema-errors.ts +28 -0
- package/src/serve.ts +7 -1
- package/src/templates/scaffold-app.ts +2 -0
- package/src/templates/scaffold-docs.ts +10 -4
- package/src/templates/scaffold-http.ts +84 -0
- package/src/templates/scaffold-repo.ts +20 -0
- package/src/templates/scaffold-roles.ts +31 -3
- package/src/verify-checks.ts +36 -0
- package/src/verify-step.ts +8 -0
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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
|
-
*
|
|
99
|
-
* `@ultimat3/
|
|
100
|
-
*
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/src/dev-roles-fixture.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
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
|
}
|
package/src/error-catalog.ts
CHANGED
package/src/error-codes.ts
CHANGED
|
@@ -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',
|