@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.
Files changed (141) hide show
  1. package/CLAUDE.md +761 -0
  2. package/README.md +42 -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 +134 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +219 -0
  13. package/src/cmd-db.ts +458 -153
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +92 -18
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +74 -10
  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 +14 -8
  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 +29 -24
  33. package/src/cmd-verify.ts +197 -25
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +269 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +144 -0
  39. package/src/db-seed.ts +294 -0
  40. package/src/db-snapshot.ts +24 -0
  41. package/src/dev-assets.ts +108 -23
  42. package/src/dev-cache.ts +122 -0
  43. package/src/dev-dashboard.ts +19 -4
  44. package/src/dev-hooks.ts +27 -2
  45. package/src/dev-n-plus-one.ts +191 -0
  46. package/src/dev-queue.ts +105 -19
  47. package/src/dev-render.ts +158 -26
  48. package/src/dev-roles-fixture.ts +67 -0
  49. package/src/dev-roles.ts +167 -78
  50. package/src/dev-runtime.ts +117 -40
  51. package/src/dev-services.ts +15 -0
  52. package/src/dev-storage.ts +247 -0
  53. package/src/dev-sync.ts +107 -0
  54. package/src/dev-traces.ts +37 -7
  55. package/src/dispatch.ts +4 -2
  56. package/src/document-styles.ts +54 -0
  57. package/src/drift.ts +78 -10
  58. package/src/error-catalog.ts +8 -18
  59. package/src/error-codes.ts +192 -0
  60. package/src/error-contract.ts +29 -7
  61. package/src/error-fixes.ts +114 -0
  62. package/src/errors.ts +201 -138
  63. package/src/exec.ts +42 -8
  64. package/src/fix-command.ts +268 -0
  65. package/src/flag-number.ts +67 -0
  66. package/src/framework-scope.ts +49 -0
  67. package/src/generate-kinds.ts +97 -0
  68. package/src/guards.ts +186 -0
  69. package/src/index.ts +92 -15
  70. package/src/island-bundle.ts +166 -0
  71. package/src/island-routes.ts +50 -0
  72. package/src/jobs-driver.ts +33 -0
  73. package/src/jobs-json.ts +24 -0
  74. package/src/jobs-report.ts +17 -4
  75. package/src/mcp-db-target.ts +52 -27
  76. package/src/mcp-errors.ts +128 -19
  77. package/src/mcp-host.ts +44 -25
  78. package/src/messages.ts +93 -2
  79. package/src/metrics-endpoint.ts +64 -16
  80. package/src/migrations.ts +37 -4
  81. package/src/otlp-export.ts +64 -0
  82. package/src/output.ts +46 -16
  83. package/src/parse.ts +41 -3
  84. package/src/policy-facts.ts +38 -6
  85. package/src/policy-fixture.ts +14 -7
  86. package/src/prerender.ts +111 -2
  87. package/src/registry.ts +21 -3
  88. package/src/runtime-overrides.ts +66 -0
  89. package/src/safe-url-label.ts +24 -0
  90. package/src/scaffold-fixture.ts +10 -0
  91. package/src/scaffold-typecheck.ts +16 -38
  92. package/src/serve.ts +185 -13
  93. package/src/shell-quote.ts +15 -0
  94. package/src/source-files.ts +4 -0
  95. package/src/statement-loop.ts +74 -0
  96. package/src/style-csp.ts +18 -0
  97. package/src/sync-authenticator.ts +59 -0
  98. package/src/templates/action.ts +15 -30
  99. package/src/templates/admin-page.ts +103 -0
  100. package/src/templates/admin.ts +11 -7
  101. package/src/templates/backfill.ts +212 -0
  102. package/src/templates/entity.ts +72 -31
  103. package/src/templates/guard.ts +143 -0
  104. package/src/templates/index.ts +12 -1
  105. package/src/templates/island.ts +67 -0
  106. package/src/templates/job.ts +53 -13
  107. package/src/templates/naming.ts +17 -1
  108. package/src/templates/policy.ts +35 -28
  109. package/src/templates/query.ts +24 -5
  110. package/src/templates/resource.ts +19 -11
  111. package/src/templates/route.ts +90 -15
  112. package/src/templates/scaffold-app.ts +142 -45
  113. package/src/templates/scaffold-claude-agents.ts +149 -0
  114. package/src/templates/scaffold-claude-commands.ts +221 -0
  115. package/src/templates/scaffold-claude.ts +134 -0
  116. package/src/templates/scaffold-container.ts +46 -2
  117. package/src/templates/scaffold-db-package.ts +91 -0
  118. package/src/templates/scaffold-docs.ts +24 -5
  119. package/src/templates/scaffold-domain-package.ts +90 -0
  120. package/src/templates/scaffold-env.ts +87 -0
  121. package/src/templates/scaffold-i18n.ts +4 -1
  122. package/src/templates/scaffold-mcp-package.ts +49 -0
  123. package/src/templates/scaffold-package-shape.ts +25 -4
  124. package/src/templates/scaffold-repo.ts +116 -257
  125. package/src/templates/scaffold-roles.ts +68 -0
  126. package/src/templates/scaffold-ui-package.ts +56 -0
  127. package/src/templates/slice-foundation.ts +88 -0
  128. package/src/templates/wrap.ts +95 -0
  129. package/src/test-counts.ts +35 -0
  130. package/src/test-select.ts +30 -15
  131. package/src/test-shards.ts +20 -11
  132. package/src/test-workers.ts +50 -0
  133. package/src/ts-scan.ts +284 -15
  134. package/src/tsconfig-references.ts +103 -0
  135. package/src/verify-floor.ts +133 -0
  136. package/src/verify-step.ts +19 -0
  137. package/src/verify-test-run.ts +72 -0
  138. package/src/verify-tests.ts +160 -71
  139. package/src/version-loader.ts +20 -3
  140. package/src/workspace-checks.ts +87 -16
  141. package/src/write-line.ts +34 -0
