@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.
- package/CLAUDE.md +761 -0
- package/README.md +42 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +134 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +219 -0
- package/src/cmd-db.ts +458 -153
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +92 -18
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +74 -10
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +14 -8
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +29 -24
- package/src/cmd-verify.ts +197 -25
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +269 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +144 -0
- package/src/db-seed.ts +294 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +108 -23
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +167 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +247 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +37 -7
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +78 -10
- package/src/error-catalog.ts +8 -18
- package/src/error-codes.ts +192 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +201 -138
- package/src/exec.ts +42 -8
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +67 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +92 -15
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +128 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +93 -2
- package/src/metrics-endpoint.ts +64 -16
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +185 -13
- package/src/shell-quote.ts +15 -0
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +20 -11
- package/src/test-workers.ts +50 -0
- package/src/ts-scan.ts +284 -15
- package/src/tsconfig-references.ts +103 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
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
|
-
/**
|
|
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
|
-
*
|
|
34
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
98
|
-
|
|
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: {
|
|
172
|
-
|
|
173
|
-
|
|
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;
|
package/src/dev-cache.ts
ADDED
|
@@ -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
|
+
}
|
package/src/dev-dashboard.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|