@ultimat3/cli 1.2.0 → 3.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 +761 -0
- package/README.md +42 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +134 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +219 -0
- package/src/cmd-db.ts +458 -153
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +92 -18
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +74 -10
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +14 -8
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +29 -24
- package/src/cmd-verify.ts +197 -25
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +269 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +144 -0
- package/src/db-seed.ts +294 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +108 -23
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +167 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +247 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +37 -7
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +78 -10
- package/src/error-catalog.ts +8 -18
- package/src/error-codes.ts +192 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +201 -138
- package/src/exec.ts +42 -8
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +67 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +92 -15
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +128 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +93 -2
- package/src/metrics-endpoint.ts +64 -16
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +185 -13
- package/src/shell-quote.ts +15 -0
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +20 -11
- package/src/test-workers.ts +50 -0
- package/src/ts-scan.ts +284 -15
- package/src/tsconfig-references.ts +103 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
package/src/serve.ts
CHANGED
|
@@ -4,11 +4,25 @@
|
|
|
4
4
|
// `dev: true`. The only production-shaped decisions live here: which role, which port, and the
|
|
5
5
|
// fact that a container must bind every interface.
|
|
6
6
|
|
|
7
|
-
import { listActions, toRoute } from '@ultimat3/action';
|
|
8
7
|
import type { Role } from '@ultimat3/core';
|
|
9
|
-
import {
|
|
10
|
-
|
|
8
|
+
import {
|
|
9
|
+
configureErrorReporting,
|
|
10
|
+
isRole,
|
|
11
|
+
logger,
|
|
12
|
+
ROLES,
|
|
13
|
+
sentryErrorReporter,
|
|
14
|
+
} from '@ultimat3/core';
|
|
15
|
+
import {
|
|
16
|
+
assertNoDrift,
|
|
17
|
+
checkDrift,
|
|
18
|
+
type DriftReport,
|
|
19
|
+
type MigrationReport,
|
|
20
|
+
migrate,
|
|
21
|
+
} from '@ultimat3/db';
|
|
11
22
|
import type { Route } from '@ultimat3/http';
|
|
23
|
+
import { createIsrController } from '@ultimat3/render';
|
|
24
|
+
import { apiRoutes } from './api-routes';
|
|
25
|
+
import { loadSignInPath } from './app-auth';
|
|
12
26
|
import { loadApp } from './app-load';
|
|
13
27
|
import { appManifest } from './app-manifest';
|
|
14
28
|
import { assetRoutes } from './dev-assets';
|
|
@@ -20,10 +34,15 @@ import type { RunningServices } from './dev-runtime';
|
|
|
20
34
|
import { startServices } from './dev-runtime';
|
|
21
35
|
import type { Env } from './dev-services';
|
|
22
36
|
import { resolveServices } from './dev-services';
|
|
37
|
+
import { storageRoutes } from './dev-storage';
|
|
23
38
|
import { PortInvalidError, RoleUnknownError } from './errors';
|
|
24
39
|
import { holdUntilShutdown } from './hold';
|
|
40
|
+
import { buildIslands } from './island-bundle';
|
|
41
|
+
import { islandRoutes } from './island-routes';
|
|
25
42
|
import { DEFAULT_METRICS_PORT } from './metrics-endpoint';
|
|
26
43
|
import { readMigrations } from './migrations';
|
|
44
|
+
import { startOtlpExport } from './otlp-export';
|
|
45
|
+
import type { RuntimeOverrides } from './runtime-overrides';
|
|
27
46
|
|
|
28
47
|
export const DEFAULT_PORT = 3000;
|
|
29
48
|
|
|
@@ -67,6 +86,45 @@ export function metricsPortFromEnv(env: Env): number {
|
|
|
67
86
|
return portValue(env, 'METRICS_PORT', DEFAULT_METRICS_PORT);
|
|
68
87
|
}
|
|
69
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The scrape port a boot uses, given the app port it already resolved. One expression, and it is
|
|
91
|
+
* exported because `x dev` is the second caller: `cmd-dev.ts` passed no `metricsPort` at all, so
|
|
92
|
+
* `METRICS_PORT` was honoured in the container and ignored on a laptop — the dev/prod parity break
|
|
93
|
+
* `dev-roles.ts`'s own header forbids, and a second copy of this rule would be the same break
|
|
94
|
+
* one edit later.
|
|
95
|
+
*
|
|
96
|
+
* An in-process caller asking for an ephemeral app port is a test, and a test that grabbed the
|
|
97
|
+
* fixed 9090 would fail the next suite to boot beside it. An environment that names the port still
|
|
98
|
+
* wins — that is the deploy talking.
|
|
99
|
+
*/
|
|
100
|
+
export const metricsPortFor = (env: Env, port: number, override?: number): number =>
|
|
101
|
+
override ?? (port === 0 && env['METRICS_PORT'] === undefined ? 0 : metricsPortFromEnv(env));
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The one env var that turns error monitoring on, and the only vendor-shaped name in the boot
|
|
105
|
+
* path. Not a platform primitive (axiom 7): the value is a URL to whatever the operator runs, the
|
|
106
|
+
* wire format behind it is documented and self-hostable, and `SENTRY_DSN` is what every monitor
|
|
107
|
+
* that speaks it already documents — inventing a second spelling would mean an operator's existing
|
|
108
|
+
* tooling sets a variable this framework ignores. Exactly the precedent
|
|
109
|
+
* `OTEL_EXPORTER_OTLP_ENDPOINT` already sets in `docker/helm/values.yaml`.
|
|
110
|
+
*/
|
|
111
|
+
export const ERROR_DSN_KEY = 'SENTRY_DSN';
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Switch reporting on for this process. Unset DSN leaves core's no-op reporter in place, so a
|
|
115
|
+
* laptop and a CI run pay nothing and page nobody — and the release every event carries is the
|
|
116
|
+
* build id this boot already computed, never a second identity for the same deploy.
|
|
117
|
+
*/
|
|
118
|
+
export function configureReporting(env: Env, buildId: string): void {
|
|
119
|
+
const dsn = env[ERROR_DSN_KEY]?.trim();
|
|
120
|
+
configureErrorReporting({
|
|
121
|
+
release: buildId,
|
|
122
|
+
// A malformed DSN throws here, at boot, rather than at the first outage: a monitor that was
|
|
123
|
+
// never connected looks exactly like an app that never failed.
|
|
124
|
+
...(dsn === undefined || dsn.length === 0 ? {} : { reporter: sentryErrorReporter({ dsn }) }),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
70
128
|
export interface ServeOptions {
|
|
71
129
|
readonly root: string;
|
|
72
130
|
readonly env: Env;
|
|
@@ -76,6 +134,16 @@ export interface ServeOptions {
|
|
|
76
134
|
readonly port?: number;
|
|
77
135
|
/** Overrides `METRICS_PORT`, on the same terms. */
|
|
78
136
|
readonly metricsPort?: number;
|
|
137
|
+
/**
|
|
138
|
+
* The drivers this deployment supplies instead of the ones the environment would select.
|
|
139
|
+
*
|
|
140
|
+
* This field is why `apps/web/server.ts` can stay three lines and still run a custom queue, a
|
|
141
|
+
* shared ISR store or an app's own middleware. Before it there was nowhere to hand the framework
|
|
142
|
+
* a driver, so the only way was an ambient setter from an app module — which `loadApp` imports
|
|
143
|
+
* AFTER `startServices` has captured its own, giving a process that enqueues to one queue and
|
|
144
|
+
* claims from another. `startRoles` now refuses that split outright.
|
|
145
|
+
*/
|
|
146
|
+
readonly runtime?: RuntimeOverrides;
|
|
79
147
|
}
|
|
80
148
|
|
|
81
149
|
export interface ServedApp {
|
|
@@ -93,6 +161,8 @@ export interface MigratedApp {
|
|
|
93
161
|
readonly kind: 'migrated';
|
|
94
162
|
readonly role: 'migrate';
|
|
95
163
|
readonly report: MigrationReport;
|
|
164
|
+
/** The post-condition: the live schema against the ledger this run just wrote. */
|
|
165
|
+
readonly drift: DriftReport;
|
|
96
166
|
}
|
|
97
167
|
|
|
98
168
|
export type StartedApp = ServedApp | MigratedApp;
|
|
@@ -106,9 +176,16 @@ export type StartedApp = ServedApp | MigratedApp;
|
|
|
106
176
|
*
|
|
107
177
|
* It boots the queue, not the whole runtime: this role touches the database and nothing else, and
|
|
108
178
|
* `startQueue` is what installs `db()` for `migrate()` to find.
|
|
179
|
+
*
|
|
180
|
+
* The drift check is the post-condition, and it lives here rather than in `cmd-db.ts` for the same
|
|
181
|
+
* reason the migrator does: it needs the connection this function opened, and a developer and a
|
|
182
|
+
* release phase must not verify different things. It is **returned, never thrown** — the role's
|
|
183
|
+
* contract is "apply every migration, then exit", and a schema difference after a clean apply is a
|
|
184
|
+
* diagnostic, not a failed migration. `x db migrate` is where it is actionable, so `x db migrate`
|
|
185
|
+
* is what fails on it.
|
|
109
186
|
*/
|
|
110
187
|
export async function runMigrations(options: ServeOptions): Promise<MigratedApp> {
|
|
111
|
-
const queue = await startQueue(resolveServices(options.root, options.env));
|
|
188
|
+
const queue = await startQueue(resolveServices(options.root, options.env), options.runtime);
|
|
112
189
|
try {
|
|
113
190
|
const migrations = await readMigrations(options.root);
|
|
114
191
|
const report = await migrate({
|
|
@@ -122,12 +199,43 @@ export async function runMigrations(options: ServeOptions): Promise<MigratedApp>
|
|
|
122
199
|
available: migrations.length,
|
|
123
200
|
appVersion: report.appVersion,
|
|
124
201
|
});
|
|
125
|
-
|
|
202
|
+
const drift = await checkDrift({ migrations });
|
|
203
|
+
// Logged with the first difference, not just a count: a release phase's log is the only place
|
|
204
|
+
// an operator sees this, and "3 differences" names nothing to act on.
|
|
205
|
+
if (!drift.ok) {
|
|
206
|
+
logger.warn('ultimate migrate drift', {
|
|
207
|
+
differences: drift.differences.length,
|
|
208
|
+
cause: drift.differences[0]?.cause,
|
|
209
|
+
fix: drift.differences[0]?.fix,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return { kind: 'migrated', role: 'migrate', report, drift };
|
|
126
213
|
} finally {
|
|
127
214
|
await queue.stop();
|
|
128
215
|
}
|
|
129
216
|
}
|
|
130
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Release what a boot acquired before it failed, newest first.
|
|
220
|
+
*
|
|
221
|
+
* Every failure here is swallowed, because the step that refused to start is the one worth
|
|
222
|
+
* reporting — the same rule `startRoles`' own rollback runs by. Without it a throw between
|
|
223
|
+
* `startServices` and `startRoles` left the Postgres pool, the queue and the OTLP exporter running
|
|
224
|
+
* in a process whose caller has already given up: `x dev` and the container both retry the boot,
|
|
225
|
+
* and the second attempt met a `.x/pgdata` the first one still held.
|
|
226
|
+
*/
|
|
227
|
+
export async function releaseBoot(
|
|
228
|
+
acquired: readonly (() => void | Promise<void>)[],
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
for (const release of [...acquired].reverse()) {
|
|
231
|
+
try {
|
|
232
|
+
await release();
|
|
233
|
+
} catch {
|
|
234
|
+
// Deliberately empty: see above.
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
131
239
|
/**
|
|
132
240
|
* Boot order is `x dev`'s, for the reason `x dev` gives: services, then the app's own modules
|
|
133
241
|
* (importing them IS the registration), then the role that serves what they registered. The route
|
|
@@ -136,7 +244,29 @@ export async function runMigrations(options: ServeOptions): Promise<MigratedApp>
|
|
|
136
244
|
*/
|
|
137
245
|
export async function serveApp(options: ServeOptions): Promise<ServedApp> {
|
|
138
246
|
const role = options.role ?? roleFromEnv(options.env);
|
|
139
|
-
const runtime = await startServices(
|
|
247
|
+
const runtime = await startServices(
|
|
248
|
+
resolveServices(options.root, options.env),
|
|
249
|
+
options.env,
|
|
250
|
+
options.runtime,
|
|
251
|
+
);
|
|
252
|
+
// Everything acquired from here down, in order, so a throw anywhere below gives it all back.
|
|
253
|
+
const acquired: (() => void | Promise<void>)[] = [() => runtime.stop()];
|
|
254
|
+
try {
|
|
255
|
+
return await bootRoles({ options, role, runtime, acquired });
|
|
256
|
+
} catch (error) {
|
|
257
|
+
await releaseBoot(acquired);
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The half of `serveApp` whose every acquisition is registered for rollback. */
|
|
263
|
+
async function bootRoles(boot: {
|
|
264
|
+
readonly options: ServeOptions;
|
|
265
|
+
readonly role: Role;
|
|
266
|
+
readonly runtime: RunningServices;
|
|
267
|
+
readonly acquired: (() => void | Promise<void>)[];
|
|
268
|
+
}): Promise<ServedApp> {
|
|
269
|
+
const { options, role, runtime, acquired } = boot;
|
|
140
270
|
// Importing the app's modules IS the registration: every route, action and job below is
|
|
141
271
|
// whatever this call put in the registries.
|
|
142
272
|
await loadApp(options.root);
|
|
@@ -149,18 +279,44 @@ export async function serveApp(options: ServeOptions): Promise<ServedApp> {
|
|
|
149
279
|
stamped !== undefined && stamped.length > 0
|
|
150
280
|
? stamped
|
|
151
281
|
: (await appManifest(options.root)).manifest.buildId;
|
|
282
|
+
// Before the first socket opens: everything above this line fails loudly into the container's
|
|
283
|
+
// own logs, everything below it is a served request, a claimed job or a routed frame.
|
|
284
|
+
configureReporting(options.env, buildId);
|
|
285
|
+
// Beside error reporting, and for the same reason it is here: `OTEL_EXPORTER_OTLP_ENDPOINT` is
|
|
286
|
+
// in the shipped chart and nothing read it, so every deployment that configured a collector got
|
|
287
|
+
// an empty dashboard. `x dev` keeps its own recorder — the `/_x` timeline is a different sink
|
|
288
|
+
// with a different lifetime — so this is the production boot's alone (axiom 6).
|
|
289
|
+
const stopOtlp = startOtlpExport(options.env);
|
|
290
|
+
acquired.push(stopOtlp);
|
|
291
|
+
// Built at boot rather than shipped prebuilt, so the container serves the same chunks `x dev`
|
|
292
|
+
// does from the same source — the alternative is a second bundler invocation in the image build
|
|
293
|
+
// whose output nothing compares against the one the dev loop proved.
|
|
294
|
+
const islands = await buildIslands(options.root);
|
|
152
295
|
const routes: readonly Route[] = [
|
|
153
|
-
...
|
|
154
|
-
...assetRoutes({
|
|
155
|
-
|
|
296
|
+
...apiRoutes(),
|
|
297
|
+
...assetRoutes({
|
|
298
|
+
root: options.root,
|
|
299
|
+
storage: runtime.storage,
|
|
300
|
+
...(options.runtime?.images === undefined ? {} : { images: options.runtime.images }),
|
|
301
|
+
}),
|
|
302
|
+
...storageRoutes({ storage: runtime.storage }),
|
|
303
|
+
...islandRoutes(() => islands),
|
|
304
|
+
...appRoutes({
|
|
305
|
+
buildId,
|
|
306
|
+
resolveIsland: (file) => islands.resolverFor(file),
|
|
307
|
+
// Only when a store was supplied. `createIsrController` defaults to a per-process memory
|
|
308
|
+
// store, so twelve replicas hold twelve of them and a purge tag regenerates one twelfth of
|
|
309
|
+
// the fleet while the other eleven keep serving the page it just invalidated.
|
|
310
|
+
...(options.runtime?.isrStore === undefined
|
|
311
|
+
? {}
|
|
312
|
+
: { isr: createIsrController({ buildId, store: options.runtime.isrStore }) }),
|
|
313
|
+
}),
|
|
156
314
|
];
|
|
157
315
|
const port = options.port ?? portFromEnv(options.env);
|
|
158
316
|
// An in-process caller asking for an ephemeral app port is a test, and a test that grabbed the
|
|
159
317
|
// fixed 9090 would fail the next suite to boot beside it. An environment that names the port
|
|
160
318
|
// still wins — that is the deploy talking.
|
|
161
|
-
const metricsPort =
|
|
162
|
-
options.metricsPort ??
|
|
163
|
-
(port === 0 && options.env['METRICS_PORT'] === undefined ? 0 : metricsPortFromEnv(options.env));
|
|
319
|
+
const metricsPort = metricsPortFor(options.env, port, options.metricsPort);
|
|
164
320
|
const running = await startRoles({
|
|
165
321
|
roles: [role],
|
|
166
322
|
port,
|
|
@@ -169,8 +325,13 @@ export async function serveApp(options: ServeOptions): Promise<ServedApp> {
|
|
|
169
325
|
runtime,
|
|
170
326
|
routes,
|
|
171
327
|
env: options.env,
|
|
328
|
+
// Same declaration `x dev` reads. Without it a container answers a browser that opened a
|
|
329
|
+
// guarded page with the problem document, rendered as raw JSON in the viewport.
|
|
330
|
+
signInPath: await loadSignInPath(options.root),
|
|
172
331
|
http: CONTAINER_BINDING,
|
|
332
|
+
...(options.runtime === undefined ? {} : { overrides: options.runtime }),
|
|
173
333
|
});
|
|
334
|
+
acquired.push(() => running.stop());
|
|
174
335
|
return {
|
|
175
336
|
kind: 'served',
|
|
176
337
|
role,
|
|
@@ -181,6 +342,9 @@ export async function serveApp(options: ServeOptions): Promise<ServedApp> {
|
|
|
181
342
|
async stop() {
|
|
182
343
|
await running.stop();
|
|
183
344
|
await runtime.stop();
|
|
345
|
+
// Last: the exporters outlive the roles they were recording, so the drain's own spans and
|
|
346
|
+
// the final counter snapshot still have somewhere to go.
|
|
347
|
+
stopOtlp();
|
|
184
348
|
},
|
|
185
349
|
};
|
|
186
350
|
}
|
|
@@ -192,7 +356,15 @@ export async function serveApp(options: ServeOptions): Promise<ServedApp> {
|
|
|
192
356
|
*/
|
|
193
357
|
export async function runRole(options: ServeOptions): Promise<StartedApp> {
|
|
194
358
|
const role = options.role ?? roleFromEnv(options.env);
|
|
195
|
-
if (role === 'migrate')
|
|
359
|
+
if (role === 'migrate') {
|
|
360
|
+
const migrated = await runMigrations({ ...options, role });
|
|
361
|
+
// The release phase has one channel — the exit code — so drift is thrown here rather than
|
|
362
|
+
// returned. `x db migrate` calls the same `runMigrations` and renders every difference as a
|
|
363
|
+
// finding before exiting non-zero; a container that logged one and exited 0 would let the
|
|
364
|
+
// deploy roll on over a schema nobody can reconstruct, which is the failure drift exists for.
|
|
365
|
+
assertNoDrift(migrated.drift);
|
|
366
|
+
return migrated;
|
|
367
|
+
}
|
|
196
368
|
const app = await serveApp({ ...options, role });
|
|
197
369
|
logger.info('ultimate started', { role: app.role, url: app.url, buildId: app.buildId });
|
|
198
370
|
await holdUntilShutdown('serve', () => app.stop())();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// POSIX single-quoting for a value the CLI pastes into a line a reader runs — a `fix:`, a
|
|
2
|
+
// reproduce command. Its own module, and not `test-shards.ts` where it started, because the
|
|
3
|
+
// subprocess boundary needs it too and `test-shards.ts` imports `exec.ts`: one leaf both can
|
|
4
|
+
// reach is the alternative to an import cycle or a second quoter.
|
|
5
|
+
|
|
6
|
+
const SHELL_SAFE = /^[\w@%+=:,./-]+$/;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A program name, a `--filter` or a path holding a space, a `$` or a `;` pastes back as two
|
|
10
|
+
* arguments or as a second command, so an unquoted line runs something other than what it claims.
|
|
11
|
+
* `'\''` is the only escape a single-quoted string has. A shell-safe value is left alone, so the
|
|
12
|
+
* common case stays readable.
|
|
13
|
+
*/
|
|
14
|
+
export const quoteArg = (value: string): string =>
|
|
15
|
+
SHELL_SAFE.test(value) ? value : `'${value.split("'").join("'\\''")}'`;
|
package/src/source-files.ts
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
export const SOURCE_GLOBS = [
|
|
6
6
|
'packages/*/src/**/*.{ts,tsx}',
|
|
7
|
+
// Three packages carry an `e2e` directory beside `src`. It is shipped source by every rule that
|
|
8
|
+
// matters here — a 900-line file or an unrunnable `fix:` in one was invisible to `filesize` and
|
|
9
|
+
// `errors` alike, and `scripts/boundaries.ts` walked past it for the same reason.
|
|
10
|
+
'packages/*/e2e/**/*.{ts,tsx}',
|
|
7
11
|
'scripts/**/*.{ts,tsx}',
|
|
8
12
|
'site/**/*.{ts,tsx}',
|
|
9
13
|
'app/**/*.{ts,tsx}',
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// One repeated statement shape, as every surface renders it. The ledger next door decides that a
|
|
2
|
+
// shape repeated; `@ultimat3/entity` decides which fix it has earned; this file is the one
|
|
3
|
+
// projection all four surfaces read — so `x dev`'s findings, the `/_x` timeline, the browser
|
|
4
|
+
// overlay and the log line can never disagree about a loop's code, its cause or the line that ends it.
|
|
5
|
+
|
|
6
|
+
import type { StatementLoopFact } from '@ultimat3/admin/dev';
|
|
7
|
+
import { logger } from '@ultimat3/core';
|
|
8
|
+
import { nPlusOne } from '@ultimat3/entity';
|
|
9
|
+
import type { OverlayNotice } from '@ultimat3/http';
|
|
10
|
+
import type { RepeatedStatement } from './dev-n-plus-one';
|
|
11
|
+
import type { Finding } from './output';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The verdict, as an error — built here rather than in the ledger because the count keeps rising
|
|
15
|
+
* after a shape is promoted: a loop of fifty reads `ran 50 times` when a surface asks, not
|
|
16
|
+
* `ran 5 times` because that is where the threshold sat. `nPlusOne` is `@ultimat3/entity`'s, and
|
|
17
|
+
* deliberately: the `fix:` names `preload`, `insertAll` and `updateWhere`, which are that package's
|
|
18
|
+
* vocabulary and derived from the relations the schema already declared — a line composed here
|
|
19
|
+
* would be a second answer to "what ends this loop", one the schema never agreed to.
|
|
20
|
+
*/
|
|
21
|
+
export function loopFacts(repeat: RepeatedStatement): StatementLoopFact {
|
|
22
|
+
const attribution = repeat.attribution;
|
|
23
|
+
const error = nPlusOne({
|
|
24
|
+
kind: repeat.kind,
|
|
25
|
+
subject: repeat.fingerprint,
|
|
26
|
+
count: repeat.count,
|
|
27
|
+
entity: attribution?.entity,
|
|
28
|
+
op: attribution?.op,
|
|
29
|
+
});
|
|
30
|
+
return {
|
|
31
|
+
requestId: repeat.requestId,
|
|
32
|
+
code: error.code,
|
|
33
|
+
cause: error.cause,
|
|
34
|
+
fix: error.fix,
|
|
35
|
+
docs: error.docs ?? null,
|
|
36
|
+
subject: repeat.fingerprint,
|
|
37
|
+
count: repeat.count,
|
|
38
|
+
sample: repeat.sample,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The `x dev` half. `at` is the request id and not a file: a loop has no line to open — it is a
|
|
44
|
+
* page's worth of statements — and the id is what joins this finding to the timeline's own row and
|
|
45
|
+
* to the log line the same request emitted.
|
|
46
|
+
*/
|
|
47
|
+
export const loopFinding = (facts: StatementLoopFact): Finding => ({
|
|
48
|
+
code: facts.code,
|
|
49
|
+
cause: facts.cause,
|
|
50
|
+
fix: facts.fix,
|
|
51
|
+
at: facts.requestId,
|
|
52
|
+
...(facts.docs === null ? {} : { docs: facts.docs }),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** The browser half. `exactOptionalPropertyTypes`: an absent doc link is omitted, never `undefined`. */
|
|
56
|
+
export const loopNotice = (facts: StatementLoopFact): OverlayNotice => ({
|
|
57
|
+
code: facts.code,
|
|
58
|
+
cause: facts.cause,
|
|
59
|
+
fix: facts.fix,
|
|
60
|
+
...(facts.docs === null ? {} : { docs: facts.docs }),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The log half, emitted once per request per code by the ledger that counts.
|
|
65
|
+
*
|
|
66
|
+
* The root logger and not `ctx.logger`, because this runs inside the request's own ALS scope and
|
|
67
|
+
* core's `setLoggerContextFields` puts `requestId` and `traceId` on every line emitted there — so
|
|
68
|
+
* the ids ride along without this file reaching for a context it would then have to prove it had.
|
|
69
|
+
* One line, in the 3-line contract's own order, because a warning an agent has to reassemble from
|
|
70
|
+
* three log records is a warning it acts on in three passes.
|
|
71
|
+
*/
|
|
72
|
+
export const warnLoop = (facts: StatementLoopFact): void => {
|
|
73
|
+
logger.warn(`${facts.code}: ${facts.cause} — fix: ${facts.fix}`);
|
|
74
|
+
};
|
package/src/style-csp.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Every inline `<style>` body a served process can put in a document, as the `style-src` sources
|
|
2
|
+
// that admit it. Read from the stylesheet registry at boot rather than checked in as a constant:
|
|
3
|
+
// importing the app's modules IS what fills that registry, so a committed hash would describe a
|
|
4
|
+
// stylesheet the document no longer carries — and the CSP would block the framework's own CSS.
|
|
5
|
+
|
|
6
|
+
import { cspHashSource } from '@ultimat3/http';
|
|
7
|
+
import { SURFACES, stylesFor } from '@ultimat3/render';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Call AFTER `loadApp`. One hash per distinct body: `stylesFor` is what `dev-render.ts` puts in
|
|
11
|
+
* the tag, per surface, so hashing the same call is the only way the two cannot drift. `extra`
|
|
12
|
+
* carries the documents this package does not render — `/_x`'s shell — because the caller is what
|
|
13
|
+
* knows which of them it mounted.
|
|
14
|
+
*/
|
|
15
|
+
export function inlineStyleSources(extra: readonly string[] = []): readonly string[] {
|
|
16
|
+
const bodies = [...SURFACES.map((surface) => stylesFor(surface)), ...extra];
|
|
17
|
+
return [...new Set(bodies.filter((body) => body.length > 0).map(cspHashSource))].sort();
|
|
18
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The app's HTTP authenticator, seen as the `sync` node's. Until this file `sync-node.ts` was
|
|
2
|
+
// handed no `authenticate` by any host, so every socket the framework ever opened carried
|
|
3
|
+
// `actorId: null` — and the channel guard, the live-query gate, the presence entry and the
|
|
4
|
+
// per-tenant subscription cap all decided against an anonymous actor. Realtime was single-tenant
|
|
5
|
+
// by wiring, not by design.
|
|
6
|
+
|
|
7
|
+
import type { Actor } from '@ultimat3/core';
|
|
8
|
+
import type { HttpConfig } from '@ultimat3/http';
|
|
9
|
+
import {
|
|
10
|
+
configuredAuthenticator,
|
|
11
|
+
createRequestContext,
|
|
12
|
+
defineHttpConfig,
|
|
13
|
+
UltimateRequest,
|
|
14
|
+
} from '@ultimat3/http';
|
|
15
|
+
import type { SyncAuthenticator, SyncGrant } from '@ultimat3/realtime';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The upgrade request, dressed as the request an `Authenticator` reads.
|
|
19
|
+
*
|
|
20
|
+
* A websocket upgrade IS an HTTP request — same cookies, same `Authorization` header — so the
|
|
21
|
+
* app's one resolver answers it, and an app does not write a second identity for its sockets.
|
|
22
|
+
* The limiter is off because nothing in this config path serves a request: it exists so
|
|
23
|
+
* `ctx.config` is a real `HttpConfig`, and a rate limit resolved here would be a second, unread
|
|
24
|
+
* declaration of the app's own numbers.
|
|
25
|
+
*/
|
|
26
|
+
function upgradeConfig(buildId: string): HttpConfig {
|
|
27
|
+
return defineHttpConfig({ buildId, rateLimit: { enabled: false, scope: 'process' } });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What the sync node is given when the app configured an authenticator, and `undefined` when it
|
|
32
|
+
* did not — which keeps `x dev` anonymous and makes the node log that it is, exactly as
|
|
33
|
+
* `createSyncNode` documents. A stub that answered `{ actor: anonymous }` would look configured.
|
|
34
|
+
*
|
|
35
|
+
* The grant carries **no `expiresAt` and no `refresh`**, and that is the honest limit of this
|
|
36
|
+
* adapter rather than an omission: `configureAuthenticator()` resolves an `Actor` and says nothing
|
|
37
|
+
* about how long it stays true, so inventing a window here would either close live sockets that
|
|
38
|
+
* are still authorized or claim a lifetime the app never promised. A deployment whose credential
|
|
39
|
+
* has a real expiry passes `runtime.syncAuthenticate` and gets re-authorization; the timer for it
|
|
40
|
+
* already lives in `createSyncNode.start()`.
|
|
41
|
+
*/
|
|
42
|
+
export function syncAuthenticator(buildId: string): SyncAuthenticator | undefined {
|
|
43
|
+
const authenticate = configuredAuthenticator();
|
|
44
|
+
if (authenticate === undefined) return undefined;
|
|
45
|
+
// Once per node, not once per upgrade: resolving a config is pure and a 50k-socket node pays
|
|
46
|
+
// this per connection otherwise.
|
|
47
|
+
const config = upgradeConfig(buildId);
|
|
48
|
+
return async (request: Request): Promise<SyncGrant | null> => {
|
|
49
|
+
const ctx = createRequestContext({
|
|
50
|
+
url: new URL(request.url),
|
|
51
|
+
method: request.method,
|
|
52
|
+
role: 'sync',
|
|
53
|
+
config,
|
|
54
|
+
requestHeaders: request.headers,
|
|
55
|
+
});
|
|
56
|
+
const actor: Actor | null = await authenticate(new UltimateRequest(request, ctx), ctx);
|
|
57
|
+
return actor === null ? null : { actor };
|
|
58
|
+
};
|
|
59
|
+
}
|
package/src/templates/action.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import type { FeatureTarget } from './entity';
|
|
6
6
|
import type { GeneratedFile, NameSet } from './naming';
|
|
7
7
|
import { names } from './naming';
|
|
8
|
+
import { sliceFoundation } from './slice-foundation';
|
|
9
|
+
import { wrapImport } from './wrap';
|
|
8
10
|
|
|
9
11
|
const actionSource = (
|
|
10
12
|
name: NameSet,
|
|
@@ -18,7 +20,7 @@ import { action, t } from '@ultimat3/action';
|
|
|
18
20
|
// slice's own files and are shared by every action in it.
|
|
19
21
|
|
|
20
22
|
import { ${feature.pascal}NotFoundError } from '../errors';
|
|
21
|
-
|
|
23
|
+
${wrapImport([`can${feature.pascal}Write`, `${feature.camel}Tag`], '../policy')}
|
|
22
24
|
import * as repo from '../repo';
|
|
23
25
|
|
|
24
26
|
export const ${name.camel} = action({
|
|
@@ -28,7 +30,7 @@ export const ${name.camel} = action({
|
|
|
28
30
|
output: t.object({ id: t.uuid, title: t.string }),
|
|
29
31
|
policy: can${feature.pascal}Write,
|
|
30
32
|
cache: { invalidates: [${feature.camel}Tag] },
|
|
31
|
-
mcp: { expose: true, description: '${name.raw} —
|
|
33
|
+
mcp: { expose: true, description: '${name.raw} — edit this description' },
|
|
32
34
|
async handle({ input }) {
|
|
33
35
|
const row = await repo.byId(input.id);
|
|
34
36
|
if (row === undefined) throw new ${feature.pascal}NotFoundError({ id: input.id });
|
|
@@ -58,7 +60,7 @@ export const ${name.camel} = mutator({
|
|
|
58
60
|
input: t.object({ id: t.uuid, orgId: t.uuid, title: t.string }),
|
|
59
61
|
output: t.object({ id: t.uuid, title: t.string }),
|
|
60
62
|
policy: can${feature.pascal}Write,
|
|
61
|
-
mcp: { expose: true, description: '${name.raw} —
|
|
63
|
+
mcp: { expose: true, description: '${name.raw} — edit this description' },
|
|
62
64
|
// tx.table(name) rather than tx.${feature.plural}: the typed accessor exists only once the app
|
|
63
65
|
// augments LocalTables, and generated code cannot assume that has happened yet. The name is the
|
|
64
66
|
// entity's snake_case table, so the local twin and the server row live under one key.
|
|
@@ -77,25 +79,6 @@ export const ${name.camel} = mutator({
|
|
|
77
79
|
});
|
|
78
80
|
`;
|
|
79
81
|
|
|
80
|
-
const errorsSource = (
|
|
81
|
-
feature: NameSet,
|
|
82
|
-
): string => `// The ${feature.kebab} feature's X_* codes. Never throw a bare Error: an agent reading the failure
|
|
83
|
-
// needs the code, the cause and the exact command that fixes it.
|
|
84
|
-
|
|
85
|
-
import { UltimateError } from '@ultimat3/core';
|
|
86
|
-
|
|
87
|
-
export class ${feature.pascal}NotFoundError extends UltimateError {
|
|
88
|
-
constructor(input: { id: string }) {
|
|
89
|
-
super({
|
|
90
|
-
code: 'X_${feature.kebab.toUpperCase().split('-').join('_')}_NOT_FOUND',
|
|
91
|
-
cause: \`no ${feature.kebab} with id \${input.id}\`,
|
|
92
|
-
fix: 'x db studio to confirm the row exists, or pass an id from the list query',
|
|
93
|
-
docs: 'https://ultimate.dev/errors/X_NOT_FOUND',
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
`;
|
|
98
|
-
|
|
99
82
|
const ID = '00000000-0000-4000-8000-000000000001';
|
|
100
83
|
const ORG = '00000000-0000-4000-8000-000000000002';
|
|
101
84
|
const OTHER_ORG = '00000000-0000-4000-8000-000000000009';
|
|
@@ -121,7 +104,9 @@ const actionTest = (
|
|
|
121
104
|
name: NameSet,
|
|
122
105
|
feature: NameSet,
|
|
123
106
|
isMutator: boolean,
|
|
124
|
-
): string =>
|
|
107
|
+
): string => `// ${name.camel}: its declared shape, the input it refuses, the contract every action owes, and the
|
|
108
|
+
// foreign-org actor it denies before the handler runs. One declaration, every surface.
|
|
109
|
+
import { testActor } from '@ultimat3/policy';
|
|
125
110
|
import { contractTest, expect, unitTest } from '@ultimat3/testing';
|
|
126
111
|
import { ${name.camel} } from './${name.kebab}';
|
|
127
112
|
|
|
@@ -148,21 +133,21 @@ unitTest('${name.camel} rejects input that is not a uuid', async () => {
|
|
|
148
133
|
await expect(target.input).toAcceptInput(input);
|
|
149
134
|
});
|
|
150
135
|
|
|
151
|
-
contractTest('${name.camel} passes the
|
|
136
|
+
contractTest('${name.camel} passes the action contract', async () => {
|
|
152
137
|
// Three assertions the framework makes for any action, without knowing what this one does:
|
|
153
138
|
// garbage input is rejected, an anonymous actor is denied, and the operation reaches the
|
|
154
139
|
// OpenAPI document. \`.contract()\` is the projection; this loop just runs it.
|
|
155
140
|
for (const contract of target.contract()) await contract.run();
|
|
156
141
|
});
|
|
157
142
|
|
|
158
|
-
contractTest('${name.camel} denies a foreign org
|
|
143
|
+
contractTest('${name.camel} denies a foreign org', async () => {
|
|
159
144
|
// \`.as()\` is the one execution path with the actor swapped, so this denial is the same one
|
|
160
145
|
// HTTP, MCP and the job surface would produce — and no repo call happened to produce it.
|
|
161
146
|
const denied = await target.as(outsider, input).catch((error: unknown) => error);
|
|
162
147
|
expect(denied).toBeUltimateError('X_FORBIDDEN');
|
|
163
148
|
});
|
|
164
149
|
|
|
165
|
-
contractTest('${name.camel} projects one
|
|
150
|
+
contractTest('${name.camel} projects one tool and one operation', () => {
|
|
166
151
|
// Same policy object on both surfaces — an MCP call cannot reach a different authz path.
|
|
167
152
|
expect(target.tool().policy).toBe(target.policy);
|
|
168
153
|
expect(target.tool().description).not.toBe('');
|
|
@@ -180,14 +165,14 @@ export function actionFiles(rawName: string, target: ActionOptions): readonly Ge
|
|
|
180
165
|
const dir = `${target.surfaceDir}/${target.feature}/actions`;
|
|
181
166
|
const isMutator = target.mutator === true;
|
|
182
167
|
return [
|
|
168
|
+
// The three slice modules this action's source imports — `../errors`, `../policy`, `../repo`
|
|
169
|
+
// (which comes with `../entity`, its row type). Composed rather than assumed: `x g action`
|
|
170
|
+
// into a slice no `x g resource` had created emitted all three imports and wrote none of them.
|
|
171
|
+
...sliceFoundation(target, ['entity', 'policy', 'errors']),
|
|
183
172
|
{
|
|
184
173
|
path: `${dir}/${name.kebab}.ts`,
|
|
185
174
|
contents: isMutator ? mutatorSource(name, feature) : actionSource(name, feature),
|
|
186
175
|
},
|
|
187
176
|
{ path: `${dir}/${name.kebab}.test.ts`, contents: actionTest(name, feature, isMutator) },
|
|
188
|
-
{
|
|
189
|
-
path: `${target.surfaceDir}/${target.feature}/errors.ts`,
|
|
190
|
-
contents: errorsSource(feature),
|
|
191
|
-
},
|
|
192
177
|
];
|
|
193
178
|
}
|