@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
package/src/dev-assets.ts CHANGED
@@ -1,20 +1,28 @@
1
1
  // Projecting the framework's one image pipeline onto the routes `x dev` serves. Three packages
2
2
  // declare what an image is — `@ultimat3/seo` a variant URL, `@ultimat3/storage` a variant key,
3
3
  // `@ultimat3/pwa` the icons a web manifest promises — and `@ultimat3/core`'s pipeline owns every
4
- // pixel, so this file picks the two base paths they hang off and decides nothing else.
4
+ // pixel, so this file picks the two base paths they hang off and decides nothing else. The one
5
+ // thing it does NOT decide is who may read a stored object: `/media` borrows that whole answer
6
+ // from `dev-storage.ts`, because the same bytes are reachable through both.
5
7
 
6
8
  // `join` is `node:`-only by necessity: Bun exposes no path-join primitive, and `ICON_SOURCE` is
7
9
  // app-root-relative, so resolving it against the root is string work no `Bun.file` overload does.
8
10
  import { join } from 'node:path';
9
11
  import { probeImage } from '@ultimat3/core';
10
- import type { Route, UltimateRequest } from '@ultimat3/http';
12
+ import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
11
13
  import { applyCacheHeaders } from '@ultimat3/http';
12
14
  import type { IconPlan } from '@ultimat3/pwa';
13
15
  import { BuiltinImagePipeline, PwaIconMissingError, planIcons } from '@ultimat3/pwa';
14
- import type { ImageQuery } from '@ultimat3/seo';
15
- import { builtinImageDriver, parseImageQuery } from '@ultimat3/seo';
16
+ import type { ImageQuery, ImageTransformDriver } from '@ultimat3/seo';
17
+ import { builtinImageDriver, DEFAULT_WIDTHS, parseImageQuery } from '@ultimat3/seo';
16
18
  import type { ImageFormat, ImageTransform, Storage } from '@ultimat3/storage';
17
- import { IMAGE_FORMATS, variantKey } from '@ultimat3/storage';
19
+ import { IMAGE_FORMATS, isTenantScoped, variantKey } from '@ultimat3/storage';
20
+ import {
21
+ AUTHORIZED_OBJECT_CACHE,
22
+ assertReadableKey,
23
+ authorizeStorageRead,
24
+ STORAGE_READ_PERMISSION,
25
+ } from './dev-storage';
18
26
 
19
27
  /**
20
28
  * The one source image every generated icon derives from. `x new` scaffolds it, `x doctor` checks
@@ -26,22 +34,59 @@ export const ICON_SOURCE = 'apps/web/site/icon.png';
26
34
  /** Where `planIcons` writes, and therefore the paths the generated web manifest names. */
27
35
  export const ICON_BASE_PATH = '/icons';
28
36
 
29
- /** Storage-backed images. `responsiveImage({ src: '/media/<key>' })` mints its variants under it. */
37
+ /**
38
+ * Storage-backed images. `responsiveImage({ src: '/media/<key>' })` mints its variants under it.
39
+ * Guarded exactly as `/_storage` is — an object reachable through two URLs must not be reachable
40
+ * on two different terms — so a `src` under this path needs a signed-in reader holding
41
+ * `storage:read`. A genuinely public image belongs in `apps/web/site/`, which is served as a
42
+ * static asset and never touches a disk holding another tenant's uploads.
43
+ */
30
44
  export const MEDIA_BASE_PATH = '/media';
31
45
 
32
46
  /**
33
- * Variants are content-addressed by `variantKey`, so a URL that answers once answers forever with
34
- * the same bytes — the immutable hint is a fact about the key, not an optimism about the source.
47
+ * A generated icon and a content-addressed variant answer forever with the same bytes, so the
48
+ * immutable hint is a fact about the key. It is NOT a fact about this route — see `mediaCache`.
35
49
  */
