@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.
Files changed (138) hide show
  1. package/CLAUDE.md +724 -0
  2. package/README.md +41 -9
  3. package/package.json +25 -23
  4. package/src/api-routes.ts +16 -0
  5. package/src/app-auth.ts +32 -0
  6. package/src/app-entities.ts +18 -0
  7. package/src/app-env.ts +103 -0
  8. package/src/app-load.ts +20 -3
  9. package/src/bin.ts +4 -3
  10. package/src/budgets.ts +114 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +215 -0
  13. package/src/cmd-db.ts +332 -155
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +87 -17
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +64 -9
  18. package/src/cmd-env.ts +95 -0
  19. package/src/cmd-errors.ts +33 -13
  20. package/src/cmd-fix.ts +5 -1
  21. package/src/cmd-generate.ts +146 -111
  22. package/src/cmd-help.ts +16 -5
  23. package/src/cmd-i18n.ts +2 -0
  24. package/src/cmd-jobs.ts +47 -33
  25. package/src/cmd-mcp.ts +11 -2
  26. package/src/cmd-new.ts +13 -7
  27. package/src/cmd-planned.ts +55 -10
  28. package/src/cmd-policy.ts +1 -0
  29. package/src/cmd-registries.ts +3 -0
  30. package/src/cmd-secrets.ts +368 -0
  31. package/src/cmd-tasks.ts +1 -0
  32. package/src/cmd-test.ts +17 -23
  33. package/src/cmd-verify.ts +177 -23
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +251 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +112 -0
  39. package/src/db-snapshot.ts +24 -0
  40. package/src/dev-assets.ts +86 -20
  41. package/src/dev-cache.ts +122 -0
  42. package/src/dev-dashboard.ts +19 -4
  43. package/src/dev-hooks.ts +27 -2
  44. package/src/dev-n-plus-one.ts +191 -0
  45. package/src/dev-queue.ts +105 -19
  46. package/src/dev-render.ts +158 -26
  47. package/src/dev-roles-fixture.ts +67 -0
  48. package/src/dev-roles.ts +186 -78
  49. package/src/dev-runtime.ts +117 -40
  50. package/src/dev-services.ts +15 -0
  51. package/src/dev-storage.ts +245 -0
  52. package/src/dev-sync.ts +107 -0
  53. package/src/dev-traces.ts +11 -3
  54. package/src/dispatch.ts +4 -2
  55. package/src/document-styles.ts +54 -0
  56. package/src/drift.ts +37 -9
  57. package/src/error-catalog.ts +7 -18
  58. package/src/error-codes.ts +186 -0
  59. package/src/error-contract.ts +29 -7
  60. package/src/error-fixes.ts +114 -0
  61. package/src/errors.ts +205 -140
  62. package/src/fix-command.ts +268 -0
  63. package/src/flag-number.ts +56 -0
  64. package/src/framework-scope.ts +49 -0
  65. package/src/generate-kinds.ts +97 -0
  66. package/src/guards.ts +186 -0
  67. package/src/index.ts +87 -14
  68. package/src/island-bundle.ts +166 -0
  69. package/src/island-routes.ts +50 -0
  70. package/src/jobs-driver.ts +33 -0
  71. package/src/jobs-json.ts +24 -0
  72. package/src/jobs-report.ts +17 -4
  73. package/src/mcp-db-target.ts +52 -27
  74. package/src/mcp-errors.ts +120 -19
  75. package/src/mcp-host.ts +44 -25
  76. package/src/messages.ts +81 -2
  77. package/src/metrics-endpoint.ts +73 -0
  78. package/src/migrations.ts +37 -4
  79. package/src/otlp-export.ts +64 -0
  80. package/src/output.ts +46 -16
  81. package/src/parse.ts +41 -3
  82. package/src/policy-facts.ts +38 -6
  83. package/src/policy-fixture.ts +14 -7
  84. package/src/prerender.ts +111 -2
  85. package/src/registry.ts +21 -3
  86. package/src/runtime-overrides.ts +66 -0
  87. package/src/safe-url-label.ts +24 -0
  88. package/src/scaffold-fixture.ts +10 -0
  89. package/src/scaffold-typecheck.ts +16 -38
  90. package/src/serve.ts +202 -18
  91. package/src/source-files.ts +4 -0
  92. package/src/statement-loop.ts +74 -0
  93. package/src/style-csp.ts +18 -0
  94. package/src/sync-authenticator.ts +59 -0
  95. package/src/templates/action.ts +15 -30
  96. package/src/templates/admin-page.ts +103 -0
  97. package/src/templates/admin.ts +11 -7
  98. package/src/templates/backfill.ts +212 -0
  99. package/src/templates/entity.ts +72 -31
  100. package/src/templates/guard.ts +143 -0
  101. package/src/templates/index.ts +12 -1
  102. package/src/templates/island.ts +67 -0
  103. package/src/templates/job.ts +53 -13
  104. package/src/templates/naming.ts +17 -1
  105. package/src/templates/policy.ts +35 -28
  106. package/src/templates/query.ts +24 -5
  107. package/src/templates/resource.ts +19 -11
  108. package/src/templates/route.ts +90 -15
  109. package/src/templates/scaffold-app.ts +142 -45
  110. package/src/templates/scaffold-claude-agents.ts +149 -0
  111. package/src/templates/scaffold-claude-commands.ts +221 -0
  112. package/src/templates/scaffold-claude.ts +134 -0
  113. package/src/templates/scaffold-container.ts +46 -2
  114. package/src/templates/scaffold-db-package.ts +91 -0
  115. package/src/templates/scaffold-docs.ts +24 -5
  116. package/src/templates/scaffold-domain-package.ts +90 -0
  117. package/src/templates/scaffold-env.ts +87 -0
  118. package/src/templates/scaffold-i18n.ts +4 -1
  119. package/src/templates/scaffold-mcp-package.ts +49 -0
  120. package/src/templates/scaffold-package-shape.ts +25 -4
  121. package/src/templates/scaffold-repo.ts +116 -257
  122. package/src/templates/scaffold-roles.ts +68 -0
  123. package/src/templates/scaffold-ui-package.ts +56 -0
  124. package/src/templates/slice-foundation.ts +88 -0
  125. package/src/templates/wrap.ts +95 -0
  126. package/src/test-counts.ts +35 -0
  127. package/src/test-select.ts +30 -15
  128. package/src/test-shards.ts +21 -3
  129. package/src/test-workers.ts +47 -0
  130. package/src/ts-scan.ts +271 -13
  131. package/src/tsconfig-references.ts +78 -0
  132. package/src/verify-floor.ts +133 -0
  133. package/src/verify-step.ts +19 -0
  134. package/src/verify-test-run.ts +72 -0
  135. package/src/verify-tests.ts +160 -71
  136. package/src/version-loader.ts +20 -3
  137. package/src/workspace-checks.ts +87 -16
  138. 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
