@ultimat3/cli 1.2.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 +83 -16
  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 +165 -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 +201 -138
  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 +84 -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 +4 -3
  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 +170 -10
  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,245 @@
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
+ import { actorOf } from '@ultimat3/action';
7
+ import type { Actor } from '@ultimat3/core';
8
+ import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
9
+ import { asCtx, unauthenticated } from '@ultimat3/http';
10
+ import type { KnownPermission } from '@ultimat3/policy';
11
+ import { can, codeOf, evaluate, forbidden, reasonOf } from '@ultimat3/policy';
12
+ import type { Storage, StorageRead } from '@ultimat3/storage';
13
+ import {
14
+ assertSafeKey,
15
+ isTenantScoped,
16
+ isWithinOrg,
17
+ objectNotFound,
18
+ orgMismatch,
19
+ } from '@ultimat3/storage';
20
+
21
+ /** `localDriver` signs `/_storage/<disk>/<key>`, so the read half hangs off the same base. */
22
+ export const STORAGE_BASE_PATH = '/_storage';
23
+
24
+ /**
25
+ * The one capability that gates reading a stored object, on every disk. A permission and not a
26
+ * per-disk family: `disk` is in the policy's `input`, so an app that wants a per-disk rule writes
27
+ * one predicate over it, while a second permission string would be a second thing to grant and to
28
+ * forget. An app that declared a permission set without it gets `X_PERMISSION_UNKNOWN` from
29
+ * `can()` — naming the exact `definePermissions` edit — rather than a request that quietly worked.
30
+ */
31
+ export const STORAGE_READ_PERMISSION = 'storage:read';
32
+
33
+ /**
34
+ * `can()` takes a `KnownPermission`, which narrows to the APP's declared set the moment an app
35
+ * augments `PermissionRegistry` — and this package compiles against no app, so the bare literal is
36
+ * a type error inside a generated project (`scaffold-typecheck` is what proves it). The check that
37
+ * decides is the runtime one anyway: `can()` calls `assertPermission`, which throws
38
+ * `X_PERMISSION_UNKNOWN` naming the `definePermissions` edit. `dev-hooks.ts` narrows a route's
39
+ * structurally-typed permission for the same reason.
40
+ */
41
+ const READ_PERMISSION = STORAGE_READ_PERMISSION as unknown as KnownPermission;
42
+
43
+ /**
44
+ * The cache posture of a response that was authorized for ONE actor, named once so the two routes
45
+ * that serve stored bytes cannot declare different ones — which they did: `/media` answered
46
+ * `public, max-age=31536000, immutable` for the same object this route marks private, so a CDN held
47
+ * one tenant's file under a public key for a year. Revalidation costs a request and no bytes, which
48
+ * is the trade an authorized response wants: the bytes never change under a key, but the actor's
49
+ * permission to read them can be revoked. `vary` names the two headers that carry an identity.
50
+ */
51
+ export const AUTHORIZED_OBJECT_CACHE: CacheHint = {
52
+ mode: 'private',
53
+ maxAgeSeconds: 0,
54
+ vary: ['authorization', 'cookie'],
55
+ };
56
+
57
+ /** What the rule decides about. The key IS the object's identity, so this is the whole subject. */
58
+ export interface StorageReadInput {
59
+ readonly disk: string;
60
+ readonly key: string;
61
+ }
62
+
63
+ /**
64
+ * The single door. `evaluate()` is the framework's one authz entry point and this is a plain call
65
+ * to it — no inline permission test, no per-surface args type, no "public unless configured".
66
+ *
67
+ * Evaluated with NO row, deliberately. An object's owner is encoded in its key (`org/<id>/<entity>/
68
+ * <id>/<field>/…`), which `input` already carries, so a `StorageObject` would hand a predicate
69
+ * nothing it cannot already read — and loading one first would decide "does it exist" before
70
+ * "may you read it", which is how a caller learns another tenant's keys by watching the status.
71
+ */
72
+ export function authorizeStorageRead(input: StorageReadInput, ctx: RequestContext): void {
73
+ const context = asCtx(ctx);
74
+ // Built per request, not at mount: an app whose permission set lacks `storage:read` must get a
75
+ // problem document on this route, not a boot that takes every other route down with it.
76
+ const policy = can<StorageReadInput>(READ_PERMISSION);
77
+ const evaluation = evaluate(policy, { input, actor: actorOf(context) });
78
+ if (evaluation.allowed) return;
79
+ // The decision's own code goes on the wire, never a flattened one: `can()` denies "nobody" with
80
+ // X_UNAUTHENTICATED and a known actor with X_FORBIDDEN, and 401 and 403 are different
81
+ // instructions — "log in" against "you may not". `reason` is the deciding clause's own words,
82
+ // which `@ultimat3/policy` guarantees are safe to log.
83
+ const reason = reasonOf(evaluation.decision) ?? 'denied';
84
+ throw codeOf(evaluation.decision) === 'X_UNAUTHENTICATED'
85
+ ? unauthenticated(ctx.url.pathname)
86
+ : forbidden(policy.label, reason);
87
+ }
88
+
89
+ /**
90
+ * The key half of the read decision, in the order that discloses least: a key that could escape its
91
+ * prefix is refused before any tenant is named, and a key inside another tenant's prefix is 404
92
+ * (never 403 — `error-map.ts` maps `X_STORAGE_ORG_MISMATCH` there so a refusal cannot confirm that
93
+ * a key exists).
94
+ *
95
+ * Split out of `readStorageObject` because `/media/*key` (`dev-assets.ts`) has to make the same
96
+ * decision and made none at all: it passed a client-supplied key straight to `disk().get`, so every
97
+ * object on the app's only disk was one unauthenticated URL away. A second copy of this test is how
98
+ * one of the two surfaces would drift back — `storage-surfaces.test.ts` is what holds them level.
99
+ */
100
+ export function assertReadableKey(key: string, actor: Actor): string {
101
+ const safe = assertSafeKey(key);
102
+ // An actor with no org is inside no org, so every tenant-scoped key is somebody else's. Checked
103
+ // before `isWithinOrg`, which reads an empty org as a malformed key and would blame the caller's
104
+ // URL for the actor's missing claim.
105
+ const orgId = actor.orgId ?? '';
106
+ if (isTenantScoped(safe) && (orgId === '' || !isWithinOrg(safe, orgId))) {
107
+ throw orgMismatch(safe, orgId);
108
+ }
109
+ return safe;
110
+ }
111
+
112
+ /**
113
+ * Everything between the decision and the bytes. An unknown disk is the same 404 the foreign-tenant
114
+ * case answers, rather than `X_STORAGE_DISK_UNKNOWN`, whose cause lists every configured disk name.
115
+ */
116
+ export async function readStorageObject(
117
+ storage: Storage,
118
+ input: StorageReadInput,
119
+ actor: Actor,
120
+ ): Promise<StorageRead> {
121
+ const key = assertReadableKey(input.key, actor);
122
+ if (!storage.diskNames.includes(input.disk)) throw objectNotFound(input.disk, key);
123
+ return storage.disk(input.disk).get(key);
124
+ }
125
+
126
+ /** Inclusive, as `Range` and `Content-Range` both are. */
127
+ export interface ByteRange {
128
+ readonly start: number;
129
+ readonly end: number;
130
+ }
131
+
132
+ const UNSATISFIABLE = 'unsatisfiable';
133
+
134
+ /**
135
+ * One range or none. A multi-range or malformed header is ignored rather than refused — RFC 9110
136
+ * lets a server answer the whole representation, and `multipart/byteranges` is a body format no
137
+ * caller of this route asks for. Ranges exist here for one reason: Safari will not play a `<video>`
138
+ * from a source that answers 200 to a `Range` probe, so an uploaded video would be a dead player.
139
+ */
140
+ export function parseByteRange(
141
+ header: string | null,
142
+ size: number,
143
+ ): ByteRange | typeof UNSATISFIABLE | undefined {
144
+ if (header === null) return undefined;
145
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
146
+ if (match === null) return undefined;
147
+ const from = match[1] ?? '';
148
+ const to = match[2] ?? '';
149
+ if (from === '' && to === '') return undefined;
150
+ if (from === '') {
151
+ const wanted = Number(to);
152
+ // A suffix longer than the object is the whole object, not a refusal.
153
+ return wanted === 0 ? UNSATISFIABLE : { start: Math.max(size - wanted, 0), end: size - 1 };
154
+ }
155
+ const start = Number(from);
156
+ const end = to === '' ? size - 1 : Math.min(Number(to), size - 1);
157
+ return start >= size || end < start ? UNSATISFIABLE : { start, end };
158
+ }
159
+
160
+ /** `*`, a weak validator and a comma list all count — the etag is what has to match. */
161
+ export function etagMatches(header: string | null, etag: string): boolean {
162
+ if (header === null) return false;
163
+ if (header.trim() === '*') return true;
164
+ return header.split(',').some((candidate) => candidate.trim().replace(/^W\//, '') === etag);
165
+ }
166
+
167
+ /**
168
+ * The content type is the STORED one — the upload gate sniffed those bytes and `put` recorded the
169
+ * answer, so an extension in the URL is the only party that can lie. The validator is the driver's
170
+ * own content hash (local: sha256 of the bytes, S3: the provider's etag), which is the identity
171
+ * storage already keeps for an object; inventing a cache key here would be a second one.
172
+ */
173
+ export function storageResponse(request: UltimateRequest, read: StorageRead): Response {
174
+ const etag = `"${read.object.etag}"`;
175
+ const headers = new Headers({
176
+ 'content-type': read.object.contentType,
177
+ etag,
178
+ 'last-modified': read.object.lastModified.toUTCString(),
179
+ 'accept-ranges': 'bytes',
180
+ });
181
+ // Revalidation costs a request and no bytes, which is the trade an authorized response wants:
182
+ // the bytes never change under a key, but the actor's permission to read them can be revoked.
183
+ if (etagMatches(request.header('if-none-match'), etag)) {
184
+ return new Response(null, { status: 304, headers });
185
+ }
186
+
187
+ const size = read.bytes.byteLength;
188
+ const range = parseByteRange(request.header('range'), size);
189
+ if (range === UNSATISFIABLE) {
190
+ headers.set('content-range', `bytes */${size}`);
191
+ return new Response(null, { status: 416, headers });
192
+ }
193
+ // Copied at each call, not through a helper, for `dev-assets.ts`'s reason: a
194
+ // `Uint8Array<ArrayBufferLike>` may be backed by a `SharedArrayBuffer`, which `Response` does
195
+ // not accept — and a helper's declared return type widens the copy back to the type it refuses.
196
+ if (range === undefined) {
197
+ headers.set('content-length', String(size));
198
+ return new Response(new Uint8Array(read.bytes), { headers });
199
+ }
200
+ const slice = read.bytes.subarray(range.start, range.end + 1);
201
+ headers.set('content-range', `bytes ${range.start}-${range.end}/${size}`);
202
+ headers.set('content-length', String(slice.byteLength));
203
+ return new Response(new Uint8Array(slice), { status: 206, headers });
204
+ }
205
+
206
+ export interface StorageRoutesOptions {
207
+ /** The configured disks. The driver seam only — this route never assumes a filesystem. */
208
+ readonly storage: Storage;
209
+ }
210
+
211
+ /**
212
+ * `enforcedBy: 'handler'` for the reason an action route says it: the handler is the one
213
+ * evaluation. The `authz` stage decides through `ServerHooks.authorize`, which resolves a policy
214
+ * from `@ultimat3/render`'s page-route table — a table this route is not in, so the stage would
215
+ * deny every request with "no policy is registered" and the real rule would never run.
216
+ *
217
+ * `auth: 'required'` so an anonymous caller is 401 before the handler, and `cache` is declared
218
+ * rather than applied here: the pipeline's `cache-headers` stage is the one place a hint becomes a
219
+ * header. `private` because the response is authorized per actor, and `vary` on the two headers
220
+ * that carry an identity so no shared cache can hand one actor's object to another.
221
+ */
222
+ export function storageRoutes(options: StorageRoutesOptions): readonly Route[] {
223
+ return [
224
+ {
225
+ method: 'GET',
226
+ path: `${STORAGE_BASE_PATH}/:disk/*key`,
227
+ meta: {
228
+ name: 'storage.read',
229
+ auth: 'required',
230
+ policy: STORAGE_READ_PERMISSION,
231
+ enforcedBy: 'handler',
232
+ cache: AUTHORIZED_OBJECT_CACHE,
233
+ tags: ['storage'],
234
+ },
235
+ handler: async (request: UltimateRequest, ctx: RequestContext): Promise<Response> => {
236
+ const input: StorageReadInput = {
237
+ disk: request.params['disk'] ?? '',
238
+ key: request.params['key'] ?? '',
239
+ };
240
+ authorizeStorageRead(input, ctx);
241
+ return storageResponse(request, await readStorageObject(options.storage, input, ctx.actor));
242
+ },
243
+ },
244
+ ];
245
+ }
@@ -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'],
@@ -89,9 +96,10 @@ function toTrace(root: ReadableSpan, spans: readonly ReadableSpan[]): RequestTra
89
96
  name: span.name,
90
97
  startMs: Math.max(0, span.startedAt - origin),
91
98
  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,
99
+ // The panel counts repeats of `detail` to find the N+1. A statement states its own
100
+ // identity (`STATEMENT_ATTRIBUTE`, set by `@ultimat3/db`'s funnels); for every other span
101
+ // the name is that identity — `query.feed` twice is two reads of one query.
102
+ detail: attrString(span, STATEMENT_ATTRIBUTE) ?? span.name,
95
103
  }),