@@ -0,0 +1,247 @@
1
+ // Single responsibility: the one HTTP surface that SERVES a stored object. `@ultimat3/storage`
2
+ // owns keys, bytes and the tenant boundary and owns no `Response`; `@ultimat3/policy` owns the
3
+ // one authz decision; this file is where those two meet a `Route` — the same shape `dev-assets.ts`
4
+ // uses for `/icons` and `/media`, so `x dev` and `apps/web/server.ts` mount one read path, not two.
5
+ //
6
+ // The base path is `@ultimat3/storage`'s `DEFAULT_SIGNED_URL_BASE`, imported and never restated:
7
+ // `localDriver` SIGNS `/_storage/<disk>/<key>`, so a local `'/_storage'` here is a second statement
8
+ // of one constant — and a signer and a reader that disagree serve 404 for every signed URL.
9
+
10
+ import { actorOf } from '@ultimat3/action';
11
+ import type { Actor } from '@ultimat3/core';
12
+ import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
13
+ import { asCtx, unauthenticated } from '@ultimat3/http';
14
+ import type { KnownPermission } from '@ultimat3/policy';
15
+ import { can, codeOf, evaluate, forbidden, reasonOf } from '@ultimat3/policy';
16
+ import type { Storage, StorageRead } from '@ultimat3/storage';
17
+ import {
18
+ assertSafeKey,
19
+ DEFAULT_SIGNED_URL_BASE,
20
+ isTenantScoped,
21
+ isWithinOrg,
22
+ objectNotFound,
23
+ orgMismatch,
24
+ } from '@ultimat3/storage';
25
+
26
+ /**
27
+ * The one capability that gates reading a stored object, on every disk. A permission and not a
28
+ * per-disk family: `disk` is in the policy's `input`, so an app that wants a per-disk rule writes
29
+ * one predicate over it, while a second permission string would be a second thing to grant and to
30
+ * forget. An app that declared a permission set without it gets `X_PERMISSION_UNKNOWN` from
31
+ * `can()` — naming the exact `definePermissions` edit — rather than a request that quietly worked.
32
+ */
33
+ export const STORAGE_READ_PERMISSION = 'storage:read';
34
+
35
+ /**
36
+ * `can()` takes a `KnownPermission`, which narrows to the APP's declared set the moment an app
37
+ * augments `PermissionRegistry` — and this package compiles against no app, so the bare literal is
38
+ * a type error inside a generated project (`scaffold-typecheck` is what proves it). The check that
39
+ * decides is the runtime one anyway: `can()` calls `assertPermission`, which throws
40
+ * `X_PERMISSION_UNKNOWN` naming the `definePermissions` edit. `dev-hooks.ts` narrows a route's
41
+ * structurally-typed permission for the same reason.
42
+ */
43
+ const READ_PERMISSION = STORAGE_READ_PERMISSION as unknown as KnownPermission;
44
+
45
+ /**
46
+ * The cache posture of a response that was authorized for ONE actor, named once so the two routes
47
+ * that serve stored bytes cannot declare different ones — which they did: `/media` answered
48
+ * `public, max-age=31536000, immutable` for the same object this route marks private, so a CDN held
49
+ * one tenant's file under a public key for a year. Revalidation costs a request and no bytes, which
50
+ * is the trade an authorized response wants: the bytes never change under a key, but the actor's
51
+ * permission to read them can be revoked. `vary` names the two headers that carry an identity.
52
+ */
53
+ export const AUTHORIZED_OBJECT_CACHE: CacheHint = {
54
+ mode: 'private',
55
+ maxAgeSeconds: 0,
56
+ vary: ['authorization', 'cookie'],
57
+ };
58
+
59
+ /** What the rule decides about. The key IS the object's identity, so this is the whole subject. */
60
+ export interface StorageReadInput {
61
+ readonly disk: string;
62
+ readonly key: string;
63
+ }
64
+
65
+ /**
66
+ * The single door. `evaluate()` is the framework's one authz entry point and this is a plain call
67
+ * to it — no inline permission test, no per-surface args type, no "public unless configured".
68
+ *
69
+ * Evaluated with NO row, deliberately. An object's owner is encoded in its key (`org/<id>/<entity>/
70
+ * <id>/<field>/…`), which `input` already carries, so a `StorageObject` would hand a predicate
71
+ * nothing it cannot already read — and loading one first would decide "does it exist" before
72
+ * "may you read it", which is how a caller learns another tenant's keys by watching the status.
73
+ */
74
+ export function authorizeStorageRead(input: StorageReadInput, ctx: RequestContext): void {
75
+ const context = asCtx(ctx);
76
+ // Built per request, not at mount: an app whose permission set lacks `storage:read` must get a
77
+ // problem document on this route, not a boot that takes every other route down with it.
78
+ const policy = can<StorageReadInput>(READ_PERMISSION);
79
+ const evaluation = evaluate(policy, { input, actor: actorOf(context) });
80
+ if (evaluation.allowed) return;
81
+ // The decision's own code goes on the wire, never a flattened one: `can()` denies "nobody" with
82
+ // X_UNAUTHENTICATED and a known actor with X_FORBIDDEN, and 401 and 403 are different
83
+ // instructions — "log in" against "you may not". `reason` is the deciding clause's own words,
84
+ // which `@ultimat3/policy` guarantees are safe to log.
85
+ const reason = reasonOf(evaluation.decision) ?? 'denied';
86
+ throw codeOf(evaluation.decision) === 'X_UNAUTHENTICATED'
87
+ ? unauthenticated(ctx.url.pathname)
88
+ : forbidden(policy.label, reason);
89
+ }
90
+
91
+ /**
92
+ * The key half of the read decision, in the order that discloses least: a key that could escape its
93
+ * prefix is refused before any tenant is named, and a key inside another tenant's prefix is 404
94
+ * (never 403 — `error-map.ts` maps `X_STORAGE_ORG_MISMATCH` there so a refusal cannot confirm that
95
+ * a key exists).
96
+ *
97
+ * Split out of `readStorageObject` because `/media/*key` (`dev-assets.ts`) has to make the same
98
+ * decision and made none at all: it passed a client-supplied key straight to `disk().get`, so every
99
+ * object on the app's only disk was one unauthenticated URL away. A second copy of this test is how
100
+ * one of the two surfaces would drift back — `storage-surfaces.test.ts` is what holds them level.
101
+ */
102
+ export function assertReadableKey(key: string, actor: Actor): string {
103
+ const safe = assertSafeKey(key);
104
+ // An actor with no org is inside no org, so every tenant-scoped key is somebody else's. Checked
105
+ // before `isWithinOrg`, which reads an empty org as a malformed key and would blame the caller's
106
+ // URL for the actor's missing claim.
107
+ const orgId = actor.orgId ?? '';
108
+ if (isTenantScoped(safe) && (orgId === '' || !isWithinOrg(safe, orgId))) {
109
+ throw orgMismatch(safe, orgId);
110
+ }
111
+ return safe;
112
+ }
113
+
114
+ /**
115
+ * Everything between the decision and the bytes. An unknown disk is the same 404 the foreign-tenant
116
+ * case answers, rather than `X_STORAGE_DISK_UNKNOWN`, whose cause lists every configured disk name.
117
+ */
118
+ export async function readStorageObject(
119
+ storage: Storage,
120
+ input: StorageReadInput,
121
+ actor: Actor,
122
+ ): Promise<StorageRead> {
123
+ const key = assertReadableKey(input.key, actor);
124
+ if (!storage.diskNames.includes(input.disk)) throw objectNotFound(input.disk, key);
125
+ return storage.disk(input.disk).get(key);
126
+ }
127
+
128
+ /** Inclusive, as `Range` and `Content-Range` both are. */
129
+ export interface ByteRange {
130
+ readonly start: number;
131
+ readonly end: number;
132
+ }
133
+
134
+ const UNSATISFIABLE = 'unsatisfiable';
135
+
136
+ /**
137
+ * One range or none. A multi-range or malformed header is ignored rather than refused — RFC 9110
138
+ * lets a server answer the whole representation, and `multipart/byteranges` is a body format no
139
+ * caller of this route asks for. Ranges exist here for one reason: Safari will not play a `<video>`
140
+ * from a source that answers 200 to a `Range` probe, so an uploaded video would be a dead player.
141
+ */
142
+ export function parseByteRange(
143
+ header: string | null,
144
+ size: number,
145
+ ): ByteRange | typeof UNSATISFIABLE | undefined {
146
+ if (header === null) return undefined;
147
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
148
+ if (match === null) return undefined;
149
+ const from = match[1] ?? '';
150
+ const to = match[2] ?? '';
151
+ if (from === '' && to === '') return undefined;
152
+ if (from === '') {
153
+ const wanted = Number(to);
154
+ // A suffix longer than the object is the whole object, not a refusal.
155
+ return wanted === 0 ? UNSATISFIABLE : { start: Math.max(size - wanted, 0), end: size - 1 };
156
+ }
157
+ const start = Number(from);
158
+ const end = to === '' ? size - 1 : Math.min(Number(to), size - 1);
159
+ return start >= size || end < start ? UNSATISFIABLE : { start, end };
160
+ }
161
+
162
+ /** `*`, a weak validator and a comma list all count — the etag is what has to match. */
163
+ export function etagMatches(header: string | null, etag: string): boolean {
164
+ if (header === null) return false;
165
+ if (header.trim() === '*') return true;
166
+ return header.split(',').some((candidate) => candidate.trim().replace(/^W\//, '') === etag);
167
+ }
168
+
169
+ /**
170
+ * The content type is the STORED one — the upload gate sniffed those bytes and `put` recorded the
171
+ * answer, so an extension in the URL is the only party that can lie. The validator is the driver's
172
+ * own content hash (local: sha256 of the bytes, S3: the provider's etag), which is the identity
173
+ * storage already keeps for an object; inventing a cache key here would be a second one.
174
+ */
175
+ export function storageResponse(request: UltimateRequest, read: StorageRead): Response {
176
+ const etag = `"${read.object.etag}"`;
177
+ const headers = new Headers({
178
+ 'content-type': read.object.contentType,
179
+ etag,
180
+ 'last-modified': read.object.lastModified.toUTCString(),
181
+ 'accept-ranges': 'bytes',
182
+ });
183
+ // Revalidation costs a request and no bytes, which is the trade an authorized response wants:
184
+ // the bytes never change under a key, but the actor's permission to read them can be revoked.
185
+ if (etagMatches(request.header('if-none-match'), etag)) {
186
+ return new Response(null, { status: 304, headers });
187
+ }
188
+
189
+ const size = read.bytes.byteLength;
190
+ const range = parseByteRange(request.header('range'), size);
191
+ if (range === UNSATISFIABLE) {
192
+ headers.set('content-range', `bytes */${size}`);
193
+ return new Response(null, { status: 416, headers });
194
+ }
195
+ // Copied at each call, not through a helper, for `dev-assets.ts`'s reason: a
196
+ // `Uint8Array<ArrayBufferLike>` may be backed by a `SharedArrayBuffer`, which `Response` does
197
+ // not accept — and a helper's declared return type widens the copy back to the type it refuses.
198
+ if (range === undefined) {
199
+ headers.set('content-length', String(size));
200
+ return new Response(new Uint8Array(read.bytes), { headers });
201
+ }
202
+ const slice = read.bytes.subarray(range.start, range.end + 1);
203
+ headers.set('content-range', `bytes ${range.start}-${range.end}/${size}`);
204
+ headers.set('content-length', String(slice.byteLength));
205
+ return new Response(new Uint8Array(slice), { status: 206, headers });
206
+ }
207
+
208
+ export interface StorageRoutesOptions {
209
+ /** The configured disks. The driver seam only — this route never assumes a filesystem. */
210
+ readonly storage: Storage;
211
+ }
212
+
213
+ /**
214
+ * `enforcedBy: 'handler'` for the reason an action route says it: the handler is the one
215
+ * evaluation. The `authz` stage decides through `ServerHooks.authorize`, which resolves a policy
216
+ * from `@ultimat3/render`'s page-route table — a table this route is not in, so the stage would
217
+ * deny every request with "no policy is registered" and the real rule would never run.
218
+ *
219
+ * `auth: 'required'` so an anonymous caller is 401 before the handler, and `cache` is declared
220
+ * rather than applied here: the pipeline's `cache-headers` stage is the one place a hint becomes a
221
+ * header. `private` because the response is authorized per actor, and `vary` on the two headers
222
+ * that carry an identity so no shared cache can hand one actor's object to another.
223
+ */
224
+ export function storageRoutes(options: StorageRoutesOptions): readonly Route[] {
225
+ return [
226
+ {
227
+ method: 'GET',
228
+ path: `${DEFAULT_SIGNED_URL_BASE}/:disk/*key`,
229
+ meta: {
230
+ name: 'storage.read',
231
+ auth: 'required',
232
+ policy: STORAGE_READ_PERMISSION,
233
+ enforcedBy: 'handler',
234
+ cache: AUTHORIZED_OBJECT_CACHE,
235
+ tags: ['storage'],
236
+ },
237
+ handler: async (request: UltimateRequest, ctx: RequestContext): Promise<Response> => {
238
+ const input: StorageReadInput = {
239
+ disk: request.params['disk'] ?? '',
240
+ key: request.params['key'] ?? '',
241
+ };
242
+ authorizeStorageRead(input, ctx);
243
+ return storageResponse(request, await readStorageObject(options.storage, input, ctx.actor));
244
+ },
245
+ },
246
+ ];
247
+ }
@@ -0,0 +1,107 @@
1
+ // The `sync` role: which live queries this node serves, who is dialling it, and the socket it owns.
2
+ // Split from `dev-roles.ts` because it is the one role with an authenticator, a presence registry
3
+ // and a listener of its own — and because that file is the boot's index, not its detail.
4
+
5
+ import { createContext, logger } from '@ultimat3/core';
6
+ import { listQueries } from '@ultimat3/query';
7
+ import {
8
+ ChannelHub,
9
+ createSyncNode,
10
+ LiveQueryRegistry,
11
+ listenSyncNode,
12
+ liveQueryDefinition,
13
+ PresenceRegistry,
14
+ RingChangeBuffer,
15
+ SocketRegistry,
16
+ } from '@ultimat3/realtime';
17
+ import type { StartRolesOptions } from './dev-roles';
18
+ import { syncAuthenticator } from './sync-authenticator';
19
+
20
+ /** What `startRoles` holds on to: where the node listens, and how to take it down. */
21
+ export interface RunningSync {
22
+ readonly url: string;
23
+ stop(): Promise<void>;
24
+ }
25
+
26
+ /**
27
+ * Every read the app declared `live: true` becomes a subscribable query on this node, through
28
+ * `@ultimat3/realtime`'s own bridge. A registry with nothing in it answers every live `subscribe`
29
+ * with "no live query registered", which is a working socket serving no reads — and it is what
30
+ * kept the row gate that decides per subscriber from ever running outside a unit test.
31
+ *
32
+ * The context is the node's, and it carries no actor: it supplies the services and the clock the
33
+ * shared read needs, never an authority. Who may subscribe, and which rows they see, is decided
34
+ * per socket at subscribe time and again for every row of every delivery.
35
+ *
36
+ * **The per-TENANT subscription cap is deliberately unset, and both halves of it are.**
37
+ * `assertCapacity` returns early unless `maxPerTenant` AND `tenantOf` are both given, so passing
38
+ * one arms nothing — a knob that quietly does nothing is the defect this whole seam exists to
39
+ * close. And no default is defensible: one tenant is a single person and the next is five
40
+ * thousand seats, so any number here is either unreachable or an outage on a Monday morning. The
41
+ * per-socket 128 stands because a socket is one browser tab, which is a bound the framework can
42
+ * actually know. A deployment that wants the tenant cap passes both:
43
+ *
44
+ * new LiveQueryRegistry({ …, maxPerTenant: 5_000, tenantOf: (actor) => actor?.orgId ?? null })
45
+ */
46
+ export function registerLiveQueries(options: StartRolesOptions): LiveQueryRegistry {
47
+ const registry = new LiveQueryRegistry({
48
+ source: new RingChangeBuffer(),
49
+ // A withheld row is a metric, never a frame and never an error: telling a client "there is a
50
+ // row you may not see" is the leak the gate exists to prevent.
51
+ onRowDenied: (event) => logger.debug('live.rows_denied', { ...event }),
52
+ });
53
+ const ctx = createContext({ role: 'sync', buildId: options.buildId });
54
+ for (const target of listQueries()) {
55
+ if (target.isLive) registry.register(liveQueryDefinition(target, { ctx }));
56
+ }
57
+ return registry;
58
+ }
59
+
60
+ /**
61
+ * The sync role owns its own socket: websockets and the request pipeline drain differently.
62
+ *
63
+ * Port 0 is passed straight through rather than incremented — `+ 1` would ask the kernel for
64
+ * port 1 instead of an ephemeral one — and the reported url is the listener's own bound address,
65
+ * never a string built from the port that was requested.
66
+ */
67
+ export async function startSync(options: StartRolesOptions): Promise<RunningSync> {
68
+ const sockets = new SocketRegistry();
69
+ const hub = new ChannelHub({ transport: options.runtime.transport, sockets });
70
+ // The node evaluated no credential of its own and no host ever handed it one, so every socket
71
+ // the framework opened was anonymous and every guard, gate, presence entry and tenant cap
72
+ // decided against `null`. An explicit override first — only that one can carry an `expiresAt`
73
+ // and a `refresh`, which is the whole of re-authorization — then the app's own HTTP resolver,
74
+ // then nothing at all, which is what `x dev` with no authenticator should stay.
75
+ const authenticate = options.overrides?.syncAuthenticate ?? syncAuthenticator(options.buildId);
76
+ const node = createSyncNode({
77
+ hub,
78
+ registry: registerLiveQueries(options),
79
+ transport: options.runtime.transport,
80
+ buildId: options.buildId,
81
+ sockets,
82
+ ...(authenticate === undefined ? {} : { authenticate }),
83
+ // Tier 1 is presence, and without a registry the node answers a topic subscribe with no member
84
+ // list at all — the KV bucket the transport just created would hold nothing and every `sync`
85
+ // container would run a presence-less protocol. It reads and writes `transport.shared`, so it
86
+ // is exactly as multi-node as the transport behind it: in-process here, the bucket under NATS.
87
+ presence: new PresenceRegistry({
88
+ transport: options.runtime.transport,
89
+ hub,
90
+ ttlMs: options.runtime.presenceTtlMs,
91
+ }),
92
+ });
93
+ await node.start();
94
+ try {
95
+ const listener = listenSyncNode(node, { port: options.port === 0 ? 0 : options.port + 1 });
96
+ return {
97
+ url: listener.url,
98
+ stop: async () => {
99
+ listener.stop();
100
+ await node.stop();
101
+ },
102
+ };
103
+ } catch (error) {
104
+ await node.stop();
105
+ throw error;
106
+ }
107
+ }
package/src/dev-traces.ts CHANGED
@@ -5,6 +5,9 @@
5
5
 
6
6
  import type { RequestTrace, SpanKind, TimelineSpan } from '@ultimat3/admin/dev';
7
7
  import type { ReadableSpan, SpanExporter } from '@ultimat3/core';
8
+ // The attribute name is `@ultimat3/db`'s to declare — this reads it rather than restating it, so
9
+ // renaming it there is a compile error here instead of a panel that silently groups nothing.
10
+ import { STATEMENT_ATTRIBUTE } from '@ultimat3/db';
8
11
 
9
12
  /** Traces retained. A dev panel shows recent requests; it does not page through history. */
10
13
  const DEFAULT_LIMIT = 50;
@@ -23,6 +26,10 @@ export interface TraceRecorder {
23
26
  * prefix IS the kind — no registry of names to keep in step with the packages that emit them.
24
27
  */
25
28
  const KIND_BY_PREFIX: readonly (readonly [string, SpanKind])[] = [
29
+ // Two producers of `sql`, one axis: `db.` is the statement itself (`db.select`, carrying its
30
+ // text), `query.` is the read that compiled it. The panel counts repeats of the detail, so a
31
+ // repository loop shows up as one SQL text fifty times and not as one `query.feed`.
32
+ ['db.', 'sql'],
26
33
  ['query.', 'sql'],
27
34
  ['cache.', 'cache'],
28
35
  ['action.', 'action'],
@@ -64,9 +71,27 @@ function requestFacts(root: ReadableSpan): { method: string; path: string } {
64
71
  };
65
72
  }
66
73
 
74
+ /** `http.request_id` is stamped by the pipeline's root and by nothing else; the name is a fallback. */
67
75
  const isHttpRoot = (span: ReadableSpan): boolean =>
68
- span.parentSpanId === undefined &&
69
- (span.attributes['http.request_id'] !== undefined || /^[A-Z]+ \//.test(span.name));
76
+ span.attributes['http.request_id'] !== undefined || /^[A-Z]+ \//.test(span.name);
77
+
78
+ /**
79
+ * The request's own span among a trace's. `parentSpanId === undefined` was a third CONDITION
80
+ * until `As of 2026-08`, and it dropped every request that arrived with an inbound
81
+ * `traceparent`: `pipeline.ts` passes `parent: correlation.parent`, so the root has a defined
82
+ * `parentSpanId`, `spans.find(isHttpRoot)` answered `undefined`, and the whole trace vanished
83
+ * from `/_x/timeline` for any caller behind an instrumented client, an ingress or a service
84
+ * mesh. It survives as the TIE-BREAK: the outermost candidate is the one whose parent is not
85
+ * itself in this recording, so a nested candidate can never outrank the request's own span.
86
+ */
87
+ function httpRootOf(spans: readonly ReadableSpan[]): ReadableSpan | undefined {
88
+ const candidates = spans.filter(isHttpRoot);
89
+ const recorded = new Set(spans.map((span) => span.context.spanId));
90
+ const outermost = candidates.find(
91
+ (span) => span.parentSpanId === undefined || !recorded.has(span.parentSpanId),
92
+ );
93
+ return outermost ?? candidates[0];
94
+ }
70
95
 
71
96
  function toTrace(root: ReadableSpan, spans: readonly ReadableSpan[]): RequestTrace {
72
97
  const { method, path } = requestFacts(root);
@@ -89,9 +114,10 @@ function toTrace(root: ReadableSpan, spans: readonly ReadableSpan[]): RequestTra
89
114
  name: span.name,
90
115
  startMs: Math.max(0, span.startedAt - origin),
91
116
  durationMs: span.durationMs,
92
- // The panel counts repeats of `detail` to find the N+1, and a framework span's name is
93
- // exactly the identity that repeats — `query.feed` twice is two reads of one query.
94
- detail: attrString(span, 'db.statement') ?? span.name,
117
+ // The panel counts repeats of `detail` to find the N+1. A statement states its own
118
+ // identity (`STATEMENT_ATTRIBUTE`, set by `@ultimat3/db`'s funnels); for every other span
119
+ // the name is that identity — `query.feed` twice is two reads of one query.
120
+ detail: attrString(span, STATEMENT_ATTRIBUTE) ?? span.name,
95
121
  }),
96
122
  )
97
123
  .sort((a, b) => a.startMs - b.startMs),
@@ -113,7 +139,11 @@ export function createTraceRecorder(options: { limit?: number } = {}): TraceReco
113
139
  const spans = byTrace.get(traceId);
114
140
  if (spans === undefined) {
115
141
  byTrace.set(traceId, [span]);
116
- // Bounded by trace, not by span: dropping half a request would leave a flame with holes.
142
+ // Bounded by TRACE, not by span: dropping half a request would leave a flame with holes.
143
+ // The cost is stated rather than capped — one trace's span array has no bound of its own, so
144
+ // a request issuing 50k statements holds 50k `ReadableSpan`s until it is evicted. That is a
145
+ // dev-only recorder (`serve.ts` installs none), and a per-trace cap would silently produce
146
+ // the holed flame this bound exists to prevent.
117
147
  while (byTrace.size > limit) {
118
148
  const oldest = byTrace.keys().next();
119
149
  if (oldest.done === true) break;
@@ -129,7 +159,7 @@ export function createTraceRecorder(options: { limit?: number } = {}): TraceReco
129
159
  traces(): readonly RequestTrace[] {
130
160
  const traces: RequestTrace[] = [];
131
161
  for (const spans of byTrace.values()) {
132
- const root = spans.find(isHttpRoot);
162
+ const root = httpRootOf(spans);
133
163
  if (root !== undefined) traces.push(toTrace(root, spans));
134
164
  }
135
165
  return traces.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
package/src/dispatch.ts CHANGED
@@ -12,7 +12,7 @@ import { exec } from './exec';
12
12
  import type { CommandResult } from './output';
13
13
  import { exitCodeFor, findingFrom, render } from './output';
14
14
  import type { ParsedArgs } from './parse';
15
- import { parseArgs } from './parse';
15
+ import { parseArgs, wantsJson } from './parse';
16
16
  import { commandFor, SPECS } from './registry';
17
17
 
18
18
  export interface DispatchOptions {
@@ -47,8 +47,10 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
47
47
  requireBunVersion(options.bunVersion);
48
48
  args = parseArgs(options.argv, SPECS);
49
49
  } catch (error) {
50
+ // `wantsJson`, not `includes('--json')`: a typo'd flag or a typo'd command is exactly the case
51
+ // an agent hits while always passing `-j`, and the short form rendered prose it then parsed.
50
52
  const result = errorResult('x', error);
51
- options.write(render(result, options.argv.includes('--json')));
53
+ options.write(render(result, wantsJson(options.argv)));
52
54
  return 1;
53
55
  }
54
56
 
@@ -0,0 +1,54 @@
1
+ // One question the gate asks of what a render actually emits: does the document carry its global
2
+ // style layer? Every rule `@ultimat3/ui` emits reads a custom property — `background:rgb(var(
3
+ // --color-bg)/1)`, `padding:var(--space-8)` — so CSS with no `:root` definitions is a document the
4
+ // browser drops every one of those declarations from, byte-for-byte identical to a working page
5
+ // apart from the styling nobody sees missing. A silent failure is exactly what axiom 3 exists for.
6
+
7
+ import type { Surface } from '@ultimat3/render';
8
+ import { routeEntries, stylesFor } from '@ultimat3/render';
9
+ import type { Finding } from './output';
10
+
11
+ /** The stylesheet an app is expected to own, named in the fix so it is one edit, not a hunt. */
12
+ export const APP_GLOBAL_STYLESHEET = 'shared/global.scss';
13
+ export const APP_GLOBAL_MODULE = 'shared/global.ts';
14
+
15
+ /**
16
+ * A custom property DEFINED at the document root. `var(--color-bg)` is a *use* and cannot match —
17
+ * there is no colon after the name — which is the whole distinction the check turns on: the broken
18
+ * document was full of uses and had not one definition.
19
+ */
20
+ const ROOT_CUSTOM_PROPERTY = /:root[^{}]*\{[^{}]*--[\w-]+\s*:/;
21
+
22
+ export const definesRootCustomProperties = (css: string): boolean => ROOT_CUSTOM_PROPERTY.test(css);
23
+
24
+ export interface SurfaceDocument {
25
+ readonly surface: Surface;
26
+ /** Exactly the CSS `dev-render.ts` would inline into a document on this surface. */
27
+ readonly css: string;
28
+ }
29
+
30
+ /**
31
+ * Every surface that renders a document, with the CSS one would carry. `api/` is excluded because
32
+ * it emits no document at all — a surface with nothing to style is not a surface missing its
33
+ * tokens. Read from render's own registry, filled when the CLI loaded the app: a second walk of
34
+ * the app's stylesheets here would be a second answer to "what does this document contain".
35
+ */
36
+ export function documentSurfaces(): readonly SurfaceDocument[] {
37
+ const surfaces = new Set(routeEntries().map((entry) => entry.surface));
38
+ surfaces.delete('api');
39
+ return [...surfaces]
40
+ .sort()
41
+ .map((surface) => ({ surface, css: stylesFor(surface) }) satisfies SurfaceDocument);
42
+ }
43
+
44
+ export function checkDocumentStyles(documents: readonly SurfaceDocument[]): readonly Finding[] {
45
+ return documents
46
+ .filter((document) => !definesRootCustomProperties(document.css))
47
+ .map((document) => ({
48
+ code: 'X_STYLES_GLOBAL_MISSING',
49
+ cause: `a ${document.surface}/ document carries ${document.css.length} characters of CSS and defines no :root custom properties, so every var(--color-*) and var(--space-*) in it resolves to nothing`,
50
+ fix: `add apps/web/${APP_GLOBAL_STYLESHEET} containing \`@use '@ultimat3/ui/global.scss';\` and apps/web/${APP_GLOBAL_MODULE} containing \`import './global.scss';\``,
51
+ docs: 'https://ultimate.dev/errors/X_STYLES_GLOBAL_MISSING',
52
+ at: `apps/web/${APP_GLOBAL_STYLESHEET}`,
53
+ }));
54
+ }
package/src/drift.ts CHANGED
@@ -1,14 +1,23 @@
1
- // Migration drift detection. `x db gen` records the hash of the app's entity schema next to the
2
- // migration it produced; drift is "the schema hashes to something no migration recorded". The
3
- // hash file is committed beside the migration, so a fresh clone can detect drift with no local
4
- // state and CI needs no database to answer the question.
1
+ // Source drift: the app's schema *source* against what migrations recorded. `x db gen` writes the
2
+ // hash of the entity schema next to the migration it produced, so drift here is "the schema hashes
3
+ // to something no migration recorded". The hash is committed beside the migration, so a fresh clone
4
+ // answers with no local state and CI needs no database. An app with NO migration at all is the one
5
+ // case that also asks how many entities are declared — see `checkSourceDrift`.
6
+ //
7
+ // This is not the post-migrate verification and deliberately cannot be: that one is the live
8
+ // database against the ledger (`checkDrift`, `@ultimat3/db`), asked by `runMigrations` where a
9
+ // connection is open. Same `X_DB_DRIFT`, two conditions — an entity edited with no migration
10
+ // generated, versus a database that does not match the migrations it ran.
5
11
 
6
12
  import { existsSync } from 'node:fs';
7
13
  import { join } from 'node:path';
14
+ import { countDeclaredEntities } from './app-entities';
15
+ // One declaration of where migrations live, and it belongs to the module that reads them —
16
+ // `x db migrate` and this sidecar must never disagree about the directory they share.
17
+ import { hashFileName, MIGRATIONS_DIR } from './migrations';
8
18
  import type { Finding } from './output';
9
19
 
10
20
  export const DB_PACKAGE = join('packages', 'db');
11
- export const MIGRATIONS_DIR = join(DB_PACKAGE, 'migrations');
12
21
  const SCHEMA_GLOB = 'packages/db/src/**/*.ts';
13
22
 
14
23
  /** Content hash of the whole schema, order-independent per file path. */
@@ -47,22 +56,81 @@ export async function recordedHashes(root: string): Promise<readonly MigrationRe
47
56
  return out;
48
57
  }
49
58
 
50
- export async function writeSchemaHash(root: string, migrationName: string): Promise<string> {
59
+ export async function writeSchemaHash(root: string, migrationId: string): Promise<string> {
51
60
  const hash = await schemaHash(root);
52
- await Bun.write(join(root, MIGRATIONS_DIR, `${migrationName}.hash`), `${hash}\n`);
61
+ await Bun.write(join(root, MIGRATIONS_DIR, hashFileName(migrationId)), `${hash}\n`);
53
62
  return hash;
54
63
  }
55
64
 
65
+ /**
66
+ * "Some migration recorded this hash" — the one predicate, read by the check below AND by the
67
+ * reconcile above it. Two spellings would let `x db gen` report the sidecar written while
68
+ * `x verify` still reports drift over the same two files.
69
+ */
70
+ const isRecorded = (records: readonly MigrationRecord[], hash: string): boolean =>
71
+ records.some((record) => record.hash === hash);
72
+
73
+ export interface HashReconciliation {
74
+ readonly hash: string;
75
+ /** False when a sidecar already held this hash — nothing was written, and nothing needed to be. */
76
+ readonly written: boolean;
77
+ }
78
+
79
+ /**
80
+ * Re-record the sidecar for a migration that is already the right one. `SCHEMA_GLOB` covers every
81
+ * non-test file under `packages/db/src`, not only the ones that imply DDL, so editing a seed or a
82
+ * helper moves the hash with no diff behind it — and `X_DB_DRIFT`'s `fix:` has to have somewhere to
83
+ * land or the instruction is unfollowable. The caller owes the proof that the DDL genuinely did not
84
+ * move (`db-generate.ts` reaches this only on an empty diff off a fully loaded registry); this
85
+ * function decides only whether a write is needed.
86
+ *
87
+ * A hash an OLDER migration recorded is left alone: `checkSourceDrift` already answers clean on it,
88
+ * and stamping the newest sidecar would claim that migration produced a schema it did not.
89
+ */
90
+ export async function reconcileSchemaHash(
91
+ root: string,
92
+ migrationId: string,
93
+ ): Promise<HashReconciliation> {
94
+ const hash = await schemaHash(root);
95
+ if (isRecorded(await recordedHashes(root), hash)) return { hash, written: false };
96
+ await Bun.write(join(root, MIGRATIONS_DIR, hashFileName(migrationId)), `${hash}\n`);
97
+ return { hash, written: true };
98
+ }
99
+
100
+ /**
101
+ * How many entities the app declares. Injected so this module's own tests need no app on disk, and
102
+ * so a caller that has already loaded the app can answer without loading it twice.
103
+ */
104
+ export type DeclaredEntityCount = () => Promise<number>;
105
+
56
106
  /**
57
107
  * Empty result = no drift. A missing db package is not drift (an app may have no database yet);
58
- * a schema with no migration at all is.
108
+ * a schema with no migration at all is — *provided* the app declares an entity for one to record.
109
+ *
110
+ * The entity count is read lazily and ONLY in that first branch, so an app past its first migration
111
+ * pays nothing for it: every other path answers from file hashes alone, with no app load and no
112
+ * database, which is what lets the gate run this in a CI with neither.
59
113
  */
60
- export async function checkDrift(root: string): Promise<readonly Finding[]> {
114
+ export async function checkSourceDrift(
115
+ root: string,
116
+ declaredEntities: DeclaredEntityCount = () => countDeclaredEntities(root),
117
+ ): Promise<readonly Finding[]> {
61
118
  if (!existsSync(join(root, DB_PACKAGE))) return [];
62
119
  const current = await schemaHash(root);
63
120
  const records = await recordedHashes(root);
64
121
  const latest = records.at(-1);
65
122
  if (latest === undefined) {
123
+ // Zero declared against zero recorded is AGREEMENT, not drift. The weaker condition this used
124
+ // to test — "a packages/db directory exists" — held `x new --no-example` permanently red behind
125
+ // `x db gen "initial"`, which has an empty diff there, writes no `.hash`, and exits ok: a fix
126
+ // that succeeds and changes nothing. Drift resumes the moment the author declares an entity,
127
+ // and by then the fix genuinely writes one.
128
+ //
129
+ // An empty diff still writes no `.hash` HERE, and must: `reconcileSchemaHash` needs a migration
130
+ // id to record against and there is no migration at all in this branch. With an entity declared
131
+ // the diff is never empty — a registry against zero migrations is `create table` for all of
132
+ // it — so this branch's fix stays the real generation it always was.
133
+ if ((await declaredEntities()) === 0) return [];
66
134
  return [
67
135
  {
68
136
  code: 'X_DB_DRIFT',
@@ -73,7 +141,7 @@ export async function checkDrift(root: string): Promise<readonly Finding[]> {
73
141
  },
74
142
  ];
75
143
  }
76
- if (records.some((record) => record.hash === current)) return [];
144
+ if (isRecorded(records, current)) return [];
77
145
  return [
78
146
  {
79
147
  code: 'X_DB_DRIFT',