36
- const imageResponse = (bytes: Uint8Array, contentType: string): Response =>
50
+ const IMMUTABLE_IMAGE: CacheHint = { mode: 'immutable' };
51
+
52
+ /**
53
+ * Whether a variant is one the framework itself can MINT, and therefore one worth storing.
54
+ *
55
+ * The cache key is built entirely from caller-supplied query values, and a signed-in reader
56
+ * holding `storage:read` may ask for any of them on their own objects — so `?w=1`, `?w=2`, … each
57
+ * wrote a new object to the app's only disk. `@ultimat3/seo`'s `MAX_IMAGE_WIDTH` (8192) bounds the
58
+ * blast radius and does not close it: 8192 stored objects per source, per format, is amplification
59
+ * a tenant drives with a `for` loop.
60
+ *
61
+ * The set is `DEFAULT_WIDTHS` **plus the source's intrinsic width**, which is exactly what
62
+ * `usableWidths` puts in a `srcset` — clamping to the constant alone would refuse the widest entry
63
+ * of every image whose intrinsic width is not one of the eight, a URL the framework mints itself.
64
+ * Anything outside it is still SERVED: this decides what is written, not what is answered, so no
65
+ * caller gains a new 4xx and the disk stops growing on a stranger's key.
66
+ */
67
+ const isMintableWidth = (width: number | undefined, intrinsic: number): boolean =>
68
+ width === undefined || width === intrinsic || DEFAULT_WIDTHS.includes(width);
69
+
70
+ const imageResponse = (bytes: Uint8Array, contentType: string, cache: CacheHint): Response =>
37
71
  applyCacheHeaders(
38
72
  // Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
39
73
  // `SharedArrayBuffer`, which `Response` does not accept, and copying is what makes that true
40
74
  // by construction rather than by a cast that would only silence it.
41
75
  new Response(new Uint8Array(bytes), { headers: { 'content-type': contentType } }),
42
- { mode: 'immutable' },
76
+ cache,
43
77
  );
44
78
 
79
+ /**
80
+ * Immutable is a claim about the KEY, and a tenant-scoped key names one org's private object: a
81
+ * CDN or shared proxy that stores it under a public URL for a year hands it to every other tenant,
82
+ * which is the cross-tenant read one hop removed. `/media` declared `public, max-age=31536000,
83
+ * immutable` for exactly those keys until this branch. Applied in the handler rather than declared
84
+ * in `meta.cache` because the posture is decided by the key, which no route declaration can see —
85
+ * the pipeline's `cache-headers` stage only fills a `cache-control` a handler did not set.
86
+ */
87
+ const mediaCache = (key: string): CacheHint =>
88
+ isTenantScoped(key) ? AUTHORIZED_OBJECT_CACHE : IMMUTABLE_IMAGE;
89
+
45
90
  const isImageFormat = (value: string): value is ImageFormat =>
46
91
  (IMAGE_FORMATS as readonly string[]).includes(value);
47
92
 