96
104
  )
97
105
  .sort((a, b) => a.startMs - b.startMs),
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,41 @@ 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
+ * How many entities the app declares. Injected so this module's own tests need no app on disk, and
67
+ * so a caller that has already loaded the app can answer without loading it twice.
68
+ */
69
+ export type DeclaredEntityCount = () => Promise<number>;
70
+
56
71
  /**
57
72
  * 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.
73
+ * a schema with no migration at all is — *provided* the app declares an entity for one to record.
74
+ *
75
+ * The entity count is read lazily and ONLY in that first branch, so an app past its first migration
76
+ * pays nothing for it: every other path answers from file hashes alone, with no app load and no
77
+ * database, which is what lets the gate run this in a CI with neither.
59
78
  */
60
- export async function checkDrift(root: string): Promise<readonly Finding[]> {
79
+ export async function checkSourceDrift(
80
+ root: string,
81
+ declaredEntities: DeclaredEntityCount = () => countDeclaredEntities(root),
82
+ ): Promise<readonly Finding[]> {
61
83
  if (!existsSync(join(root, DB_PACKAGE))) return [];
62
84
  const current = await schemaHash(root);
63
85
  const records = await recordedHashes(root);
64
86
  const latest = records.at(-1);
65
87
  if (latest === undefined) {
88
+ // Zero declared against zero recorded is AGREEMENT, not drift. The weaker condition this used
89
+ // to test — "a packages/db directory exists" — held `x new --no-example` permanently red behind
90
+ // `x db gen "initial"`, which has an empty diff there, writes no `.hash`, and exits ok: a fix
91
+ // that succeeds and changes nothing. Drift resumes the moment the author declares an entity,
92
+ // and by then the fix genuinely writes one.
93
+ if ((await declaredEntities()) === 0) return [];
66
94
  return [
67
95
  {
68
96
  code: 'X_DB_DRIFT',
@@ -3,8 +3,7 @@
3
3
  // commands actually need — so without this, `x errors explain X_UNAUTHENTICATED` answered "not a
4
4
  // registered error code" for a code the framework throws on every unauthenticated request.
5
5
 
6
- import { listErrorCodes, registerErrorCodes } from '@ultimat3/core';
7
- import { SCHEMA_ERROR_CODES } from '@ultimat3/schema';
6
+ import { listErrorCodes } from '@ultimat3/core';
8
7
  import type { Finding } from './output';
9
8
  import { findingFrom } from './output';
10
9
 
@@ -23,6 +22,7 @@ export const CATALOG_PACKAGES = [
23
22
  '@ultimat3/core',
24
23
  '@ultimat3/db',
25
24
  '@ultimat3/entity',
25
+ '@ultimat3/flags',
26
26
  '@ultimat3/http',
27
27
  '@ultimat3/i18n',
28
28
  '@ultimat3/jobs',
@@ -64,21 +64,6 @@ export interface ErrorCatalog {
64
64
  }
65
65
 
66
66
  let cached: Promise<ErrorCatalog> | undefined;
67
- let schemaRegistered = false;
68
-
69
- /**
70
- * `@ultimat3/schema` is tier 0 alongside `core`, so it cannot register its own codes — it exports
71
- * the declarations and names the CLI as the package that may import both tiers. This is that, in
72
- * one unguarded call: a `hasErrorCode()` skip would swallow the exact collision
73
- * `registerErrorCodes` raises `X_ERROR_CODE_DUPLICATE` for, and `x errors` would then explain a
74
- * schema code with whatever title the package that claimed it first gave it. Once per process,
75
- * because the code registry is process-global while this module's cache is not.
76
- */
77
- function registerSchemaCodes(): void {
78
- if (schemaRegistered) return;
79
- schemaRegistered = true;
80
- registerErrorCodes(SCHEMA_ERROR_CODES);
81
- }
82
67
 
83
68
  /**
84
69
  * Bun reports an unresolvable specifier as a `ResolveMessage` carrying `ERR_MODULE_NOT_FOUND` —
@@ -129,8 +114,12 @@ export async function buildErrorCatalog(
129
114
  };
130
115
  }
131
116
 
117
+ /**
118
+ * `@ultimat3/schema`'s codes are registered by `@ultimat3/core` itself now (`schema-error-codes.ts`)
119
+ * — every process that imports core gets them, this CLI process included, just by importing
120
+ * `@ultimat3/core` at all. Nothing schema-specific happens here any more.
121
+ */
132
122
  async function importAll(): Promise<ErrorCatalog> {
133
- registerSchemaCodes();
134
123
  return buildErrorCatalog((specifier) => import(specifier));
135
124
  }
136
125