@ultimat3/cli 7.0.0 → 9.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 +24 -4
- package/README.md +8 -3
- package/package.json +26 -25
- package/src/app-boundaries.ts +55 -5
- package/src/app-load.ts +7 -0
- package/src/bin.ts +6 -3
- package/src/ci-log.ts +0 -0
- package/src/cmd-db-backfill.ts +240 -0
- package/src/cmd-db-branch.ts +3 -2
- package/src/cmd-db.ts +35 -156
- package/src/cmd-deploy.ts +43 -6
- package/src/cmd-dev.ts +7 -1
- package/src/cmd-errors.ts +2 -3
- package/src/cmd-fix.ts +3 -3
- package/src/cmd-i18n.ts +67 -5
- package/src/cmd-jobs.ts +27 -4
- package/src/cmd-mcp.ts +18 -9
- package/src/cmd-new.ts +91 -4
- package/src/cmd-policy.ts +3 -2
- package/src/cmd-pr.ts +55 -4
- package/src/cmd-registries.ts +3 -2
- package/src/cmd-shot.ts +68 -6
- package/src/cmd-tasks.ts +9 -4
- package/src/cmd-verify.ts +47 -6
- package/src/dev-assets.ts +4 -7
- package/src/dev-cache.ts +130 -33
- package/src/dev-lock.ts +124 -12
- package/src/dev-purge.ts +120 -0
- package/src/dev-queue.ts +39 -9
- package/src/dev-render.ts +11 -14
- package/src/dev-replicator.ts +3 -7
- package/src/dev-roles-fixture.ts +1 -1
- package/src/dev-roles.ts +40 -8
- package/src/dev-runtime.ts +137 -6
- package/src/dev-sync.ts +9 -4
- package/src/dispatch.ts +35 -5
- package/src/document-styles.ts +2 -1
- package/src/drift.ts +52 -7
- package/src/error-codes.ts +5 -0
- package/src/framework-scope.ts +57 -5
- package/src/generate-kinds.ts +19 -1
- package/src/i18n-registration.ts +67 -4
- package/src/index.ts +1 -1
- package/src/island-bundle.ts +2 -6
- package/src/island-styles.ts +1 -1
- package/src/jobs-report.ts +10 -13
- package/src/mcp-errors.ts +3 -0
- package/src/messages.ts +12 -0
- package/src/output.ts +22 -2
- package/src/parse.ts +81 -37
- package/src/prerender.ts +2 -1
- package/src/realtime-browser-probe-fixture.ts +9 -0
- package/src/runtime-overrides.ts +12 -4
- package/src/serve.ts +1 -1
- package/src/shot-settle.ts +57 -0
- package/src/shot-verdict.ts +27 -4
- package/src/solid-loader.ts +1 -1
- package/src/style-csp.ts +2 -1
- package/src/sync-authenticator.ts +86 -14
- package/src/templates/guard-bare-error.ts +122 -0
- package/src/templates/guard-raw-colour.ts +138 -0
- package/src/templates/guard-untranslated-string.ts +138 -0
- package/src/templates/guard-unzoned-date.ts +142 -0
- package/src/templates/index.ts +3 -0
- package/src/templates/island.ts +2 -1
- package/src/templates/route.ts +1 -1
- package/src/templates/scaffold-app.ts +3 -82
- package/src/templates/scaffold-container.ts +30 -4
- package/src/templates/scaffold-db-package.ts +14 -6
- package/src/templates/scaffold-docs.ts +34 -16
- package/src/templates/scaffold-entries.ts +131 -0
- package/src/templates/scaffold-guards.ts +26 -0
- package/src/templates/scaffold-repo.ts +40 -7
- package/src/test-select.ts +4 -3
- package/src/verify-run.ts +25 -3
- package/src/verify-step.ts +11 -2
- package/src/verify-tests.ts +11 -3
- package/src/write-line.ts +23 -5
package/src/dev-replicator.ts
CHANGED
|
@@ -4,13 +4,9 @@
|
|
|
4
4
|
// roles reading the other end of that transport.
|
|
5
5
|
|
|
6
6
|
import { describeEntities } from '@ultimat3/entity';
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
ReplicatorSlotHeldError,
|
|
11
|
-
replicatorLockKey,
|
|
12
|
-
selectChangeFeed,
|
|
13
|
-
} from '@ultimat3/realtime';
|
|
7
|
+
import { ReplicatorSlotHeldError } from '@ultimat3/realtime';
|
|
8
|
+
import type { Replicator, Transport } from '@ultimat3/realtime/server';
|
|
9
|
+
import { createReplicator, replicatorLockKey, selectChangeFeed } from '@ultimat3/realtime/server';
|
|
14
10
|
import type { DevServices, Env } from './dev-services';
|
|
15
11
|
import { BadFlagError } from './errors';
|
|
16
12
|
|
package/src/dev-roles-fixture.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
resetTasks,
|
|
17
17
|
} from '@ultimat3/jobs';
|
|
18
18
|
import { createMemoryDriver as createMemoryMailDriver } from '@ultimat3/mail';
|
|
19
|
-
import { DEFAULT_PRESENCE_TTL_MS, InProcessTransport } from '@ultimat3/realtime';
|
|
19
|
+
import { DEFAULT_PRESENCE_TTL_MS, InProcessTransport } from '@ultimat3/realtime/server';
|
|
20
20
|
import { defineStorage, localDriver } from '@ultimat3/storage';
|
|
21
21
|
import type { RunningServices } from './dev-runtime';
|
|
22
22
|
import { resolveServices } from './dev-services';
|
package/src/dev-roles.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Role } from '@ultimat3/core';
|
|
10
10
|
import { createContext, isRole, logger, ROLES } from '@ultimat3/core';
|
|
11
|
-
import type { Route, ServerHandle, ServerHooks } from '@ultimat3/http';
|
|
11
|
+
import type { RateLimitStore, Route, ServerHandle, ServerHooks } from '@ultimat3/http';
|
|
12
12
|
import { configuredAuthenticator, createServer, defineHttpConfig } from '@ultimat3/http';
|
|
13
13
|
import type { OutboxRelay, Scheduler, Worker } from '@ultimat3/jobs';
|
|
14
14
|
import {
|
|
@@ -195,11 +195,43 @@ export function trustedHopsFromEnv(env: Env): number | null {
|
|
|
195
195
|
return hops;
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* A deployment that substituted a PER-PROCESS store is enforcing every declared number once per
|
|
200
|
+
* replica, and the shipped chart runs three. Not refused — `assertRateLimitScope` only fires on a
|
|
201
|
+
* `'shared'` declaration, and the scope below is DERIVED from the store, so the two can never
|
|
202
|
+
* contradict each other — but the multiplier is stated, in the `warnIfUnauthenticatable` shape:
|
|
203
|
+
* loud, coded, and naming the call that fixes it.
|
|
204
|
+
*
|
|
205
|
+
* Only for a store the host SUPPLIED. A boot with no store at all resolved one of its own
|
|
206
|
+
* (`startServices`), so the remaining silent case is a hand-built `RunningServices` in a test.
|
|
207
|
+
*/
|
|
208
|
+
function warnIfProcessScoped(store: RateLimitStore): void {
|
|
209
|
+
if (store.scope === 'shared') return;
|
|
210
|
+
logger.warn(
|
|
211
|
+
`X_CONFIG_INVALID: the rate-limit store this deployment passed keeps its counters per process, so every limit the app declares is enforced once per replica — docker/helm/values.yaml runs roles.web.replicas: 3, which is 3x every number — fix: drop runtime.rateLimitStore and the boot installs postgresRateLimitStore({ executor }) on the pool it already opened`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Where this web role's limiter keeps its counters: what the deployment SUBSTITUTED, else the
|
|
217
|
+
* shared Postgres store `startServices` resolved over the pool this boot already opened.
|
|
218
|
+
*
|
|
219
|
+
* One expression, one answer, in the order `RuntimeOverrides` documents — an override REPLACES the
|
|
220
|
+
* resolved default rather than sitting beside it. `undefined` is reachable only from a hand-built
|
|
221
|
+
* runtime, which is `createServer`'s per-process memory store and `scope: 'process'` below.
|
|
222
|
+
*/
|
|
223
|
+
function rateLimitStoreFor(options: StartRolesOptions): RateLimitStore | undefined {
|
|
224
|
+
const supplied = options.overrides?.rateLimitStore;
|
|
225
|
+
if (supplied === undefined) return options.runtime.rateLimitStore;
|
|
226
|
+
warnIfProcessScoped(supplied);
|
|
227
|
+
return supplied;
|
|
228
|
+
}
|
|
229
|
+
|
|
198
230
|
function startWeb(options: StartRolesOptions): ServerHandle {
|
|
199
231
|
warnIfUnauthenticatable(options.routes);
|
|
200
232
|
const binding = options.http ?? DEV_BINDING;
|
|
201
233
|
const hops = trustedHopsFromEnv(options.env);
|
|
202
|
-
const store = options
|
|
234
|
+
const store = rateLimitStoreFor(options);
|
|
203
235
|
return createServer({
|
|
204
236
|
routes: options.routes,
|
|
205
237
|
role: 'web',
|
|
@@ -218,12 +250,12 @@ function startWeb(options: StartRolesOptions): ServerHandle {
|
|
|
218
250
|
signInPath: options.signInPath ?? null,
|
|
219
251
|
// One declaration, never half of one: `defineHttpConfig` refuses `trustProxy` without hops.
|
|
220
252
|
...(hops === null ? {} : { trustProxy: true, trustedProxyHops: hops }),
|
|
221
|
-
// `scope` is mandatory since @ultimat3/http made an undeclared limiter a boot error
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
// store
|
|
226
|
-
//
|
|
253
|
+
// `scope` is mandatory since @ultimat3/http made an undeclared limiter a boot error, and it
|
|
254
|
+
// is DERIVED from the store rather than hardcoded — a literal here would be a second
|
|
255
|
+
// declaration quietly contradicting the object beside it, and `assertRateLimitScope` holds
|
|
256
|
+
// the two halves together. It answered `'process'` on every real boot until `startServices`
|
|
257
|
+
// resolved a store, so the shipped chart's three `web` replicas enforced `login: { limit: 5 }`
|
|
258
|
+
// as fifteen attempts, with `x verify` green.
|
|
227
259
|
rateLimit: { scope: store?.scope ?? 'process' },
|
|
228
260
|
// Hashes, never `'unsafe-inline'`: a `render: 'static'` page is a file on disk, so
|
|
229
261
|
// nothing can stamp a per-response nonce into it, but its body is fixed and a hash is a
|
package/src/dev-runtime.ts
CHANGED
|
@@ -4,9 +4,19 @@
|
|
|
4
4
|
// boot installs, only backed by embedded drivers.
|
|
5
5
|
|
|
6
6
|
import { mkdirSync } from 'node:fs';
|
|
7
|
+
import { dirname } from 'node:path';
|
|
8
|
+
import { configureAuthLimiters, postgresAuthLimiter, resetAuthLimiters } from '@ultimat3/auth';
|
|
7
9
|
import type { PurgeDriver } from '@ultimat3/cache';
|
|
8
10
|
import { isNoopPurgeDriver, selectPurgeDriver } from '@ultimat3/cache';
|
|
9
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
isLocal,
|
|
13
|
+
registerReadinessCheck,
|
|
14
|
+
renderThrowable,
|
|
15
|
+
resolveEnvironment,
|
|
16
|
+
systemClock,
|
|
17
|
+
} from '@ultimat3/core';
|
|
18
|
+
import type { RateLimitStore } from '@ultimat3/http';
|
|
19
|
+
import { postgresRateLimitStore } from '@ultimat3/http';
|
|
10
20
|
import type { EventBus, JobDriver, OutboxStore } from '@ultimat3/jobs';
|
|
11
21
|
import type { MailDriver } from '@ultimat3/mail';
|
|
12
22
|
import {
|
|
@@ -16,13 +26,14 @@ import {
|
|
|
16
26
|
selectMailDriver,
|
|
17
27
|
setMailDriver,
|
|
18
28
|
} from '@ultimat3/mail';
|
|
19
|
-
import type { Transport, TransportSelection } from '@ultimat3/realtime';
|
|
20
|
-
import { selectTransport } from '@ultimat3/realtime';
|
|
29
|
+
import type { Transport, TransportSelection } from '@ultimat3/realtime/server';
|
|
30
|
+
import { selectTransport } from '@ultimat3/realtime/server';
|
|
21
31
|
import type { Storage } from '@ultimat3/storage';
|
|
22
32
|
import { defineStorage, localDriver, s3Driver, usesDevStorageSecret } from '@ultimat3/storage';
|
|
23
|
-
import { startCacheTiers } from './dev-cache';
|
|
33
|
+
import { loadCacheTiers, startCacheTiers } from './dev-cache';
|
|
34
|
+
import { installRetentionSweep } from './dev-purge';
|
|
24
35
|
import type { DevDbClient } from './dev-queue';
|
|
25
|
-
import { startQueue } from './dev-queue';
|
|
36
|
+
import { pgExecutorFor, startQueue } from './dev-queue';
|
|
26
37
|
import type { DevServices, Env } from './dev-services';
|
|
27
38
|
import { LocalDiskUnsafeError, StorageUnwritableError } from './errors';
|
|
28
39
|
import { msg } from './messages';
|
|
@@ -51,6 +62,20 @@ export interface RunningServices {
|
|
|
51
62
|
* would show members leaving that never left.
|
|
52
63
|
*/
|
|
53
64
|
readonly presenceTtlMs: number;
|
|
65
|
+
/**
|
|
66
|
+
* Where the HTTP rate limiter keeps its counters, over the pool this boot already opened.
|
|
67
|
+
*
|
|
68
|
+
* Resolved HERE and not at `startWeb` for the reason every other driver is: `startServices` is
|
|
69
|
+
* what holds the connection, and a store built anywhere else would open a second pool against a
|
|
70
|
+
* url this boot resolved once. Until it did, no boot installed one at all — so `rateLimit.scope`
|
|
71
|
+
* derived to `'process'` on every deployment the framework produces, while `docker/helm` runs
|
|
72
|
+
* `roles.web.replicas: 3` and `x new` scaffolds two.
|
|
73
|
+
*
|
|
74
|
+
* OPTIONAL because a `RunningServices` can be hand-built: a test's fixture runtime has a stub
|
|
75
|
+
* client and no shared store, which is `createServer`'s memory store and `scope: 'process'` —
|
|
76
|
+
* the honest answer for it, and the one `startWeb` derives.
|
|
77
|
+
*/
|
|
78
|
+
readonly rateLimitStore?: RateLimitStore;
|
|
54
79
|
readonly purge: PurgeDriver;
|
|
55
80
|
/** Same rule as `mailDetail`: the env key that selected the CDN, never the token behind it. */
|
|
56
81
|
readonly purgeDetail: string;
|
|
@@ -176,6 +201,59 @@ export function startStorage(services: DevServices, env: Env, override?: Storage
|
|
|
176
201
|
return defineStorage({ disks: { local: localDriver({ root }) }, default: 'local' });
|
|
177
202
|
}
|
|
178
203
|
|
|
204
|
+
/**
|
|
205
|
+
* A readiness check over a dependency that can only be asked asynchronously.
|
|
206
|
+
*
|
|
207
|
+
* `ReadinessCheck` is synchronous and that is the mechanism, not a limitation: a probe that awaits
|
|
208
|
+
* a network call takes as long as the dependency does, so a slow database makes `/readyz` miss its
|
|
209
|
+
* `timeoutSeconds`, the kubelet reads that as unready, and capacity is pulled from an already
|
|
210
|
+
* struggling system — the outage the probe existed to prevent, caused by the probe.
|
|
211
|
+
*
|
|
212
|
+
* So the read answers the PREVIOUS probe and schedules the next, which is the standard cached
|
|
213
|
+
* health shape: staleness is one poll period, and the poll period is the operator's own
|
|
214
|
+
* `periodSeconds`. Deliberately not a `setInterval`: a timer would probe a process nobody is
|
|
215
|
+
* asking about, and in `x dev` every probe is a statement — one span, one trace id — so a
|
|
216
|
+
* one-per-second heartbeat would evict every real request from `/_x/timeline` inside a minute.
|
|
217
|
+
*
|
|
218
|
+
* It starts `true` because it is already proven: `startQueue` pinged this pool before this line
|
|
219
|
+
* ran, and a boot that could not would have thrown instead of reaching here.
|
|
220
|
+
*/
|
|
221
|
+
function probedReadinessCheck(name: string, probe: () => Promise<unknown>): () => void {
|
|
222
|
+
let live = true;
|
|
223
|
+
let inFlight = false;
|
|
224
|
+
const refresh = (): void => {
|
|
225
|
+
if (inFlight) return;
|
|
226
|
+
inFlight = true;
|
|
227
|
+
void probe()
|
|
228
|
+
.then(
|
|
229
|
+
() => {
|
|
230
|
+
live = true;
|
|
231
|
+
},
|
|
232
|
+
// Every rejection, including the coded `X_DB_UNAVAILABLE` a closed pool answers with. A
|
|
233
|
+
// check that threw would be reported as failing anyway; catching it here keeps the reason
|
|
234
|
+
// out of the endpoint's own path, where nothing could report it.
|
|
235
|
+
() => {
|
|
236
|
+
live = false;
|
|
237
|
+
},
|
|
238
|
+
)
|
|
239
|
+
.finally(() => {
|
|
240
|
+
inFlight = false;
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
return registerReadinessCheck(name, () => {
|
|
244
|
+
refresh();
|
|
245
|
+
return live;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** A transport that says whether it is connected. NATS does; the in-process bus has nothing to be. */
|
|
250
|
+
interface ConnectableTransport {
|
|
251
|
+
readonly connected: boolean;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const saysConnected = (transport: Transport): transport is Transport & ConnectableTransport =>
|
|
255
|
+
typeof (transport as Partial<ConnectableTransport>).connected === 'boolean';
|
|
256
|
+
|
|
179
257
|
/**
|
|
180
258
|
* Release what has already started, newest first, and return every failure instead of throwing on
|
|
181
259
|
* the first: a step that rejects must not skip the ones after it, or one transport that will not
|
|
@@ -214,10 +292,45 @@ export async function startServices(
|
|
|
214
292
|
const bus: TransportSelection = selectTransport(env);
|
|
215
293
|
const queue = await startQueue(services, overrides);
|
|
216
294
|
const { db, jobs, outbox, events } = queue;
|
|
295
|
+
// The same executor the jobs driver, the outbox, the event bus and the idempotency store run
|
|
296
|
+
// on — one pool, one `Bun.sql` that does NOT satisfy `PgExecutor` (`Bun.sql.query` is
|
|
297
|
+
// `undefined`), one wrapper.
|
|
298
|
+
const executor = pgExecutorFor(db);
|
|
299
|
+
const rateLimitStore = postgresRateLimitStore({ executor });
|
|
217
300
|
// Boot is a sequence of external resources, and every step after the first can reject — the
|
|
218
301
|
// queue is already up, so from here an unwind must release it exactly like everything after it.
|
|
302
|
+
// Which is why the `try` opens on the NEXT line and not eight steps further down: it did, and
|
|
303
|
+
// the steps above it registered process-wide state (`configureAuthLimiters`, the `purge()` job
|
|
304
|
+
// and its `task`) that throws on a name a previous boot in this process left in the registry —
|
|
305
|
+
// `X_JOB_NAME_TAKEN`, outside the unwind, so `x dev` exited holding the PGlite lock, the pool
|
|
306
|
+
// and the ambient accessors. Nothing may be pushed onto `started` from outside this block.
|
|
219
307
|
const started: (() => void | Promise<void>)[] = [() => queue.stop()];
|
|
220
308
|
try {
|
|
309
|
+
// Where failed sign-ins are counted, installed BEFORE `loadApp` for the reason the idempotency
|
|
310
|
+
// store is: `defineAuth` is the app's call and it runs when the app's modules import, so a seam
|
|
311
|
+
// filled afterwards is one every app would have to fill itself. A FACTORY and not a limiter —
|
|
312
|
+
// the app declares `maxAttempts`/`windowMs`/`lockoutMs` and this boot has not read them yet, so
|
|
313
|
+
// a limiter built here would be refused by `assertAuthLimiterPolicy` on any app that tuned one.
|
|
314
|
+
//
|
|
315
|
+
// Until this line every deployment the framework produces counted lockouts per POD, while
|
|
316
|
+
// `x new` scaffolds `replicas: 2` and `docker/helm` runs three — `maxAttempts × N` guesses per
|
|
317
|
+
// account, and a lockout one replica established invisible to the rest.
|
|
318
|
+
configureAuthLimiters((policy) =>
|
|
319
|
+
postgresAuthLimiter({ executor, clock: systemClock, policy }),
|
|
320
|
+
);
|
|
321
|
+
started.push(() => resetAuthLimiters());
|
|
322
|
+
// The hourly sweep over the three framework tables this boot is responsible for. Every one of
|
|
323
|
+
// them ships a `purgeExpired()` that nothing called, so every row written was a row kept —
|
|
324
|
+
// `x_rate_limit` takes one upsert per request the web role serves, assets included.
|
|
325
|
+
started.push(
|
|
326
|
+
installRetentionSweep({ idempotency: queue.idempotency, rateLimit: rateLimitStore }),
|
|
327
|
+
);
|
|
328
|
+
// One readiness check per resource this boot OWNS, released with it. Nothing in the tree
|
|
329
|
+
// registered one, so `/readyz` was `markReady()` alone — "this process bound a socket" — and
|
|
330
|
+
// `packages/http/src/server.ts` calls that BEFORE `Bun.serve`, while `dev-roles.ts` calls it
|
|
331
|
+
// before `sync`, `worker` and `scheduler` start. The chart's `readinessProbe` and the container
|
|
332
|
+
// healthcheck both route on it, so a replica whose pool was gone kept taking traffic.
|
|
333
|
+
started.push(probedReadinessCheck('database', () => db.ping()));
|
|
221
334
|
// Dialled here rather than at selection: an unreachable bus must fail at `x dev`, not on the
|
|
222
335
|
// first change nobody receives, and the socket is a resource the unwind below has to release.
|
|
223
336
|
// A supplied transport is already connected and is NOT closed here: whoever built it owns its
|
|
@@ -228,6 +341,14 @@ export async function startServices(
|
|
|
228
341
|
started.push(() => bus.transport.close());
|
|
229
342
|
transport = bus.transport;
|
|
230
343
|
}
|
|
344
|
+
// Only a transport that can be disconnected gets a check. `NatsTransport.connected` is a
|
|
345
|
+
// synchronous getter over the client's own state, so no probe is needed; the in-process bus
|
|
346
|
+
// has nothing to lose a connection to, and a check that can only answer `true` is a number in
|
|
347
|
+
// `registered` that means nothing.
|
|
348
|
+
if (saysConnected(transport)) {
|
|
349
|
+
const connectable = transport;
|
|
350
|
+
started.push(registerReadinessCheck('transport', () => connectable.connected));
|
|
351
|
+
}
|
|
231
352
|
const storage = startStorage(services, env, overrides?.storage);
|
|
232
353
|
// With no credential this is the memory driver: caught, not sent, so the `/_x` mail panel can
|
|
233
354
|
// show what a template renders in every locale without a mailbox or a message escaping to a
|
|
@@ -243,7 +364,13 @@ export async function startServices(
|
|
|
243
364
|
// recomputed every cached read. Released with `resetTiers()`, which drops the whole registry:
|
|
244
365
|
// this boot is the only thing that registers one, and a tier left behind would purge for a
|
|
245
366
|
// process that has stopped.
|
|
246
|
-
|
|
367
|
+
// The ladder is the app's declaration, never the environment's. `cache.tiers` was declared,
|
|
368
|
+
// validated and documented while NOTHING read it — so an app asking for one rung got four,
|
|
369
|
+
// and one asking for a shared tier got whatever `REDIS_URL` happened to say. `.x` is
|
|
370
|
+
// `resolveServices`' own join, so its parent is the app root: the one fact this function needs
|
|
371
|
+
// and does not already carry.
|
|
372
|
+
const tiers = await loadCacheTiers(dirname(services.stateDir));
|
|
373
|
+
started.push(startCacheTiers({ env, purge, transport, tiers }));
|
|
247
374
|
|
|
248
375
|
return {
|
|
249
376
|
services,
|
|
@@ -255,6 +382,10 @@ export async function startServices(
|
|
|
255
382
|
storage,
|
|
256
383
|
mail,
|
|
257
384
|
mailDetail: selection.detail,
|
|
385
|
+
// Built above, beside the auth limiter factory and the retention sweep that purges its
|
|
386
|
+
// table: three readers of one executor, resolved once. `startWeb` is what an override
|
|
387
|
+
// replaces it at.
|
|
388
|
+
rateLimitStore,
|
|
258
389
|
// The env key that selected the bus — or the honest answer that no env key did, because a
|
|
259
390
|
// boot line reading `NATS_URL` over a transport the host handed in is a lie a script parses.
|
|
260
391
|
transportDetail: overrides?.transport === undefined ? bus.detail : 'runtime override',
|
package/src/dev-sync.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
PresenceRegistry,
|
|
14
14
|
RingChangeBuffer,
|
|
15
15
|
SocketRegistry,
|
|
16
|
-
} from '@ultimat3/realtime';
|
|
16
|
+
} from '@ultimat3/realtime/server';
|
|
17
17
|
import type { StartRolesOptions } from './dev-roles';
|
|
18
18
|
import { syncAuthenticator } from './sync-authenticator';
|
|
19
19
|
|
|
@@ -69,9 +69,14 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
|
|
|
69
69
|
const hub = new ChannelHub({ transport: options.runtime.transport, sockets });
|
|
70
70
|
// The node evaluated no credential of its own and no host ever handed it one, so every socket
|
|
71
71
|
// the framework opened was anonymous and every guard, gate, presence entry and tenant cap
|
|
72
|
-
// decided against `null`. An explicit override first
|
|
73
|
-
//
|
|
74
|
-
//
|
|
72
|
+
// decided against `null`. An explicit override first, then the app's own HTTP resolver, then
|
|
73
|
+
// nothing at all, which is what `x dev` with no authenticator should stay.
|
|
74
|
+
//
|
|
75
|
+
// BOTH of the first two re-authorize: `syncAuthenticator` carries an `expiresAt` and a `refresh`
|
|
76
|
+
// of its own (`SYNC_GRANT_TTL_MS`), re-asking the app's resolver with the upgrade's own
|
|
77
|
+
// `cookie`/`authorization`, so `logout` closes the socket and not only the HTTP session. The
|
|
78
|
+
// override is how a deployment states a window its credential already declares (a token's
|
|
79
|
+
// `exp`), or resolves identity from a header the adapter deliberately does not retain.
|
|
75
80
|
const authenticate = options.overrides?.syncAuthenticate ?? syncAuthenticator(options.buildId);
|
|
76
81
|
const node = createSyncNode({
|
|
77
82
|
hub,
|
package/src/dispatch.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// path as results, so a failure is machine-readable exactly like a success.
|
|
4
4
|
|
|
5
5
|
import { isAbsolute, resolve } from 'node:path';
|
|
6
|
+
import { setLogStream } from '@ultimat3/core';
|
|
6
7
|
import { requireAppRoot, requireBunVersion } from './app-root';
|
|
7
8
|
import { createHelpCommand } from './cmd-help';
|
|
8
9
|
import { plannedCommandFor } from './cmd-planned';
|
|
@@ -23,8 +24,26 @@ export interface DispatchOptions {
|
|
|
23
24
|
readonly bunVersion: string;
|
|
24
25
|
readonly runner?: Runner;
|
|
25
26
|
readonly write: (line: string) => void;
|
|
27
|
+
/**
|
|
28
|
+
* Where a result declaring `stream: 'stderr'` goes. Optional, and it falls back to `write`: an
|
|
29
|
+
* embedding caller that supplies one sink must still SEE the line, and `create-ultimate`'s entry
|
|
30
|
+
* point — which can only ever reach `x new` — would otherwise have to declare a sink for a case
|
|
31
|
+
* it cannot produce. `bin.ts` passes the real fd 2.
|
|
32
|
+
*/
|
|
33
|
+
readonly writeError?: (line: string) => void;
|
|
26
34
|
}
|
|
27
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Which sink a result is written to. One function because `dispatch` writes in five places — the
|
|
38
|
+
* version refusal, the parse failure, the unknown command, the result and the catch — and a rule
|
|
39
|
+
* about fd 1 that four of them apply is not a rule.
|
|
40
|
+
*/
|
|
41
|
+
export const sinkFor = (
|
|
42
|
+
result: CommandResult,
|
|
43
|
+
options: Pick<DispatchOptions, 'write' | 'writeError'>,
|
|
44
|
+
): ((line: string) => void) =>
|
|
45
|
+
result.stream === 'stderr' ? (options.writeError ?? options.write) : options.write;
|
|
46
|
+
|
|
28
47
|
const resolveCwd = (cwd: string, flag: string | undefined): string => {
|
|
29
48
|
if (flag === undefined) return cwd;
|
|
30
49
|
return isAbsolute(flag) ? flag : resolve(cwd, flag);
|
|
@@ -48,7 +67,8 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
|
48
67
|
try {
|
|
49
68
|
requireBunVersion(options.bunVersion);
|
|
50
69
|
} catch (error) {
|
|
51
|
-
|
|
70
|
+
const failure = errorResult('x', error);
|
|
71
|
+
sinkFor(failure, options)(render(failure, wantsJson(options.argv)));
|
|
52
72
|
return 1;
|
|
53
73
|
}
|
|
54
74
|
|
|
@@ -70,7 +90,7 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
|
70
90
|
// `wantsJson`, not `includes('--json')`: a typo'd flag or a typo'd command is exactly the case
|
|
71
91
|
// an agent hits while always passing `-j`, and the short form rendered prose it then parsed.
|
|
72
92
|
const result = errorResult(planned?.name ?? 'x', failure);
|
|
73
|
-
options
|
|
93
|
+
sinkFor(result, options)(render(result, wantsJson(options.argv)));
|
|
74
94
|
return 1;
|
|
75
95
|
}
|
|
76
96
|
|
|
@@ -83,7 +103,7 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
|
83
103
|
known: SPECS.map((spec) => spec.name),
|
|
84
104
|
}),
|
|
85
105
|
);
|
|
86
|
-
options
|
|
106
|
+
sinkFor(result, options)(render(result, args.json));
|
|
87
107
|
return 1;
|
|
88
108
|
}
|
|
89
109
|
|
|
@@ -105,6 +125,16 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
|
105
125
|
};
|
|
106
126
|
|
|
107
127
|
try {
|
|
128
|
+
// `--json` means fd 1 carries ONE document, and every command that boots an app logs through
|
|
129
|
+
// core's module-scope `logger`, whose default writer is stdout: `x db migrate --json` printed
|
|
130
|
+
// `ultimate migrate applied` and then the command's own object, so `json.load` on the output
|
|
131
|
+
// of a command whose whole contract is `--json` raised on the second. Decided HERE, once, for
|
|
132
|
+
// all thirty commands — a rule each booting command had to remember is a rule the next one
|
|
133
|
+
// forgets. A server's stdout stays its log stream; this is about the CLI process only.
|
|
134
|
+
// Set on EVERY dispatch, both ways: the stream is process-wide state, so `if (args.json)`
|
|
135
|
+
// alone left a JSON run's `stderr` in place for the next non-JSON one — one `x` process
|
|
136
|
+
// dispatching twice (the MCP host, a test, an embedding caller) lost its boot logs off stdout.
|
|
137
|
+
setLogStream(args.json ? 'stderr' : 'stdout');
|
|
108
138
|
// The reader `CommandSpec.requiresApp` never had. Its doc said "the dispatcher enforces it" and
|
|
109
139
|
// `dispatch` did not read the field at all: the guarantee held only because all 17 declaring
|
|
110
140
|
// commands happen to call `requireAppRoot` themselves, so a new command that declares it and
|
|
@@ -115,7 +145,7 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
|
115
145
|
// command, which declares nothing: usage for a command must be readable from anywhere.
|
|
116
146
|
if (target.spec.requiresApp === true) requireAppRoot(target.spec.name, ctx.cwd);
|
|
117
147
|
const result = await target.run(ctx);
|
|
118
|
-
options
|
|
148
|
+
sinkFor(result, options)(render(result, args.json, args.flags.get('verbose') === true));
|
|
119
149
|
// `x dev` and `x mcp serve --transport http` are still listening here: report first, so the
|
|
120
150
|
// url is on stdout the moment it is reachable, then stay in the process until the drain that
|
|
121
151
|
// stops them. Without this the exit code below is what takes the server down.
|
|
@@ -123,7 +153,7 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
|
123
153
|
return exitCodeFor(result);
|
|
124
154
|
} catch (error) {
|
|
125
155
|
const result = errorResult(args.command, error);
|
|
126
|
-
options
|
|
156
|
+
sinkFor(result, options)(render(result, args.json));
|
|
127
157
|
return 1;
|
|
128
158
|
}
|
|
129
159
|
}
|
package/src/document-styles.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// apart from the styling nobody sees missing. A silent failure is exactly what axiom 3 exists for.
|
|
6
6
|
|
|
7
7
|
import type { Surface } from '@ultimat3/render';
|
|
8
|
-
import { routeEntries
|
|
8
|
+
import { routeEntries } from '@ultimat3/render';
|
|
9
|
+
import { stylesFor } from '@ultimat3/render/server';
|
|
9
10
|
import type { Finding } from './output';
|
|
10
11
|
|
|
11
12
|
/** The stylesheet an app is expected to own, named in the fix so it is one edit, not a hunt. */
|
package/src/drift.ts
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
|
+
import { describeEntities } from '@ultimat3/entity';
|
|
14
15
|
import { countDeclaredEntities } from './app-entities';
|
|
16
|
+
import { loadApp } from './app-load';
|
|
15
17
|
// One declaration of where migrations live, and it belongs to the module that reads them —
|
|
16
18
|
// `x db migrate` and this sidecar must never disagree about the directory they share.
|
|
17
19
|
import { hashFileName, MIGRATIONS_DIR } from './migrations';
|
|
@@ -20,7 +22,48 @@ import type { Finding } from './output';
|
|
|
20
22
|
export const DB_PACKAGE = join('packages', 'db');
|
|
21
23
|
const SCHEMA_GLOB = 'packages/db/src/**/*.ts';
|
|
22
24
|
|
|
23
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Canonical JSON: object keys sorted, arrays in their own order. The registry's description is a
|
|
27
|
+
* BUILD INPUT committed to disk as a hash, so a field reordered inside `describe()` upstream would
|
|
28
|
+
* otherwise move every app's hash and report drift over a framework upgrade nobody made.
|
|
29
|
+
*/
|
|
30
|
+
function canonicalJson(value: unknown): string {
|
|
31
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
32
|
+
if (typeof value === 'object' && value !== null) {
|
|
33
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
|
|
34
|
+
a < b ? -1 : a > b ? 1 : 0,
|
|
35
|
+
);
|
|
36
|
+
return `{${entries.map(([key, held]) => `${JSON.stringify(key)}:${canonicalJson(held)}`).join(',')}}`;
|
|
37
|
+
}
|
|
38
|
+
// `undefined` has no JSON form and an optional field left unset must hash as absent, not throw.
|
|
39
|
+
return JSON.stringify(value) ?? 'null';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What the app's entities declare, as the registry describes them — the half `SCHEMA_GLOB` cannot
|
|
44
|
+
* see. `x new` puts an entity at `apps/web/app/<feature>/entity.ts` and `packages/db/src/schema.ts`
|
|
45
|
+
* merely re-exports it, so a column added there moved NO byte the glob reads: three generated
|
|
46
|
+
* entities and one migration reported clean, with every `.hash` sidecar identical. The registry is
|
|
47
|
+
* the same fact `x db gen` diffs (`describeEntities()`), so the check and the generator now read
|
|
48
|
+
* one schema instead of two.
|
|
49
|
+
*
|
|
50
|
+
* `loadApp` is what fills that registry, and it is called on every path rather than only where the
|
|
51
|
+
* caller happens to have loaded already: a hash computed against an EMPTY registry would differ
|
|
52
|
+
* from the one `x db gen` recorded with the app loaded, and every app would read as drifted.
|
|
53
|
+
* A module that will not import leaves the registry SHORT rather than raising — the stance
|
|
54
|
+
* `countDeclaredEntities` already documents — so `x doctor` still answers on the app it diagnoses.
|
|
55
|
+
*/
|
|
56
|
+
async function declaredSchemaJson(root: string): Promise<string> {
|
|
57
|
+
await loadApp(root);
|
|
58
|
+
return canonicalJson(describeEntities());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Content hash of the whole schema: the entity registry first, then every non-test file under
|
|
63
|
+
* `packages/db/src`, order-independent per file path. Both halves, because they answer different
|
|
64
|
+
* questions — the registry is what reaches the database, and the glob is what catches a seed or a
|
|
65
|
+
* helper moving under it (which is what `reconcileSchemaHash` exists to re-record).
|
|
66
|
+
*/
|
|
24
67
|
export async function schemaHash(root: string): Promise<string> {
|
|
25
68
|
const glob = new Bun.Glob(SCHEMA_GLOB);
|
|
26
69
|
const paths: string[] = [];
|
|
@@ -29,6 +72,7 @@ export async function schemaHash(root: string): Promise<string> {
|
|
|
29
72
|
}
|
|
30
73
|
paths.sort();
|
|
31
74
|
const hasher = new Bun.CryptoHasher('sha256');
|
|
75
|
+
hasher.update(await declaredSchemaJson(root));
|
|
32
76
|
for (const path of paths) {
|
|
33
77
|
hasher.update(path);
|
|
34
78
|
hasher.update(await Bun.file(join(root, path)).text());
|
|
@@ -77,9 +121,10 @@ export interface HashReconciliation {
|
|
|
77
121
|
}
|
|
78
122
|
|
|
79
123
|
/**
|
|
80
|
-
* Re-record the sidecar for a migration that is already the right one.
|
|
81
|
-
* non-test file under `packages/db/src`,
|
|
82
|
-
*
|
|
124
|
+
* Re-record the sidecar for a migration that is already the right one. Neither half of the hash is
|
|
125
|
+
* DDL-only — `SCHEMA_GLOB` covers every non-test file under `packages/db/src`, and the registry
|
|
126
|
+
* carries invariants and tags a diff can leave empty — so an edit can move the hash with no
|
|
127
|
+
* statement behind it, and `X_DB_DRIFT`'s `fix:` has to have somewhere to
|
|
83
128
|
* land or the instruction is unfollowable. The caller owes the proof that the DDL genuinely did not
|
|
84
129
|
* move (`db-generate.ts` reaches this only on an empty diff off a fully loaded registry); this
|
|
85
130
|
* function decides only whether a write is needed.
|
|
@@ -107,9 +152,9 @@ export type DeclaredEntityCount = () => Promise<number>;
|
|
|
107
152
|
* Empty result = no drift. A missing db package is not drift (an app may have no database yet);
|
|
108
153
|
* a schema with no migration at all is — *provided* the app declares an entity for one to record.
|
|
109
154
|
*
|
|
110
|
-
* The entity count is read lazily and ONLY in that first branch
|
|
111
|
-
*
|
|
112
|
-
*
|
|
155
|
+
* The entity count is read lazily and ONLY in that first branch; the hash itself loads the app on
|
|
156
|
+
* every path, because the registry is half of what it covers. Still no database anywhere here,
|
|
157
|
+
* which is what lets the gate run this in a CI with nothing listening.
|
|
113
158
|
*/
|
|
114
159
|
export async function checkSourceDrift(
|
|
115
160
|
root: string,
|
package/src/error-codes.ts
CHANGED
|
@@ -56,6 +56,10 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
56
56
|
'X_ROLE_UNKNOWN',
|
|
57
57
|
'X_PORT_INVALID',
|
|
58
58
|
'X_DEV_ALREADY_RUNNING',
|
|
59
|
+
// The OTHER thing the preflight can find, and it was reported as the one above: an unreadable
|
|
60
|
+
// lock this process could not remove made `DevAlreadyRunningError` name THIS pid as the holder,
|
|
61
|
+
// so the remedy printed was `kill <self>` — unrunnable, and a cause that was simply untrue.
|
|
62
|
+
'X_DEV_LOCK_UNREADABLE',
|
|
59
63
|
// The boot's own consistency check. `startServices` captures the drivers it built, and
|
|
60
64
|
// `loadApp` runs AFTER it — so an app module calling `setJobDriver(theirs)` moved the ambient
|
|
61
65
|
// slot and left the captured object alone: every `handle.enqueue()` went to their queue while
|
|
@@ -177,6 +181,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
177
181
|
X_ROLE_UNKNOWN: 'ROLE names something that is not a role',
|
|
178
182
|
X_PORT_INVALID: 'PORT is not a TCP port number',
|
|
179
183
|
X_DEV_ALREADY_RUNNING: 'another x dev already owns this checkout',
|
|
184
|
+
X_DEV_LOCK_UNREADABLE: 'the dev lock file cannot be read or removed',
|
|
180
185
|
X_RUNTIME_DRIVER_SPLIT: 'the ambient driver is not the one this process serves',
|
|
181
186
|
X_GENERATE_CONFLICT: 'a generator would overwrite a file',
|
|
182
187
|
X_PORT_IN_USE: 'the dev port is taken',
|