@ultimat3/cli 1.1.0 → 2.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 +724 -0
- package/README.md +41 -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 +114 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +215 -0
- package/src/cmd-db.ts +332 -155
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +87 -17
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +64 -9
- 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 +13 -7
- 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 +17 -23
- package/src/cmd-verify.ts +177 -23
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +251 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +112 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +86 -20
- 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 +186 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +245 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +11 -3
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +37 -9
- package/src/error-catalog.ts +7 -18
- package/src/error-codes.ts +186 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +205 -140
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +56 -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 +87 -14
- 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 +120 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +81 -2
- package/src/metrics-endpoint.ts +73 -0
- 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 +202 -18
- 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 +21 -3
- package/src/test-workers.ts +47 -0
- package/src/ts-scan.ts +271 -13
- package/src/tsconfig-references.ts +78 -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
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The one place a host hands the framework a driver. `ServeOptions` had `{ root, env, role, port,
|
|
2
|
+
// metricsPort }` and nothing else, so the ONLY way to install a driver was to call an ambient
|
|
3
|
+
// setter from an app module — which `loadApp` imports *after* `startServices` has already captured
|
|
4
|
+
// its own. This type is the seam that removes the need to, and every field is read exactly once,
|
|
5
|
+
// as the first arm of `overrides?.x ?? <the env-selected default>`.
|
|
6
|
+
|
|
7
|
+
import type { PurgeDriver } from '@ultimat3/cache';
|
|
8
|
+
import type { Middleware, RateLimitStore } from '@ultimat3/http';
|
|
9
|
+
import type { JobDriver } from '@ultimat3/jobs';
|
|
10
|
+
import type { MailDriver } from '@ultimat3/mail';
|
|
11
|
+
import type { SyncAuthenticator, Transport } from '@ultimat3/realtime';
|
|
12
|
+
import type { IsrStore } from '@ultimat3/render';
|
|
13
|
+
import type { ImageTransformDriver } from '@ultimat3/seo';
|
|
14
|
+
import type { Storage } from '@ultimat3/storage';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What a deployment may substitute for a boot decision the environment would otherwise make.
|
|
18
|
+
*
|
|
19
|
+
* Every field is OPTIONAL and every field REPLACES the default rather than sitting beside it: the
|
|
20
|
+
* env switch each one used to be is now the `??` arm of one expression, so there is exactly one
|
|
21
|
+
* answer to "which driver is this process running" (axiom 1). A field nothing consumes is not
|
|
22
|
+
* here — the entity `Driver` in particular, because `@ultimat3/entity` exposes no installer for
|
|
23
|
+
* one (`database(entities, { driver })` is the app's own call), and a slot the boot cannot honour
|
|
24
|
+
* is the class of defect this seam exists to end.
|
|
25
|
+
*/
|
|
26
|
+
export interface RuntimeOverrides {
|
|
27
|
+
/**
|
|
28
|
+
* The queue every enqueue AND every claim uses. Installed as the ambient driver too, so
|
|
29
|
+
* `jobDriver()` and the worker cannot disagree — `startRoles` refuses to boot when they do.
|
|
30
|
+
*/
|
|
31
|
+
readonly jobs?: JobDriver;
|
|
32
|
+
/** Replaces the `S3_ENDPOINT`/embedded-disk decision whole: disks, default disk and all. */
|
|
33
|
+
readonly storage?: Storage;
|
|
34
|
+
/** Replaces the `SMTP_URL` / `RESEND_API_KEY` selection. */
|
|
35
|
+
readonly mail?: MailDriver;
|
|
36
|
+
/**
|
|
37
|
+
* Replaces the `NATS_URL` selection. Already connected when it arrives, and NOT closed by
|
|
38
|
+
* `stop()`: whoever built it owns its socket, exactly as `createServer` does not close a
|
|
39
|
+
* `rateLimitStore` it was handed.
|
|
40
|
+
*/
|
|
41
|
+
readonly transport?: Transport;
|
|
42
|
+
/** Replaces the `CLOUDFLARE_*` / `FASTLY_*` selection behind the `cdn` cache tier. */
|
|
43
|
+
readonly purge?: PurgeDriver;
|
|
44
|
+
/**
|
|
45
|
+
* Where the HTTP rate limiter keeps its counters. It also DECIDES `rateLimit.scope`: a store
|
|
46
|
+
* that says `'shared'` is a deployment declaring fleet-wide numbers, and `assertRateLimitScope`
|
|
47
|
+
* holds the two halves together rather than a literal in the boot contradicting the store.
|
|
48
|
+
*/
|
|
49
|
+
readonly rateLimitStore?: RateLimitStore;
|
|
50
|
+
/**
|
|
51
|
+
* Where regenerated ISR pages live. Omitted, `createIsrController` keeps a per-process memory
|
|
52
|
+
* store — twelve replicas then hold twelve of them, and a purge tag reaches one twelfth of the
|
|
53
|
+
* fleet.
|
|
54
|
+
*/
|
|
55
|
+
readonly isrStore?: IsrStore;
|
|
56
|
+
/** Prepended to the pipeline by `createServer`, which `startRoles` never passed one. */
|
|
57
|
+
readonly middleware?: readonly Middleware[];
|
|
58
|
+
/** The `/media/*` transform. Omitted, `builtinImageDriver` — core's PNG/JPEG pipeline. */
|
|
59
|
+
readonly images?: ImageTransformDriver;
|
|
60
|
+
/**
|
|
61
|
+
* Who is dialling the `sync` node. Omitted, the app's own `configureAuthenticator()` is adapted
|
|
62
|
+
* — this field exists because that adapter can only ever answer `{ actor }`, and a real
|
|
63
|
+
* deployment's token has an `expiresAt` and a `refresh`, which is the whole of re-authorization.
|
|
64
|
+
*/
|
|
65
|
+
readonly syncAuthenticate?: SyncAuthenticator;
|
|
66
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Single responsibility: a connection URL rendered so it can be printed, logged and scraped —
|
|
2
|
+
// scheme, host, path, and never the userinfo, query or fragment that carry the password.
|
|
3
|
+
// DATABASE_URL, NATS_URL and S3_ENDPOINT all reach `x dev --json` as raw strings, so the redaction
|
|
4
|
+
// has one implementation rather than one per caller that remembers.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `fallback` is the caller's, not this file's: a string that does not parse as a URL is a fact
|
|
8
|
+
* about that binding, and only the caller knows which binding it was. Never the raw string — an
|
|
9
|
+
* unparseable value is exactly where a hand-written credential ends up.
|
|
10
|
+
*/
|
|
11
|
+
export function safeUrlLabel(url: string, fallback: string): string {
|
|
12
|
+
let label: string;
|
|
13
|
+
try {
|
|
14
|
+
const parsed = new URL(url);
|
|
15
|
+
label = `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
|
|
16
|
+
} catch {
|
|
17
|
+
return fallback;
|
|
18
|
+
}
|
|
19
|
+
// A scheme with no `//` is parsed as ONE opaque path — `app:user:pw@host/db` keeps its whole
|
|
20
|
+
// credential in `pathname` and `username` is empty, so dropping the userinfo fields is not
|
|
21
|
+
// enough. Any surviving `@` is treated as userinfo we failed to split, and the fallback wins: a
|
|
22
|
+
// host printed with a `@` in it is a redaction that did not happen.
|
|
23
|
+
return label.includes('@') ? fallback : label;
|
|
24
|
+
}
|
package/src/scaffold-fixture.ts
CHANGED
|
@@ -28,9 +28,19 @@ export const FIXTURE_GENERATORS: readonly GenerateOptions[] = [
|
|
|
28
28
|
{ kind: 'query', name: 'invoice-search', feature: 'invoice' },
|
|
29
29
|
{ kind: 'query', name: 'invoice-feed', feature: 'invoice', live: true },
|
|
30
30
|
{ kind: 'job', name: 'sweep-invoices', feature: 'invoice' },
|
|
31
|
+
{ kind: 'backfill', name: 'reindex-invoices', feature: 'invoice' },
|
|
31
32
|
{ kind: 'task', name: 'nightly-sweep', feature: 'invoice' },
|
|
32
33
|
{ kind: 'route', name: 'pricing', surface: 'site' },
|
|
33
34
|
{ kind: 'route', name: 'billing', surface: 'app' },
|
|
35
|
+
// `--at`, pointed at the `site/` route above: an island's whole reason to exist is a 0kb page
|
|
36
|
+
// that needs one interactive control, so the fixture places it where that is true.
|
|
37
|
+
{ kind: 'island', name: 'currency-picker', at: 'apps/web/site/pricing' },
|
|
38
|
+
// The one screen the admin derives from nothing — and the one generator that must NOT emit a
|
|
39
|
+
// `defineRoute`, so compiling it is how that stays true.
|
|
40
|
+
{ kind: 'admin:page', name: 'reconcile', permission: 'ledger:reconcile' },
|
|
41
|
+
// The app's own convention. It is the only generated file that imports `@ultimat3/cli` for its
|
|
42
|
+
// types, so compiling it is what proves a scaffolded app can actually write one.
|
|
43
|
+
{ kind: 'guard', name: 'migration-safety' },
|
|
34
44
|
];
|
|
35
45
|
|
|
36
46
|
/** The whole scaffolded surface: a new app, then every generator run inside it. */
|
|
@@ -66,50 +66,28 @@ export interface KnownGap {
|
|
|
66
66
|
readonly owner: string;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
// `InvariantColumns` is an index-signature type, so `c.title` is `ColumnExpr | undefined` under
|
|
70
|
-
// `noUncheckedIndexedAccess`. Every hand-written entity in `examples/dummy` reproduces it
|
|
71
|
-
// identically: the fix is a column proxy typed from the entity's own columns, in @ultimat3/entity
|
|
72
|
-
// — a different template cannot avoid it without dropping to `satisfies()`, which would silently
|
|
73
|
-
// stop emitting the Postgres CHECK.
|
|
74
|
-
//
|
|
75
|
-
// Measured, not guessed: no open-keyed form avoids the `| undefined` (index signature, `Record`,
|
|
76
|
-
// and a mapped type over `string` or a template-literal pattern all produce it), and typing the
|
|
77
|
-
// proxy from `columns` only reaches `c` when the element of `invariants:` is itself
|
|
78
|
-
// context-sensitive. `invariant(name, build)` is a *call*, which TypeScript checks before
|
|
79
|
-
// `entity()`'s own `C` is fixed, so `K` falls back to `string` and nothing changes. Making it
|
|
80
|
-
// reach means changing the shape of `invariants:` — a documented primitive, so a major.
|
|
81
|
-
const INVARIANT_PROXY =
|
|
82
|
-
'@ultimat3/entity — type the invariant column proxy from the declared columns (needs a major: ' +
|
|
83
|
-
'the `invariants:` element shape has to become context-sensitive)';
|
|
84
|
-
|
|
85
|
-
/** Every entity the fixture generates. Each one declares the same two invariants. */
|
|
86
|
-
const FIXTURE_ENTITIES = [
|
|
87
|
-
'apps/web/app/credit-note/entity.ts',
|
|
88
|
-
'apps/web/app/invoice/entity.ts',
|
|
89
|
-
'apps/web/app/post/entity.ts',
|
|
90
|
-
] as const;
|
|
91
|
-
|
|
92
69
|
/**
|
|
93
70
|
* Diagnostics a template cannot fix, pinned one occurrence at a time. Pinned, never ignored:
|
|
94
71
|
* `unexpectedIn` fails on anything not listed here — including a second copy of a listed
|
|
95
72
|
* diagnostic, because each entry is consumed by exactly one match — and `staleGapsIn` fails when
|
|
96
73
|
* a listed entry stops reproducing, so an entry cannot outlive the bug it describes.
|
|
74
|
+
*
|
|
75
|
+
* Empty, and that is the point: `invariants:` is one callback over the whole list, so `c` is typed
|
|
76
|
+
* from the entity's own columns and the six `TS18048 'c.title' is possibly 'undefined'` pins this
|
|
77
|
+
* list used to carry are gone. A new entry here is a template shipping a diagnostic, which needs
|
|
78
|
+
* an owner who can remove it — not a permanent exemption.
|
|
97
79
|
*/
|
|
98
|
-
export const KNOWN_GAPS: readonly KnownGap[] =
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
/** The pins one invocation may spend. Another variant's pins are not its to spend. */
|
|
111
|
-
export const gapsFor = (variant: string): readonly KnownGap[] =>
|
|
112
|
-
KNOWN_GAPS.filter((gap) => gap.variant === variant);
|
|
80
|
+
export const KNOWN_GAPS: readonly KnownGap[] = [];
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The pins one invocation may spend. Another variant's pins are not its to spend. `gaps` is a
|
|
84
|
+
* parameter for the same reason it is on `unexpectedIn` and `staleGapsIn`: with `KNOWN_GAPS`
|
|
85
|
+
* legitimately empty, the bookkeeping is only testable against a list a test supplies.
|
|
86
|
+
*/
|
|
87
|
+
export const gapsFor = (
|
|
88
|
+
variant: string,
|
|
89
|
+
gaps: readonly KnownGap[] = KNOWN_GAPS,
|
|
90
|
+
): readonly KnownGap[] => gaps.filter((gap) => gap.variant === variant);
|
|
113
91
|
|
|
114
92
|
const matches = (diagnostic: TypeDiagnostic, gap: KnownGap): boolean =>
|
|
115
93
|
diagnostic.code === gap.code &&
|
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,9 +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';
|
|
42
|
+
import { DEFAULT_METRICS_PORT } from './metrics-endpoint';
|
|
25
43
|
import { readMigrations } from './migrations';
|
|
44
|
+
import { startOtlpExport } from './otlp-export';
|
|
45
|
+
import type { RuntimeOverrides } from './runtime-overrides';
|
|
26
46
|
|
|
27
47
|
export const DEFAULT_PORT = 3000;
|
|
28
48
|
|
|
@@ -40,19 +60,57 @@ export function roleFromEnv(env: Env): Role {
|
|
|
40
60
|
}
|
|
41
61
|
|
|
42
62
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* somewhere nobody asked for.
|
|
63
|
+
* `Number.parseInt` would read `80abc` as 80, so the whole string has to be a port — a
|
|
64
|
+
* partially-parsed port is a deploy that binds somewhere nobody asked for.
|
|
46
65
|
*/
|
|
47
|
-
|
|
48
|
-
const raw = env[
|
|
49
|
-
if (raw === undefined || raw.trim().length === 0) return
|
|
66
|
+
function portValue(env: Env, name: string, fallback: number): number {
|
|
67
|
+
const raw = env[name];
|
|
68
|
+
if (raw === undefined || raw.trim().length === 0) return fallback;
|
|
50
69
|
const port = Number(raw.trim());
|
|
51
70
|
if (!Number.isInteger(port) || port < 0 || port > 65_535)
|
|
52
|
-
throw new PortInvalidError({ value: raw });
|
|
71
|
+
throw new PortInvalidError({ value: raw, name });
|
|
53
72
|
return port;
|
|
54
73
|
}
|
|
55
74
|
|
|
75
|
+
/** Every PaaS injects `PORT` and routes traffic to exactly it. */
|
|
76
|
+
export function portFromEnv(env: Env): number {
|
|
77
|
+
return portValue(env, 'PORT', DEFAULT_PORT);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The scrape port, deliberately its own env var and not `PORT + n`: an operator who moves the app
|
|
82
|
+
* port must not silently move the port their Prometheus is configured against, and the roles that
|
|
83
|
+
* set no `PORT` at all — `worker`, `scheduler`, `replicator` — still need this one.
|
|
84
|
+
*/
|
|
85
|
+
export function metricsPortFromEnv(env: Env): number {
|
|
86
|
+
return portValue(env, 'METRICS_PORT', DEFAULT_METRICS_PORT);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The one env var that turns error monitoring on, and the only vendor-shaped name in the boot
|
|
91
|
+
* path. Not a platform primitive (axiom 7): the value is a URL to whatever the operator runs, the
|
|
92
|
+
* wire format behind it is documented and self-hostable, and `SENTRY_DSN` is what every monitor
|
|
93
|
+
* that speaks it already documents — inventing a second spelling would mean an operator's existing
|
|
94
|
+
* tooling sets a variable this framework ignores. Exactly the precedent
|
|
95
|
+
* `OTEL_EXPORTER_OTLP_ENDPOINT` already sets in `docker/helm/values.yaml`.
|
|
96
|
+
*/
|
|
97
|
+
export const ERROR_DSN_KEY = 'SENTRY_DSN';
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Switch reporting on for this process. Unset DSN leaves core's no-op reporter in place, so a
|
|
101
|
+
* laptop and a CI run pay nothing and page nobody — and the release every event carries is the
|
|
102
|
+
* build id this boot already computed, never a second identity for the same deploy.
|
|
103
|
+
*/
|
|
104
|
+
export function configureReporting(env: Env, buildId: string): void {
|
|
105
|
+
const dsn = env[ERROR_DSN_KEY]?.trim();
|
|
106
|
+
configureErrorReporting({
|
|
107
|
+
release: buildId,
|
|
108
|
+
// A malformed DSN throws here, at boot, rather than at the first outage: a monitor that was
|
|
109
|
+
// never connected looks exactly like an app that never failed.
|
|
110
|
+
...(dsn === undefined || dsn.length === 0 ? {} : { reporter: sentryErrorReporter({ dsn }) }),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
56
114
|
export interface ServeOptions {
|
|
57
115
|
readonly root: string;
|
|
58
116
|
readonly env: Env;
|
|
@@ -60,6 +118,18 @@ export interface ServeOptions {
|
|
|
60
118
|
readonly role?: Role;
|
|
61
119
|
/** Overrides `PORT`. 0 asks the kernel for an ephemeral one, which is what a test wants. */
|
|
62
120
|
readonly port?: number;
|
|
121
|
+
/** Overrides `METRICS_PORT`, on the same terms. */
|
|
122
|
+
readonly metricsPort?: number;
|
|
123
|
+
/**
|
|
124
|
+
* The drivers this deployment supplies instead of the ones the environment would select.
|
|
125
|
+
*
|
|
126
|
+
* This field is why `apps/web/server.ts` can stay three lines and still run a custom queue, a
|
|
127
|
+
* shared ISR store or an app's own middleware. Before it there was nowhere to hand the framework
|
|
128
|
+
* a driver, so the only way was an ambient setter from an app module — which `loadApp` imports
|
|
129
|
+
* AFTER `startServices` has captured its own, giving a process that enqueues to one queue and
|
|
130
|
+
* claims from another. `startRoles` now refuses that split outright.
|
|
131
|
+
*/
|
|
132
|
+
readonly runtime?: RuntimeOverrides;
|
|
63
133
|
}
|
|
64
134
|
|
|
65
135
|
export interface ServedApp {
|
|
@@ -77,6 +147,8 @@ export interface MigratedApp {
|
|
|
77
147
|
readonly kind: 'migrated';
|
|
78
148
|
readonly role: 'migrate';
|
|
79
149
|
readonly report: MigrationReport;
|
|
150
|
+
/** The post-condition: the live schema against the ledger this run just wrote. */
|
|
151
|
+
readonly drift: DriftReport;
|
|
80
152
|
}
|
|
81
153
|
|
|
82
154
|
export type StartedApp = ServedApp | MigratedApp;
|
|
@@ -90,9 +162,16 @@ export type StartedApp = ServedApp | MigratedApp;
|
|
|
90
162
|
*
|
|
91
163
|
* It boots the queue, not the whole runtime: this role touches the database and nothing else, and
|
|
92
164
|
* `startQueue` is what installs `db()` for `migrate()` to find.
|
|
165
|
+
*
|
|
166
|
+
* The drift check is the post-condition, and it lives here rather than in `cmd-db.ts` for the same
|
|
167
|
+
* reason the migrator does: it needs the connection this function opened, and a developer and a
|
|
168
|
+
* release phase must not verify different things. It is **returned, never thrown** — the role's
|
|
169
|
+
* contract is "apply every migration, then exit", and a schema difference after a clean apply is a
|
|
170
|
+
* diagnostic, not a failed migration. `x db migrate` is where it is actionable, so `x db migrate`
|
|
171
|
+
* is what fails on it.
|
|
93
172
|
*/
|
|
94
173
|
export async function runMigrations(options: ServeOptions): Promise<MigratedApp> {
|
|
95
|
-
const queue = await startQueue(resolveServices(options.root, options.env));
|
|
174
|
+
const queue = await startQueue(resolveServices(options.root, options.env), options.runtime);
|
|
96
175
|
try {
|
|
97
176
|
const migrations = await readMigrations(options.root);
|
|
98
177
|
const report = await migrate({
|
|
@@ -106,12 +185,43 @@ export async function runMigrations(options: ServeOptions): Promise<MigratedApp>
|
|
|
106
185
|
available: migrations.length,
|
|
107
186
|
appVersion: report.appVersion,
|
|
108
187
|
});
|
|
109
|
-
|
|
188
|
+
const drift = await checkDrift({ migrations });
|
|
189
|
+
// Logged with the first difference, not just a count: a release phase's log is the only place
|
|
190
|
+
// an operator sees this, and "3 differences" names nothing to act on.
|
|
191
|
+
if (!drift.ok) {
|
|
192
|
+
logger.warn('ultimate migrate drift', {
|
|
193
|
+
differences: drift.differences.length,
|
|
194
|
+
cause: drift.differences[0]?.cause,
|
|
195
|
+
fix: drift.differences[0]?.fix,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return { kind: 'migrated', role: 'migrate', report, drift };
|
|
110
199
|
} finally {
|
|
111
200
|
await queue.stop();
|
|
112
201
|
}
|
|
113
202
|
}
|
|
114
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Release what a boot acquired before it failed, newest first.
|
|
206
|
+
*
|
|
207
|
+
* Every failure here is swallowed, because the step that refused to start is the one worth
|
|
208
|
+
* reporting — the same rule `startRoles`' own rollback runs by. Without it a throw between
|
|
209
|
+
* `startServices` and `startRoles` left the Postgres pool, the queue and the OTLP exporter running
|
|
210
|
+
* in a process whose caller has already given up: `x dev` and the container both retry the boot,
|
|
211
|
+
* and the second attempt met a `.x/pgdata` the first one still held.
|
|
212
|
+
*/
|
|
213
|
+
export async function releaseBoot(
|
|
214
|
+
acquired: readonly (() => void | Promise<void>)[],
|
|
215
|
+
): Promise<void> {
|
|
216
|
+
for (const release of [...acquired].reverse()) {
|
|
217
|
+
try {
|
|
218
|
+
await release();
|
|
219
|
+
} catch {
|
|
220
|
+
// Deliberately empty: see above.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
115
225
|
/**
|
|
116
226
|
* Boot order is `x dev`'s, for the reason `x dev` gives: services, then the app's own modules
|
|
117
227
|
* (importing them IS the registration), then the role that serves what they registered. The route
|
|
@@ -120,7 +230,29 @@ export async function runMigrations(options: ServeOptions): Promise<MigratedApp>
|
|
|
120
230
|
*/
|
|
121
231
|
export async function serveApp(options: ServeOptions): Promise<ServedApp> {
|
|
122
232
|
const role = options.role ?? roleFromEnv(options.env);
|
|
123
|
-
const runtime = await startServices(
|
|
233
|
+
const runtime = await startServices(
|
|
234
|
+
resolveServices(options.root, options.env),
|
|
235
|
+
options.env,
|
|
236
|
+
options.runtime,
|
|
237
|
+
);
|
|
238
|
+
// Everything acquired from here down, in order, so a throw anywhere below gives it all back.
|
|
239
|
+
const acquired: (() => void | Promise<void>)[] = [() => runtime.stop()];
|
|
240
|
+
try {
|
|
241
|
+
return await bootRoles({ options, role, runtime, acquired });
|
|
242
|
+
} catch (error) {
|
|
243
|
+
await releaseBoot(acquired);
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The half of `serveApp` whose every acquisition is registered for rollback. */
|
|
249
|
+
async function bootRoles(boot: {
|
|
250
|
+
readonly options: ServeOptions;
|
|
251
|
+
readonly role: Role;
|
|
252
|
+
readonly runtime: RunningServices;
|
|
253
|
+
readonly acquired: (() => void | Promise<void>)[];
|
|
254
|
+
}): Promise<ServedApp> {
|
|
255
|
+
const { options, role, runtime, acquired } = boot;
|
|
124
256
|
// Importing the app's modules IS the registration: every route, action and job below is
|
|
125
257
|
// whatever this call put in the registries.
|
|
126
258
|
await loadApp(options.root);
|
|
@@ -133,20 +265,61 @@ export async function serveApp(options: ServeOptions): Promise<ServedApp> {
|
|
|
133
265
|
stamped !== undefined && stamped.length > 0
|
|
134
266
|
? stamped
|
|
135
267
|
: (await appManifest(options.root)).manifest.buildId;
|
|
268
|
+
// Before the first socket opens: everything above this line fails loudly into the container's
|
|
269
|
+
// own logs, everything below it is a served request, a claimed job or a routed frame.
|
|
270
|
+
configureReporting(options.env, buildId);
|
|
271
|
+
// Beside error reporting, and for the same reason it is here: `OTEL_EXPORTER_OTLP_ENDPOINT` is
|
|
272
|
+
// in the shipped chart and nothing read it, so every deployment that configured a collector got
|
|
273
|
+
// an empty dashboard. `x dev` keeps its own recorder — the `/_x` timeline is a different sink
|
|
274
|
+
// with a different lifetime — so this is the production boot's alone (axiom 6).
|
|
275
|
+
const stopOtlp = startOtlpExport(options.env);
|
|
276
|
+
acquired.push(stopOtlp);
|
|
277
|
+
// Built at boot rather than shipped prebuilt, so the container serves the same chunks `x dev`
|
|
278
|
+
// does from the same source — the alternative is a second bundler invocation in the image build
|
|
279
|
+
// whose output nothing compares against the one the dev loop proved.
|
|
280
|
+
const islands = await buildIslands(options.root);
|
|
136
281
|
const routes: readonly Route[] = [
|
|
137
|
-
...
|
|
138
|
-
...assetRoutes({
|
|
139
|
-
|
|
282
|
+
...apiRoutes(),
|
|
283
|
+
...assetRoutes({
|
|
284
|
+
root: options.root,
|
|
285
|
+
storage: runtime.storage,
|
|
286
|
+
...(options.runtime?.images === undefined ? {} : { images: options.runtime.images }),
|
|
287
|
+
}),
|
|
288
|
+
...storageRoutes({ storage: runtime.storage }),
|
|
289
|
+
...islandRoutes(() => islands),
|
|
290
|
+
...appRoutes({
|
|
291
|
+
buildId,
|
|
292
|
+
resolveIsland: (file) => islands.resolverFor(file),
|
|
293
|
+
// Only when a store was supplied. `createIsrController` defaults to a per-process memory
|
|
294
|
+
// store, so twelve replicas hold twelve of them and a purge tag regenerates one twelfth of
|
|
295
|
+
// the fleet while the other eleven keep serving the page it just invalidated.
|
|
296
|
+
...(options.runtime?.isrStore === undefined
|
|
297
|
+
? {}
|
|
298
|
+
: { isr: createIsrController({ buildId, store: options.runtime.isrStore }) }),
|
|
299
|
+
}),
|
|
140
300
|
];
|
|
301
|
+
const port = options.port ?? portFromEnv(options.env);
|
|
302
|
+
// An in-process caller asking for an ephemeral app port is a test, and a test that grabbed the
|
|
303
|
+
// fixed 9090 would fail the next suite to boot beside it. An environment that names the port
|
|
304
|
+
// still wins — that is the deploy talking.
|
|
305
|
+
const metricsPort =
|
|
306
|
+
options.metricsPort ??
|
|
307
|
+
(port === 0 && options.env['METRICS_PORT'] === undefined ? 0 : metricsPortFromEnv(options.env));
|
|
141
308
|
const running = await startRoles({
|
|
142
309
|
roles: [role],
|
|
143
|
-
port
|
|
310
|
+
port,
|
|
311
|
+
metricsPort,
|
|
144
312
|
buildId,
|
|
145
313
|
runtime,
|
|
146
314
|
routes,
|
|
147
315
|
env: options.env,
|
|
316
|
+
// Same declaration `x dev` reads. Without it a container answers a browser that opened a
|
|
317
|
+
// guarded page with the problem document, rendered as raw JSON in the viewport.
|
|
318
|
+
signInPath: await loadSignInPath(options.root),
|
|
148
319
|
http: CONTAINER_BINDING,
|
|
320
|
+
...(options.runtime === undefined ? {} : { overrides: options.runtime }),
|
|
149
321
|
});
|
|
322
|
+
acquired.push(() => running.stop());
|
|
150
323
|
return {
|
|
151
324
|
kind: 'served',
|
|
152
325
|
role,
|
|
@@ -157,6 +330,9 @@ export async function serveApp(options: ServeOptions): Promise<ServedApp> {
|
|
|
157
330
|
async stop() {
|
|
158
331
|
await running.stop();
|
|
159
332
|
await runtime.stop();
|
|
333
|
+
// Last: the exporters outlive the roles they were recording, so the drain's own spans and
|
|
334
|
+
// the final counter snapshot still have somewhere to go.
|
|
335
|
+
stopOtlp();
|
|
160
336
|
},
|
|
161
337
|
};
|
|
162
338
|
}
|
|
@@ -168,7 +344,15 @@ export async function serveApp(options: ServeOptions): Promise<ServedApp> {
|
|
|
168
344
|
*/
|
|
169
345
|
export async function runRole(options: ServeOptions): Promise<StartedApp> {
|
|
170
346
|
const role = options.role ?? roleFromEnv(options.env);
|
|
171
|
-
if (role === 'migrate')
|
|
347
|
+
if (role === 'migrate') {
|
|
348
|
+
const migrated = await runMigrations({ ...options, role });
|
|
349
|
+
// The release phase has one channel — the exit code — so drift is thrown here rather than
|
|
350
|
+
// returned. `x db migrate` calls the same `runMigrations` and renders every difference as a
|
|
351
|
+
// finding before exiting non-zero; a container that logged one and exited 0 would let the
|
|
352
|
+
// deploy roll on over a schema nobody can reconstruct, which is the failure drift exists for.
|
|
353
|
+
assertNoDrift(migrated.drift);
|
|
354
|
+
return migrated;
|
|
355
|
+
}
|
|
172
356
|
const app = await serveApp({ ...options, role });
|
|
173
357
|
logger.info('ultimate started', { role: app.role, url: app.url, buildId: app.buildId });
|
|
174
358
|
await holdUntilShutdown('serve', () => app.stop())();
|
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
|
+
}
|