@@ -66,6 +111,7 @@ async function transformedVariant(
66
111
  storage: Storage,
67
112
  key: string,
68
113
  query: ImageQuery,
114
+ images: ImageTransformDriver | undefined,
69
115
  ): Promise<Response> {
70
116
  const disk = storage.disk();
71
117
  // A format storage cannot name has no variant key, so it cannot be cached. The driver refuses
@@ -74,33 +120,57 @@ async function transformedVariant(
74
120
  query.format !== undefined && isImageFormat(query.format) ? query.format : undefined;
75
121
  const cacheable = query.format === undefined || format !== undefined;
76
122
  const cached = cacheable ? variantKey(key, storageTransform(query, format)) : undefined;
123
+ // The SOURCE key decides the posture, not the variant's: `variantKey` keeps the source's prefix,
124
+ // so a variant of `org/<id>/…` is one org's object too, and reading the hint off the derived key
125
+ // would be a second answer to a question the source already settled.
126
+ const cache = mediaCache(key);
77
127
  if (cached !== undefined && (await disk.exists(cached))) {
78
128
  const hit = await disk.get(cached);
79
- return imageResponse(hit.bytes, hit.object.contentType);
129
+ return imageResponse(hit.bytes, hit.object.contentType, cache);
80
130
  }
81
131
 
82
132
  const source = await disk.get(key);
83
- const variant = await builtinImageDriver({ read: async () => source.bytes }).transform({
133
+ const intrinsic = probeImage(source.bytes).width;
134
+ // The seam is WHICH driver transforms, not who resolves the bytes: `TransformRequest.width` is
135
+ // required, and a request with no `?w=` gets its width from the source's own header — so the
136
+ // read happens either way and a supplied driver is handed the same resolved request the builtin
137
+ // one gets. Constructed inline before this, with no seam at all, so a deployment that routes
138
+ // transforms through a CDN had to fork the route to do it.
139
+ const driver = images ?? builtinImageDriver({ read: async () => source.bytes });
140
+ const variant = await driver.transform({
84
141
  src: key,
85
142
  // A header read, not a decode: `?f=webp` alone still needs a width, and the source's own is
86
143
  // the only one that does not resize an image the caller never asked to resize.
87
- width: query.width ?? probeImage(source.bytes).width,
144
+ width: query.width ?? intrinsic,
88
145
  ...(query.format === undefined ? {} : { format: query.format }),
89
146
  ...(query.quality === undefined ? {} : { quality: query.quality }),
90
147
  });
91
- if (cached !== undefined) {
148
+ if (cached !== undefined && isMintableWidth(query.width, intrinsic)) {
92
149
  await disk.put(cached, variant.bytes, { contentType: variant.contentType });
93
150
  }
94
- return imageResponse(variant.bytes, variant.contentType);
151
+ return imageResponse(variant.bytes, variant.contentType, cache);
95
152
  }
96
153
 
97
- async function mediaResponse(request: UltimateRequest, storage: Storage): Promise<Response> {
98
- const key = request.params['key'] ?? '';
154
+ /**
155
+ * Authorized before a key is parsed and before a disk is touched — the same two calls, in the same
156
+ * order, that `/_storage` makes. This route made NEITHER: it was `auth: 'public'` with no policy
157
+ * and handed the raw client-supplied key to `disk().get`, so every object on the app's only disk
158
+ * was one unauthenticated URL away, and `?w=` made it an unauthenticated `put` besides.
159
+ */
160
+ async function mediaResponse(
161
+ request: UltimateRequest,
162
+ ctx: RequestContext,
163
+ storage: Storage,
164
+ images: ImageTransformDriver | undefined,
165
+ ): Promise<Response> {
166
+ const requested = request.params['key'] ?? '';
167
+ authorizeStorageRead({ disk: storage.defaultDisk, key: requested }, ctx);
168
+ const key = assertReadableKey(requested, ctx.actor);
99
169
  const query = parseImageQuery(request.url.searchParams);
100
- if (query !== null) return transformedVariant(storage, key, query);
170
+ if (query !== null) return transformedVariant(storage, key, query, images);
101
171
  // No transform asked for: the object itself, still under the storage key's own safety checks.
102
172
  const read = await storage.disk().get(key);
103
- return imageResponse(read.bytes, read.object.contentType);
173
+ return imageResponse(read.bytes, read.object.contentType, mediaCache(key));
104
174
  }
105
175
 
106
176
  /**
@@ -145,6 +215,8 @@ export interface AssetRoutesOptions {
145
215
  /** App root. The source icon is resolved against it; storage keys never are. */
146
216
  readonly root: string;
147
217
  readonly storage: Storage;
218
+ /** Replaces `builtinImageDriver` for `/media/*`. Omitted, core's PNG/JPEG pipeline. */
219
+ readonly images?: ImageTransformDriver;
148
220
  }
149
221
 
150
222
  /**
@@ -163,14 +235,27 @@ export function assetRoutes(options: AssetRoutesOptions): readonly Route[] {
163
235
  path: entry.outputPath,
164
236
  meta: { name: `assets.icon.${entry.spec.filename}`, auth: 'public', tags: ['assets'] },
165
237
  handler: async (request: UltimateRequest): Promise<Response> =>
166
- imageResponse(await render(plan, request.pathname), 'image/png'),
238
+ imageResponse(await render(plan, request.pathname), 'image/png', IMMUTABLE_IMAGE),
167
239
  }));
240
+ // The icons above are genuinely public — they are rendered from a file committed in the app, and
241
+ // an install prompt fetches them before anyone has signed in. `/media` is the opposite: it serves
242
+ // whatever is on the app's only disk, which is every tenant's uploads, so it takes `/_storage`'s
243
+ // declaration verbatim. `enforcedBy: 'handler'` for the reason that route gives — the `authz`
244
+ // stage resolves a policy from `@ultimat3/render`'s page table, which this route is not in.
245
+ // `cache` declares the conservative posture; `mediaCache` narrows or widens it per key.
168
246
  routes.push({
169
247
  method: 'GET',
170
248
  path: `${MEDIA_BASE_PATH}/*key`,
171
- meta: { name: 'assets.media', auth: 'public', tags: ['assets'] },
172
- handler: async (request: UltimateRequest): Promise<Response> =>
173
- mediaResponse(request, options.storage),
249
+ meta: {
250
+ name: 'assets.media',
251
+ auth: 'required',
252
+ policy: STORAGE_READ_PERMISSION,
253
+ enforcedBy: 'handler',
254
+ cache: AUTHORIZED_OBJECT_CACHE,
255
+ tags: ['assets'],
256
+ },
257
+ handler: async (request: UltimateRequest, ctx: RequestContext): Promise<Response> =>
258
+ mediaResponse(request, ctx, options.storage, options.images),
174
259
  });
175
260
 
176
261
  return routes;
@@ -0,0 +1,122 @@
1
+ // Which cache tiers this process reads through, and the hop that tells the other replicas what it
2
+ // just dropped. `createMemoTier`, `createLruTier` and `createRedisTier` were built, exported and
3
+ // tested with ZERO callers — `dev-runtime.ts` registered the CDN tier and nothing else — so every
4
+ // cached read was recomputed on every replica on every request, and `registerInvalidationBroadcast`
5
+ // had no one to register it.
6
+
7
+ import type { CacheTier, PurgeDriver } from '@ultimat3/cache';
8
+ import {
9
+ createCdnTier,
10
+ createLruTier,
11
+ createMemoTier,
12
+ createRedisTier,
13
+ isNoopPurgeDriver,
14
+ receiveInvalidationBroadcast,
15
+ registerInvalidationBroadcast,
16
+ registerTier,
17
+ resetTiers,
18
+ } from '@ultimat3/cache';
19
+ import { logger } from '@ultimat3/core';
20
+ import type { Transport, TransportSubscription } from '@ultimat3/realtime';
21
+ import type { Env } from './dev-services';
22
+
23
+ /**
24
+ * The subject every replica of every app publishes tag invalidations on. One subject and not one
25
+ * per app: a transport is already namespaced by the bus an operator pointed the deployment at,
26
+ * and a second namespace here would be a knob whose only correct value is the default.
27
+ */
28
+ export const CACHE_INVALIDATE_SUBJECT = 'x.cache.invalidate';
29
+
30
+ export interface CacheTiersOptions {
31
+ readonly env: Env;
32
+ /** Already resolved by the boot — the CDN tier is registered only for a real edge. */
33
+ readonly purge: PurgeDriver;
34
+ readonly transport: Transport;
35
+ }
36
+
37
+ /**
38
+ * The shared tier, or nothing. `REDIS_URL` is the same "an unset variable means the embedded
39
+ * default" law the db, events, storage, mail and CDN bindings already follow — and it is the
40
+ * variable Bun's own `Bun.redis` reads, so a tier selected here and a client built there cannot
41
+ * point at two servers.
42
+ */
43
+ function sharedTier(env: Env): CacheTier | undefined {
44
+ const url = env['REDIS_URL']?.trim();
45
+ return url === undefined || url === '' ? undefined : createRedisTier();
46
+ }
47
+
48
+ /**
49
+ * Register the tiers, wire both halves of cross-instance invalidation, and return the release.
50
+ *
51
+ * The outbound half publishes the wire tags this process just dropped; the inbound half applies
52
+ * another instance's. A message this process published is delivered back to it on every real bus
53
+ * and is applied again — deliberately, with no node-id filter: dropping an already-dropped key is
54
+ * idempotent and free, while a dedup table is state that can be wrong. Re-emit is impossible by
55
+ * construction, not by a flag: `receiveInvalidationBroadcast` is the only entry point that
56
+ * suppresses it, and `emit` is not a public parameter.
57
+ */
58
+ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void> {
59
+ // Request-scoped memo first, then the process-local LRU: both are free of external state, so
60
+ // they are the "embedded default" that needs no variable to switch on. Registration order does
61
+ // not decide read order — `sortTiers` does — but it is written in read order anyway.
62
+ registerTier(createMemoTier());
63
+ registerTier(createLruTier());
64
+ const shared = sharedTier(options.env);
65
+ if (shared !== undefined) registerTier(shared);
66
+ // Nothing installs a read tier here, and that is the point. `@ultimat3/query` used to own a
67
+ // private `ReadCache` this boot had to hand-wire over one of the objects above, because
68
+ // `invalidateTags` fans out to registered tiers and to nothing else — so a read cache holding
69
+ // entries of its own was a `cache:` query an action's `invalidates` could never bust. The seam
70
+ // is gone: a `cache:` read fills the ladder registered here, so there is one registry and one
71
+ // fan-out and no wiring to get wrong.
72
+ // Registered only when a credential named a real edge. A noop tier would put a `cdn` line in
73
+ // every invalidation report claiming keys an edge that does not exist had accepted — and the
74
+ // `/_x` cache panel renders those reports, so the lie would be the thing an agent reads.
75
+ if (!isNoopPurgeDriver(options.purge)) {
76
+ registerTier(createCdnTier({ purge: options.purge }));
77
+ }
78
+
79
+ registerInvalidationBroadcast(async (wireTags) => {
80
+ await options.transport.publish(CACHE_INVALIDATE_SUBJECT, JSON.stringify(wireTags));
81
+ });
82
+ let subscription: TransportSubscription | undefined;
83
+ // Not awaited: the boot must not block on a subscribe, and a bus that refuses one is a process
84
+ // that misses peer invalidations, never a process that fails to start.
85
+ void options.transport
86
+ .subscribe(CACHE_INVALIDATE_SUBJECT, (payload: string) => {
87
+ void applyBroadcast(payload);
88
+ })
89
+ .then((handle) => {
90
+ subscription = handle;
91
+ })
92
+ .catch((error: unknown) => {
93
+ logger.warn('cache.broadcast.subscribe-failed', { error: messageOf(error) });
94
+ });
95
+
96
+ // `resetTiers()` drops the registry AND the broadcast in one call: this boot is the only thing
97
+ // that registers either, and a tier left behind would purge for a process that has stopped.
98
+ return async () => {
99
+ subscription?.unsubscribe();
100
+ subscription = undefined;
101
+ resetTiers();
102
+ };
103
+ }
104
+
105
+ const messageOf = (error: unknown): string =>
106
+ error instanceof Error ? error.message : 'unknown error';
107
+
108
+ /**
109
+ * A peer's wire tags, applied here. Never throws: a malformed frame or an undeclared tag must not
110
+ * kill the subscriber loop, because that would silently end cross-instance invalidation for the
111
+ * whole process — the exact failure this hop exists to prevent, arriving quietly.
112
+ */
113
+ async function applyBroadcast(payload: string): Promise<void> {
114
+ try {
115
+ const parsed: unknown = JSON.parse(payload);
116
+ if (!Array.isArray(parsed)) return;
117
+ const wire = parsed.filter((value): value is string => typeof value === 'string');
118
+ if (wire.length > 0) await receiveInvalidationBroadcast(wire);
119
+ } catch (error) {
120
+ logger.warn('cache.broadcast.apply-failed', { error: messageOf(error) });
121
+ }
122
+ }
@@ -12,6 +12,7 @@ import type {
12
12
  PolicyFact,
13
13
  RequestTrace,
14
14
  SqlResult,
15
+ StatementLoopFact,
15
16
  } from '@ultimat3/admin/dev';
16
17
  import { DEV_BASE_PATH, DEV_PANELS, defaultDevSources, devDashboard } from '@ultimat3/admin/dev';
17
18
  import { recentInvalidations } from '@ultimat3/cache';
@@ -23,11 +24,13 @@ import { isMemoryDriver } from '@ultimat3/mail';
23
24
  import type { Manifest } from '@ultimat3/manifest';
24
25
  import { checkAppBoundaries } from './app-boundaries';
25
26
  import { appManifest, readAppManifest } from './app-manifest';
27
+ import type { StatementLedger } from './dev-n-plus-one';
26
28
  import { devPolicyMatrix } from './dev-policy';
27
29
  import type { RunningServices } from './dev-runtime';
28
30
  import type { DevServices } from './dev-services';
29
31
  import type { TraceRecorder } from './dev-traces';
30
32
  import type { Finding } from './output';
33
+ import { loopFacts } from './statement-loop';
31
34
 
32
35
  export interface DevStatus {
33
36
  readonly url: string;
@@ -46,6 +49,8 @@ export interface DevDashboardInput {
46
49
  readonly env?: string | undefined;
47
50
  /** The spans this process recorded. Absent when `x dev` did not install the exporter. */
48
51
  readonly traces?: TraceRecorder | undefined;
52
+ /** The statement shapes this process counted. Absent when `x dev` did not install the observer. */
53
+ readonly statements?: StatementLedger | undefined;
49
54
  }
50
55
 
51
56
  /**
@@ -127,6 +132,7 @@ const invalidationFacts = (): readonly InvalidationFact[] =>
127
132
  */
128
133
  export function devSources(input: DevDashboardInput): DevSources {
129
134
  const traces = input.traces;
135
+ const statements = input.statements;
130
136
  // Only the memory driver retains what it accepted. Once a credential selects a real transport
131
137
  // the messages are at the provider, so the hook is omitted rather than answered with `[]` —
132
138
  // an empty outbox claims nobody was mailed, which is a different and unearned answer.
@@ -148,6 +154,15 @@ export function devSources(input: DevDashboardInput): DevSources {
148
154
  ...(traces === undefined
149
155
  ? {}
150
156
  : { traces: (): Promise<readonly RequestTrace[]> => Promise.resolve(traces.traces()) }),
157
+ // Same rule, same reason: the timeline panel answers `null` for a host that counted nothing,
158
+ // and an empty list here would instead claim every request on screen was clean. The verdicts
159
+ // are read at request time, so a loop that just happened is on the panel without a remount.
160
+ ...(statements === undefined
161
+ ? {}
162
+ : {
163
+ statementLoops: (): Promise<readonly StatementLoopFact[]> =>
164
+ Promise.resolve(statements.repeats().map(loopFacts)),
165
+ }),
151
166
  },
152
167
  });
153
168
  }
@@ -163,8 +178,8 @@ interface ServicesPanelData extends DevStatus {
163
178
  */
164
179
  const servicesPanel = (input: DevDashboardInput): DevPanel<ServicesPanelData> => ({
165
180
  key: 'services',
166
- titleKey: 'dev.panel.services',
167
- question: 'which services is this process talking to, and did anything fail to load?',
181
+ titleKey: 'dev.panel.services.title',
182
+ questionKey: 'dev.panel.services.question',
168
183
  data(): Promise<ServicesPanelData> {
169
184
  const status = input.status();
170
185
  return Promise.resolve({ ...status, stateDir: status.services.stateDir });
@@ -177,8 +192,8 @@ interface BoundariesPanelData {
177
192
 
178
193
  const boundariesPanel = (input: DevDashboardInput): DevPanel<BoundariesPanelData> => ({
179
194
  key: 'boundaries',
180
- titleKey: 'dev.panel.boundaries',
181
- question: 'does any file import across a boundary the build will reject?',
195
+ titleKey: 'dev.panel.boundaries.title',
196
+ questionKey: 'dev.panel.boundaries.question',
182
197
  async data(): Promise<BoundariesPanelData> {
183
198
  return { findings: await checkAppBoundaries(input.root) };
184
199
  },
package/src/dev-hooks.ts CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { actorOf } from '@ultimat3/action';
6
6
  import type { AuthzDecision, ServerHooks } from '@ultimat3/http';
7
- import { asCtx } from '@ultimat3/http';
7
+ import { asCtx, configuredAuthenticator } from '@ultimat3/http';
8
8
  import type { KnownPermission, Policy } from '@ultimat3/policy';
9
9
  import { can, evaluate } from '@ultimat3/policy';
10
10
  import { routeFor } from '@ultimat3/render';
@@ -22,8 +22,33 @@ function policyFor(path: string): Policy<unknown, unknown> | undefined {
22
22
  return permission !== undefined && isPermission(permission) ? can(permission) : undefined;
23
23
  }
24
24
 
25
- export function devHooks(): ServerHooks {
25
+ /**
26
+ * Both seams, never one. This returned `authorize` alone, so `hooks.authenticate` — the only
27
+ * place an actor can come from — had no caller anywhere in the framework: every request under
28
+ * `x dev` AND under `apps/web/server.ts` (both boot through `startRoles`) was anonymous, and
29
+ * `auth: 'required'` was unsatisfiable. The app declares the resolver with
30
+ * `configureAuthenticator()` at import time; this reads it back at server start, which is after
31
+ * `loadApp` has imported the app's modules.
32
+ *
33
+ * Read here rather than captured at module load so a test — and a watch-mode restart — sees the
34
+ * function the app configured, not the one that was absent when this module first evaluated.
35
+ */
36
+ export interface DevHookOptions {
37
+ /**
38
+ * Dev-only findings this process accumulated for the request being answered — `x dev`'s N+1
39
+ * ledger, and nothing else today. Passed rather than read from a module-global for the reason
40
+ * `authorize` is passed a route: a hook that reached for the ledger itself would make every host
41
+ * that starts a web role — `serve.ts` included — carry a diagnostic only one of them installs.
42
+ */
43
+ readonly devNotices?: ServerHooks['devNotices'];
44
+ }
45
+
46
+ export function devHooks(options: DevHookOptions = {}): ServerHooks {
47
+ const authenticate = configuredAuthenticator();
48
+ const devNotices = options.devNotices;
26
49
  return {
50
+ ...(authenticate === undefined ? {} : { authenticate }),
51
+ ...(devNotices === undefined ? {} : { devNotices }),
27
52
  authorize: (route, _request, ctx): AuthzDecision => {
28
53
  // An action route never arrives here: it carries `enforcedBy: 'handler'`, so the pipeline
29
54
  // never asks. `invoke` is its one evaluation, and the only one holding the row a row-level
@@ -0,0 +1,191 @@
1
+ // The N+1 ledger: one count per statement shape per request, and a verdict once a shape crosses
2
+ // the threshold. Counting state hangs off the request's own `Ctx` in a `WeakMap`, so it is
3
+ // collected with the request and never swept. Installed by `x dev` and by nothing else — a
4
+ // production process pays the one `undefined` branch `@ultimat3/db`'s seam already costs (axiom 6).
5
+ // It counts and it warns once; what a verdict *means* — which code, which `fix:` — is
6
+ // `statement-loop.ts`'s, so all four surfaces read one answer.
7
+
8
+ import type { Ctx } from '@ultimat3/core';
9
+ import { assert, tryUseContext } from '@ultimat3/core';
10
+ import type { StatementAttribution, StatementEvent, StatementObserver } from '@ultimat3/db';
11
+ import { statementFingerprint, statementKind } from '@ultimat3/db';
12
+ import { N_PLUS_ONE_THRESHOLD } from '@ultimat3/entity';
13
+ import { loopFacts, warnLoop } from './statement-loop';
14
+
15
+ /** Verdicts retained. A dev diagnostic shows the recent loops; it does not page through history. */
16
+ const DEFAULT_LIMIT = 50;
17
+
18
+ /** One statement shape, repeated inside one request past the threshold. */
19
+ export interface RepeatedStatement {
20
+ /** What was repeated: `members.findById` when attributed, else the statement's own text. */
21
+ readonly fingerprint: string;
22
+ /** Which loop this is, and therefore which fix a report can name. */
23
+ readonly kind: 'read' | 'write';
24
+ /** The entity and operation that compiled it, absent for hand-written SQL and queue traffic. */
25
+ readonly attribution?: StatementAttribution | undefined;
26
+ /** One of the statements, verbatim — the SQL a report shows under the fingerprint. */
27
+ readonly sample: string;
28
+ /** Statements of this shape the request has issued so far. Never below the threshold. */
29
+ readonly count: number;
30
+ /** The request the loop happened in; a report and its log line name the same one. */
31
+ readonly requestId: string;
32
+ readonly traceId: string;
33
+ }
34
+
35
+ export interface StatementLedger {
36
+ /** Hand this to `setStatementObserver()`. */
37
+ readonly observer: StatementObserver;
38
+ /** Shapes that crossed the threshold, newest first. */
39
+ repeats(): readonly RepeatedStatement[];
40
+ /**
41
+ * The same verdicts, for one request. What the browser overlay shows next to an error: a page
42
+ * that looped names its loop on the page, not only in a terminal the author is not looking at.
43
+ */
44
+ repeatsFor(ctx: Ctx): readonly RepeatedStatement[];
45
+ reset(): void;
46
+ }
47
+
48
+ export interface StatementLedgerOptions {
49
+ /**
50
+ * Statements of one shape in one request that trip a verdict. Defaults to
51
+ * `N_PLUS_ONE_THRESHOLD` — `@ultimat3/entity`'s, so this ledger and the strict test fixture
52
+ * cannot disagree about how many of one shape is a loop.
53
+ */
54
+ readonly threshold?: number;
55
+ /** Verdicts retained before the oldest is dropped. Default `50`. */
56
+ readonly limit?: number;
57
+ }
58
+
59
+ /** The live count behind a `RepeatedStatement`: reported once, then still counting. */
60
+ interface RepeatGroup {
61
+ readonly fingerprint: string;
62
+ readonly kind: 'read' | 'write';
63
+ readonly attribution: StatementAttribution | undefined;
64
+ readonly sample: string;
65
+ readonly requestId: string;
66
+ readonly traceId: string;
67
+ count: number;
68
+ /** Whether this shape is already a verdict. The flag, not the count, so a `threshold` of 1 works. */
69
+ promoted: boolean;
70
+ }
71
+
72
+ /** The verdict a surface reads, without the bookkeeping the group keeps for the ledger itself. */
73
+ const snapshot = (group: RepeatGroup): RepeatedStatement => ({
74
+ fingerprint: group.fingerprint,
75
+ kind: group.kind,
76
+ attribution: group.attribution,
77
+ sample: group.sample,
78
+ count: group.count,
79
+ requestId: group.requestId,
80
+ traceId: group.traceId,
81
+ });
82
+
83
+ /**
84
+ * Count statement shapes per request and report the ones that repeat past `threshold`.
85
+ *
86
+ * Three rules, each load-bearing. **Per request, keyed by the context object** — the map dies with
87
+ * the `Ctx` that owns it, so a dev server up for a week accumulates nothing and no sweep has to
88
+ * decide when a request ended. A statement issued outside a request (a migration, a boot probe, a
89
+ * script) is not counted at all: "five of one shape" only means something inside one unit of work.
90
+ * A `withChildContext` scope is its own key and therefore its own tally, which is the price of
91
+ * keying on identity rather than on `requestId` and holding the counts forever.
92
+ *
93
+ * **An expected statement is not counted** — `expectedQueryLoop` suppresses a verdict, and this
94
+ * ledger *is* the verdict. The statement is still sent, still observed and still a span, so the
95
+ * timeline keeps showing the loop while the thing that warns is told the author already answered.
96
+ *
97
+ * **A shape is promoted exactly once**, on the statement that crosses the threshold, and the group
98
+ * behind it keeps counting — so a loop of fifty is one verdict reading `count: 50`, not
99
+ * forty-six verdicts. The report list is bounded and drops its oldest entry.
100
+ *
101
+ * Promotion is also the moment the log line goes out, and it is **one line per request per code**:
102
+ * a request that loops three different shapes of read has three verdicts and one `X_N_PLUS_ONE_QUERY`
103
+ * warning, because a log is read to learn that this request looped and the three shapes are what
104
+ * `x dev`'s findings, `/_x` and the overlay are for. The line names the count as it stood when the
105
+ * threshold was crossed; every other surface reads the count as it stands when asked.
106
+ */
107
+ export function createStatementLedger(options: StatementLedgerOptions = {}): StatementLedger {
108
+ const threshold = options.threshold ?? N_PLUS_ONE_THRESHOLD;
109
+ const limit = options.limit ?? DEFAULT_LIMIT;
110
+ assert(
111
+ Number.isInteger(threshold) && threshold >= 1,
112
+ `createStatementLedger() was given a threshold of ${threshold}, which no statement count can reach`,
113
+ 'pass a whole number of statements: createStatementLedger({ threshold: 5 })',
114
+ );
115
+ assert(
116
+ Number.isInteger(limit) && limit >= 1,
117
+ `createStatementLedger() was given a limit of ${limit}, so no verdict could be kept`,
118
+ 'pass how many verdicts to retain: createStatementLedger({ limit: 50 })',
119
+ );
120
+ const byRequest = new WeakMap<Ctx, Map<string, RepeatGroup>>();
121
+ // Which codes this request has already warned about. Its own map rather than a field on the
122
+ // groups: the rule is one line per *code*, and the groups are per shape — three shapes of read
123
+ // in one request share one warning and each keeps its own verdict.
124
+ const warned = new WeakMap<Ctx, Set<string>>();
125
+ const reported: RepeatGroup[] = [];
126
+
127
+ const warnOnce = (ctx: Ctx, group: RepeatGroup): void => {
128
+ const facts = loopFacts(snapshot(group));
129
+ let codes = warned.get(ctx);
130
+ if (codes === undefined) {
131
+ codes = new Set();
132
+ warned.set(ctx, codes);
133
+ }
134
+ if (codes.has(facts.code)) return;
135
+ codes.add(facts.code);
136
+ warnLoop(facts);
137
+ };
138
+
139
+ const onStatement = (event: StatementEvent): void => {
140
+ if (event.expected !== undefined) return;
141
+ const ctx = tryUseContext();
142
+ if (ctx === undefined) return;
143
+ let groups = byRequest.get(ctx);
144
+ if (groups === undefined) {
145
+ groups = new Map();
146
+ byRequest.set(ctx, groups);
147
+ }
148
+ const fingerprint = statementFingerprint(event);
149
+ let group = groups.get(fingerprint);
150
+ if (group === undefined) {
151
+ group = {
152
+ fingerprint,
153
+ kind: statementKind(event.text),
154
+ attribution: event.attribution,
155
+ sample: event.text,
156
+ requestId: ctx.requestId,
157
+ traceId: ctx.traceId,
158
+ count: 0,
159
+ promoted: false,
160
+ };
161
+ groups.set(fingerprint, group);
162
+ }
163
+ // A statement that threw is still a statement: fifty identical timeouts are still a loop, and
164
+ // one that reports them as four is a loop nobody is told about.
165
+ group.count += 1;
166
+ if (group.promoted || group.count < threshold) return;
167
+ group.promoted = true;
168
+ reported.push(group);
169
+ if (reported.length > limit) reported.shift();
170
+ warnOnce(ctx, group);
171
+ };
172
+
173
+ return {
174
+ observer: { onStatement },
175
+ repeats(): readonly RepeatedStatement[] {
176
+ // Snapshotted, because the groups behind these are still counting — and newest first,
177
+ // matching the trace recorder: the loop that just happened is the one being looked at.
178
+ return reported.map(snapshot).reverse();
179
+ },
180
+ repeatsFor(ctx: Ctx): readonly RepeatedStatement[] {
181
+ // Read off this request's own tally rather than filtered out of `reported`, so a verdict the
182
+ // bound already dropped is still shown on the page it happened on.
183
+ const groups = byRequest.get(ctx);
184
+ if (groups === undefined) return [];
185
+ return [...groups.values()].filter((group) => group.promoted).map(snapshot);
186
+ },
187
+ reset(): void {
188
+ reported.length = 0;
189
+ },
190
+ };
191
+ }