+ }
@@ -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[] = FIXTURE_ENTITIES.flatMap((file) =>
99
- ["'c.title' is possibly 'undefined'.", "'c.price' is possibly 'undefined'."].map(
100
- (message): KnownGap => ({
101
- variant: 'x new',
102
- code: 'TS18048',
103
- file,
104
- message,
105
- owner: INVARIANT_PROXY,
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 { isRole, logger, ROLES } from '@ultimat3/core';
10
- import { type MigrationReport, migrate } from '@ultimat3/db';
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
- * Every PaaS injects `PORT` and routes traffic to exactly it. `Number.parseInt` would read `80abc`
44
- * as 80, so the whole string has to be a port — a partially-parsed port is a deploy that binds
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
- export function portFromEnv(env: Env): number {
48
- const raw = env['PORT'];
49
- if (raw === undefined || raw.trim().length === 0) return DEFAULT_PORT;
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
- return { kind: 'migrated', role: 'migrate', report };
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(resolveServices(options.root, options.env), options.env);
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
- ...listActions().map(toRoute),
138
- ...assetRoutes({ root: options.root, storage: runtime.storage }),
139
- ...appRoutes({ buildId }),
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: options.port ?? portFromEnv(options.env),
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') return runMigrations({ ...options, role });
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())();
@@ -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
+ };
@@ -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
+ }