From 946a63fe5354dd1ad8f27b8644f697570dad1484 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 23 Aug 2026 14:45:33 +0200 Subject: [PATCH] perf(api): minimize cold isolate initialization --- apps/api/scripts/bench-startup-cpu.ts | 12 +- apps/api/src/http/api-cors.test.ts | 54 ++++++++ apps/api/src/http/api-cors.ts | 26 ++++ apps/api/src/worker.ts | 120 ++++++++++++------ packages/domain/package.json | 2 + .../domain/scripts/gen-anticipated-errors.ts | 39 ++++++ .../domain/src/anticipated-errors-derive.ts | 56 ++++++++ .../domain/src/anticipated-errors.test.ts | 7 +- packages/domain/src/anticipated-errors.ts | 61 +-------- .../anticipated-error-identifiers.ts | 119 +++++++++++++++++ 10 files changed, 396 insertions(+), 100 deletions(-) create mode 100644 apps/api/src/http/api-cors.test.ts create mode 100644 packages/domain/scripts/gen-anticipated-errors.ts create mode 100644 packages/domain/src/anticipated-errors-derive.ts create mode 100644 packages/domain/src/generated/anticipated-error-identifiers.ts diff --git a/apps/api/scripts/bench-startup-cpu.ts b/apps/api/scripts/bench-startup-cpu.ts index c03bba54d..e0581bde8 100644 --- a/apps/api/scripts/bench-startup-cpu.ts +++ b/apps/api/scripts/bench-startup-cpu.ts @@ -12,8 +12,8 @@ // // Cloudflare error 10021 ("Script startup exceeded CPU time limit") fires during // upload validation, which runs ONLY the worker's top-level module scope against -// a fixed budget (~400ms documented; behaved like ~1s here). So the only thing -// that matters is: how much CPU does *constructing* these schemas burn at import? +// a fixed 1s budget. The relevant question is therefore: how much CPU does +// *constructing* these schemas burn at import? // // bun run scripts/bench-startup-cpu.ts # micro (default) // bun run scripts/bench-startup-cpu.ts micro --json @@ -35,9 +35,9 @@ import { join, resolve } from "node:path" import { Predicate, Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" -const CF_STARTUP_BUDGET_MS = 400 // documented startup CPU ceiling +// https://developers.cloudflare.com/workers/platform/limits/#worker-startup-time +const CF_STARTUP_BUDGET_MS = 1_000 const OBSERVED_BLOWUP_MS = 1000 // what the team saw blow up (per the fix session) -const POST_FIX_STARTUP_MS = 25 // post lazy-import startup CPU (per memory) type Sample = { wallMs: number; cpuMs: number } @@ -224,10 +224,10 @@ const runMicro = (opts: { ` evaluating the ENTIRE static import graph (all of @maple/domain + MCP tool/JSON-schema` + ` derivation + OpenApi.fromApi), not the error taxonomy — which is why the fix was deferring`, ) + console.log(` ./app behind a dynamic import, not trimming error classes.`) console.log( - ` ./app behind a dynamic import, not trimming error classes. Post-fix startup is ~${POST_FIX_STARTUP_MS} ms.`, + ` Use \`bun run scripts/bench-startup-cpu.ts worker\` for the current authoritative V8/workerd number.\n`, ) - console.log(` Authoritative V8/workerd number: \`bun run scripts/bench-startup-cpu.ts worker\`.\n`) } type CpuProfile = { diff --git a/apps/api/src/http/api-cors.test.ts b/apps/api/src/http/api-cors.test.ts new file mode 100644 index 000000000..5191502b2 --- /dev/null +++ b/apps/api/src/http/api-cors.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest" +import { Layer } from "effect" +import { HttpRouter, HttpServerResponse } from "effect/unstable/http" +import { API_CORS_OPTIONS, API_CORS_RESPONSE_HEADERS, apiCorsPreflightResponse } from "./api-cors" + +describe("apiCorsPreflightResponse", () => { + it("answers preflights with the global API CORS contract", async () => { + const response = apiCorsPreflightResponse() + + expect(response.status).toBe(204) + expect(await response.text()).toBe("") + expect(response.headers.get("access-control-allow-origin")).toBe( + API_CORS_RESPONSE_HEADERS["access-control-allow-origin"], + ) + expect(response.headers.get("vary")).toBe(API_CORS_RESPONSE_HEADERS.vary) + expect(response.headers.get("access-control-allow-methods")).toBe( + API_CORS_OPTIONS.allowedMethods.join(", "), + ) + expect(response.headers.get("access-control-allow-headers")).toBe( + API_CORS_OPTIONS.allowedHeaders.join(","), + ) + expect(response.headers.get("access-control-expose-headers")).toBe( + API_CORS_RESPONSE_HEADERS["access-control-expose-headers"], + ) + expect(response.headers.get("access-control-max-age")).toBe(String(API_CORS_OPTIONS.maxAge)) + }) + + it("stays byte-equivalent to Effect's configured CORS middleware", async () => { + const probe = HttpRouter.use((router) => + router.add("GET", "/probe", HttpServerResponse.text("probe")), + ).pipe(Layer.provideMerge(HttpRouter.cors(API_CORS_OPTIONS))) + const { handler, dispose } = HttpRouter.toWebHandler(probe, { disableLogger: true }) + try { + const request = new Request("https://api.example/probe", { + method: "OPTIONS", + headers: { + origin: "https://app.maple.dev", + "access-control-request-method": "GET", + "access-control-request-headers": "authorization,content-type", + }, + }) + const [fast, effect] = await Promise.all([ + Promise.resolve(apiCorsPreflightResponse()), + handler(request), + ]) + + expect(fast.status).toBe(effect.status) + expect([...fast.headers]).toEqual([...effect.headers]) + expect(await fast.text()).toBe(await effect.text()) + } finally { + await dispose() + } + }) +}) diff --git a/apps/api/src/http/api-cors.ts b/apps/api/src/http/api-cors.ts index 3dc2be388..b21b9910b 100644 --- a/apps/api/src/http/api-cors.ts +++ b/apps/api/src/http/api-cors.ts @@ -20,3 +20,29 @@ export const API_CORS_OPTIONS = { // for OPTIONS. Browsers clamp this value themselves (Chrome 2h, Firefox 24h). maxAge: 86_400, } + +/** Headers Effect's CORS middleware adds to every non-OPTIONS response. */ +export const API_CORS_RESPONSE_HEADERS = { + "access-control-allow-origin": API_CORS_OPTIONS.allowedOrigins[0], + vary: "Origin", + "access-control-expose-headers": API_CORS_OPTIONS.exposedHeaders.join(","), +} as const + +/** + * Bootstrap-safe equivalent of Effect's global CORS middleware for OPTIONS. + * + * The configured origin and header policies are static, so preflights do not + * need the route, service, auth, database, or telemetry graphs. Keep this in + * lockstep with `API_CORS_OPTIONS`; non-OPTIONS responses still receive their + * CORS headers from `HttpRouter.cors`. + */ +export const apiCorsPreflightResponse = (): Response => + new Response(null, { + status: 204, + headers: { + ...API_CORS_RESPONSE_HEADERS, + "access-control-allow-methods": API_CORS_OPTIONS.allowedMethods.join(", "), + "access-control-allow-headers": API_CORS_OPTIONS.allowedHeaders.join(","), + "access-control-max-age": String(API_CORS_OPTIONS.maxAge), + }, + }) diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index 25ff2c22f..022eae59e 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -17,6 +17,7 @@ import * as HttpPlatform from "effect/unstable/http/HttpPlatform" import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" import { serverErrorSpanMiddleware } from "./http/server-error-span" import { v2WorkerUnavailableResponse } from "./http/v2-worker-unavailable" +import { API_CORS_RESPONSE_HEADERS, apiCorsPreflightResponse } from "./http/api-cors" import { persistSession, preloadSession, type SessionsBinding } from "./mcp/lib/session-store" import { classifyWorkerQueue } from "./queue-dispatch" @@ -52,6 +53,11 @@ const WorkerPlatformLive = Layer.mergeAll( WorkerHttpPlatformLive, ) +// HttpRouter accepts an immutable request-local context. The worker does not +// inject per-request services here, so reuse the same empty value rather than +// rebuilding it on every invocation. +const HandlerContext = Context.empty() as never + // Construct telemetry once at module scope — `layer` is stable, `flush(env)` // resolves env lazily on first call. Including `telemetry.layer` in the // handler's layer composition is the critical bit: the Tracer reference must @@ -66,23 +72,6 @@ const telemetry = MapleCloudflareSDK.make({ anticipatedErrorIdentifiers: [...ANTICIPATED_ERROR_IDENTIFIERS, ...MCP_ANTICIPATED_ERROR_IDENTIFIERS], }) -// `HttpMiddleware.tracer` ends the root server span on a deferred macrotask -// (`scheduleTask(span.end, 0)`), but `telemetry.flush` drains synchronously. -// Flushing immediately after the response loses the server span — its macrotask -// hasn't fired yet. Isolated requests (e.g. a GitHub webhook) freeze the isolate -// before a subsequent request rescues it, so the trace is silently dropped. -// Yield one macrotask first so `span.end` runs before we drain. -// -// The SDK now owns this drain — `telemetry.flush` yields a macrotask inside its -// own serialized body. This local yield is kept deliberately redundant (an extra -// macrotask is harmless) so a version skew between the worker and the published -// SDK can't drop spans; remove it once the SDK version carrying that change is -// pinned here. -const flushTelemetry = async (env: Record): Promise => { - await new Promise((resolve) => setTimeout(resolve, 0)) - await telemetry.flush(env) -} - /** * Install one Postgres connection for the whole of `program`. * @@ -104,10 +93,17 @@ const scoped = async (program: Effect.Effect) => { // the top level near-empty; the cost moves to the first request, which runs // under the far larger per-request CPU budget. const buildHandler = async () => { - const { HttpServicesLive } = await import("./runtime/service-graph") - const { AllRoutes, ApiAuthLive, ApiObservabilityLive } = await import("./runtime/http-graph") - const { layerPg } = await import("@/platform/DatabasePgLive") - const { pgConnectionMiddleware } = await import("@/platform/pg-connection-scope") + const [ + { HttpServicesLive }, + { AllRoutes, ApiAuthLive, ApiObservabilityLive }, + { layerPg }, + { pgConnectionMiddleware }, + ] = await Promise.all([ + import("./runtime/service-graph"), + import("./runtime/http-graph"), + import("@/platform/DatabasePgLive"), + import("@/platform/pg-connection-scope"), + ]) // The worker's one per-request middleware stack. Ordering is load-bearing: // `serverErrorSpanMiddleware` must stay OUTERMOST (directly under // `HttpMiddleware.tracer`) so it converts a 5xx success into the failure the @@ -138,18 +134,28 @@ const buildHandler = async () => { // Single isolate-wide handler — `toWebHandler` builds its own ManagedRuntime // lazily on first invocation and keeps it for the lifetime of the isolate. -// Memoized via the build promise so concurrent first requests share one build; -// a construction failure surfaces as a 504 in `handle` rather than bricking the -// isolate. +// Memoized via the build promise so concurrent first requests share one build. +// A rejected build is cleared after those callers observe it, allowing a later +// request to recover instead of pinning the isolate to a rejected promise. let handlerPromise: ReturnType | undefined -const getHandler = () => (handlerPromise ??= buildHandler()) +const getHandler = (): ReturnType => { + if (handlerPromise !== undefined) return handlerPromise + const pending = buildHandler() + handlerPromise = pending + void pending.catch(() => { + if (handlerPromise === pending) handlerPromise = undefined + }) + return pending +} // RPC has no HttpApi request to construct the application services for it, so // it gets a sibling isolate-wide ManagedRuntime. Its headless service graph // stays behind a dynamic import, preserving the worker's startup-CPU budget. const buildRpcRuntime = async (env: Record) => { - const { InvestigationServicesLive } = await import("./runtime/mcp-service-graph") - const { layerPg } = await import("@/platform/DatabasePgLive") + const [{ InvestigationServicesLive }, { layerPg }] = await Promise.all([ + import("./runtime/mcp-service-graph"), + import("@/platform/DatabasePgLive"), + ]) return ManagedRuntime.make( InvestigationServicesLive.pipe( Layer.provideMerge(WorkerPlatformLive), @@ -162,7 +168,15 @@ const buildRpcRuntime = async (env: Record) => { } let rpcRuntimePromise: ReturnType | undefined -const getRpcRuntime = (env: Record) => (rpcRuntimePromise ??= buildRpcRuntime(env)) +const getRpcRuntime = (env: Record): ReturnType => { + if (rpcRuntimePromise !== undefined) return rpcRuntimePromise + const pending = buildRpcRuntime(env) + rpcRuntimePromise = pending + void pending.catch(() => { + if (rpcRuntimePromise === pending) rpcRuntimePromise = undefined + }) + return pending +} type InternalRpcMethod = "listMcpTools" | "callMcpTool" | "submitDiagnosis" @@ -210,7 +224,7 @@ const runInternalRpc = async ( exit = await runtime.runPromiseExit(await scoped(submitDiagnosisRpc(input))) break } - ctx.waitUntil(flushTelemetry(env)) + ctx.waitUntil(telemetry.flush(env)) if (exit._tag === "Success") return exit.value const defect = exit.cause.reasons.find(Cause.isDieReason) if (defect) throw defect.defect @@ -242,6 +256,26 @@ const isV2Request = (request: Request): boolean => { } } +/** + * Liveness does not need the domain graph, service graph, authentication, + * database scope, route codecs, or telemetry runtime. Keeping it + * bootstrap-safe also lets a cold isolate report health when an unrelated + * application binding is unavailable. + */ +const isHealthRequest = (request: Request): boolean => { + if (request.method !== "GET") return false + try { + return new URL(request.url).pathname === "/health" + } catch { + return false + } +} + +const healthResponse = (): Response => + new Response("OK", { + headers: { ...API_CORS_RESPONSE_HEADERS, "content-type": "text/plain; charset=utf-8" }, + }) + const readMcpSessionsBinding = (env: Record): SessionsBinding | undefined => { const candidate = env.MCP_SESSIONS if (candidate && typeof candidate === "object" && "get" in candidate && "put" in candidate) { @@ -283,16 +317,24 @@ const handle = async ( env: Record, ctx: ExecutionContext, ): Promise => { - const kv = readMcpSessionsBinding(env) + if (isHealthRequest(request)) return healthResponse() + if (request.method === "OPTIONS") return apiCorsPreflightResponse() + const isMcp = isMcpPost(request) + const kv = isMcp ? readMcpSessionsBinding(env) : undefined const reqSid = isMcp ? request.headers.get("mcp-session-id") : null + // Start the expensive cold handler build and the independent KV read before + // buffering an MCP body. Warm requests resolve both promises immediately; + // cold MCP requests hide module evaluation and KV latency behind body I/O. + const pendingHandler = getHandler() + const pendingSession = kv && reqSid ? preloadSession(kv, reqSid) : undefined // MCP diagnostics: buffer the body so we can peek the JSON-RPC method/id // before handing it off to Effect, then re-emit the request with the // buffered body so the inner handler still sees a readable stream. let forwardRequest = request let mcpFrame: McpFrame | null = null - const startedAt = Date.now() + const startedAt = isMcp ? Date.now() : undefined if (isMcp) { const bodyText = await request.text() mcpFrame = peekMcpFrame(bodyText) @@ -307,11 +349,11 @@ const handle = async ( ) } - if (kv && reqSid) await preloadSession(kv, reqSid) - try { - const { handler } = await getHandler() - const response = await handler(forwardRequest, Context.empty() as never) + const { handler } = pendingSession + ? (await Promise.all([pendingHandler, pendingSession]))[0] + : await pendingHandler + const response = await handler(forwardRequest, HandlerContext) if (kv && isMcp) { const resSid = response.headers.get("mcp-session-id") // Only persist when the server issued a new session — i.e. on @@ -323,7 +365,7 @@ const handle = async ( if (put) ctx.waitUntil(put) } } - if (isMcp && mcpFrame) { + if (isMcp && mcpFrame && startedAt !== undefined) { console.log( `[mcp-out] method=${mcpFrame.method} id=${mcpFrame.id}` + ` status=${response.status} dur=${Date.now() - startedAt}ms` + @@ -331,7 +373,7 @@ const handle = async ( ` resp_sid=${response.headers.get("mcp-session-id") ?? "-"}`, ) } - ctx.waitUntil(flushTelemetry(env)) + ctx.waitUntil(telemetry.flush(env)) return response } catch (err) { console.error("[worker] handler failed:", err) @@ -348,12 +390,12 @@ const handle = async ( Effect.provide(telemetry.layer), ), ) - if (isMcp && mcpFrame) { + if (isMcp && mcpFrame && startedAt !== undefined) { console.error( `[mcp-err] method=${mcpFrame.method} id=${mcpFrame.id}` + ` dur=${Date.now() - startedAt}ms`, ) } - ctx.waitUntil(flushTelemetry(env)) + ctx.waitUntil(telemetry.flush(env)) return isV2Request(request) ? v2WorkerUnavailableResponse() : new Response("The API worker is temporarily unavailable.", { status: 504 }) diff --git a/packages/domain/package.json b/packages/domain/package.json index ad3736edd..eea9cecf1 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -35,6 +35,8 @@ "./chat-session": "./src/chat-session.ts" }, "scripts": { + "gen:anticipated-errors": "bun scripts/gen-anticipated-errors.ts", + "gen:anticipated-errors:check": "bun scripts/gen-anticipated-errors.ts --check", "test": "vitest run", "typecheck": "tsc --noEmit" }, diff --git a/packages/domain/scripts/gen-anticipated-errors.ts b/packages/domain/scripts/gen-anticipated-errors.ts new file mode 100644 index 000000000..c29b6e813 --- /dev/null +++ b/packages/domain/scripts/gen-anticipated-errors.ts @@ -0,0 +1,39 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { dirname } from "node:path" +import { fileURLToPath } from "node:url" +import { deriveAnticipatedIdentifiers } from "../src/anticipated-errors-derive" + +const outputPath = fileURLToPath( + new URL("../src/generated/anticipated-error-identifiers.ts", import.meta.url), +) +const checkMode = process.argv.includes("--check") +const identifiers = [...deriveAnticipatedIdentifiers()].sort() +const renderedModule = `// This file is generated by packages/domain/scripts/gen-anticipated-errors.ts +// Do not edit manually. +// +// Anticipated 4xx error identifiers are reflected from the domain HTTP +// contracts outside production so worker startup imports only this literal. + +export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ +${identifiers.map((identifier) => `\t${JSON.stringify(identifier)},`).join("\n")} +] +` + +let existingModule = "" +try { + existingModule = readFileSync(outputPath, "utf8") +} catch { + existingModule = "" +} + +if (checkMode) { + if (existingModule !== renderedModule) { + console.error("Anticipated error identifiers are out of date. Run `bun run gen:anticipated-errors`.") + process.exit(1) + } + console.log(`Anticipated error identifiers are up to date (${identifiers.length} identifiers).`) +} else { + mkdirSync(dirname(outputPath), { recursive: true }) + writeFileSync(outputPath, renderedModule) + console.log(`Wrote ${identifiers.length} anticipated error identifiers to ${outputPath}.`) +} diff --git a/packages/domain/src/anticipated-errors-derive.ts b/packages/domain/src/anticipated-errors-derive.ts new file mode 100644 index 000000000..a970148c8 --- /dev/null +++ b/packages/domain/src/anticipated-errors-derive.ts @@ -0,0 +1,56 @@ +// BOUNDARY: Reflection reads heterogeneous schema/class exports and narrows every value before use. +// Reflection-based derivation of anticipated (4xx) error identifiers. +// +// This module intentionally imports the entire domain HTTP surface and must not +// be imported by a worker entrypoint. The generator and drift test pay that cost +// once outside production; runtime code consumes the generated literal list in +// `generated/anticipated-error-identifiers.ts`. +import * as Http from "./http/index" +import * as HttpV2 from "./http/v2/index" + +/** Read `obj[key]` when `obj` is an object/function that has it; `undefined` otherwise. */ +const prop = (obj: unknown, key: string): unknown => + (typeof obj === "object" || typeof obj === "function") && obj !== null && key in obj + ? (obj as Record)[key] + : undefined + +/** Stable runtime identifier from a v2 definition or tagged-error class. */ +const readIdentifier = (value: unknown): string | undefined => { + const tag = prop(value, "tag") + if (typeof tag === "string") return tag + const literal = prop(prop(prop(prop(value, "fields"), "_tag"), "schema"), "literal") + if (typeof literal === "string") return literal + const identifier = prop(value, "identifier") + return typeof identifier === "string" ? identifier : undefined +} + +/** The `httpApiStatus` annotation on a schema's AST, when present. */ +const readHttpStatus = (value: unknown): number | undefined => { + const status = prop(value, "status") + if (typeof status === "number") return status + const annotation = prop(prop(prop(value, "ast"), "annotations"), "httpApiStatus") + return typeof annotation === "number" ? annotation : undefined +} + +/** + * Identifiers that cannot be derived from Maple exports because Effect owns the + * class. `HttpApiSchemaError` is Effect's request-decode failure and responds + * with 400, so the HTTP 4xx → Ok telemetry rule applies. + */ +export const EXTERNAL_ANTICIPATED_IDENTIFIERS = ["HttpApiSchemaError"] as const + +const exportedValues = (namespace: Namespace): ReadonlyArray => + Object.values(namespace) + +/** Derive the full 4xx identifier set from the authoritative domain contracts. */ +export const deriveAnticipatedIdentifiers = (): ReadonlySet => { + const identifiers = new Set(EXTERNAL_ANTICIPATED_IDENTIFIERS) + for (const value of [...exportedValues(Http), ...exportedValues(HttpV2)]) { + const identifier = readIdentifier(value) + if (identifier === undefined) continue + const status = readHttpStatus(value) + if (status === undefined) continue + if (status >= 400 && status < 500) identifiers.add(identifier) + } + return identifiers +} diff --git a/packages/domain/src/anticipated-errors.test.ts b/packages/domain/src/anticipated-errors.test.ts index ae471941f..ae512ba9f 100644 --- a/packages/domain/src/anticipated-errors.test.ts +++ b/packages/domain/src/anticipated-errors.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest" import { ANTICIPATED_ERROR_IDENTIFIERS, isAnticipatedErrorIdentifier } from "./anticipated-errors" +import { deriveAnticipatedIdentifiers } from "./anticipated-errors-derive" describe("ANTICIPATED_ERROR_IDENTIFIERS", () => { + it("matches the reflection-derived set", () => { + expect([...ANTICIPATED_ERROR_IDENTIFIERS].sort()).toEqual([...deriveAnticipatedIdentifiers()].sort()) + }) + it("includes exact tagged-error identifiers for 4xx business errors", () => { for (const identifier of [ "@maple/http/errors/UnauthorizedError", @@ -26,7 +31,7 @@ describe("ANTICIPATED_ERROR_IDENTIFIERS", () => { } }) - it("derives a non-trivial set (reflection still works)", () => { + it("contains a non-trivial generated set", () => { expect(ANTICIPATED_ERROR_IDENTIFIERS.size).toBeGreaterThan(25) }) }) diff --git a/packages/domain/src/anticipated-errors.ts b/packages/domain/src/anticipated-errors.ts index a1a70fbbb..f4d3026e0 100644 --- a/packages/domain/src/anticipated-errors.ts +++ b/packages/domain/src/anticipated-errors.ts @@ -14,65 +14,18 @@ // rule (4xx → Ok, 5xx → Error). // // Derived (not hand-maintained) from the exported error classes and v2 -// definitions. Every class has a schema identifier plus an `httpApiStatus` -// annotation; every v2 definition exposes the same tag/status pair directly. -// A 5xx error (persistence/upstream failures) is intentionally excluded and -// keeps tracing. -import * as Http from "./http/index" -import * as HttpV2 from "./http/v2/index" - -/** Read `obj[key]` when `obj` is an object/function that has it; `undefined` otherwise. */ -const prop = (obj: unknown, key: string): unknown => - (typeof obj === "object" || typeof obj === "function") && obj !== null && key in obj - ? (obj as Record)[key] - : undefined - -/** Stable runtime identifier from a v2 definition or tagged-error class. */ -const readIdentifier = (value: unknown): string | undefined => { - const tag = prop(value, "tag") - if (typeof tag === "string") return tag - const literal = prop(prop(prop(prop(value, "fields"), "_tag"), "schema"), "literal") - if (typeof literal === "string") return literal - const identifier = prop(value, "identifier") - return typeof identifier === "string" ? identifier : undefined -} - -/** The `httpApiStatus` annotation on a schema's AST, when present. */ -const readHttpStatus = (value: unknown): number | undefined => { - const status = prop(value, "status") - if (typeof status === "number") return status - const annotation = prop(prop(prop(value, "ast"), "annotations"), "httpApiStatus") - return typeof annotation === "number" ? annotation : undefined -} - -/** - * Identifiers that can't be derived from our own exports because Effect owns the - * class. `HttpApiSchemaError` is Effect's request-decode failure — it always - * responds 400, so by the 4xx→Ok rule above it belongs here. It was also the - * worst offender for legibility: its `message` is its `kind`, so a failed decode - * arrived as an Error span whose entire description was the word "Payload". - */ -const EXTERNAL_ANTICIPATED_IDENTIFIERS = ["HttpApiSchemaError"] as const -const exportedValues = (namespace: Namespace): ReadonlyArray => - Object.values(namespace) - -const deriveAnticipatedIdentifiers = (): ReadonlySet => { - const identifiers = new Set(EXTERNAL_ANTICIPATED_IDENTIFIERS) - for (const value of [...exportedValues(Http), ...exportedValues(HttpV2)]) { - const identifier = readIdentifier(value) - if (identifier === undefined) continue - const status = readHttpStatus(value) - if (status === undefined) continue - if (status >= 400 && status < 500) identifiers.add(identifier) - } - return identifiers -} +// definitions, but at code-generation time rather than worker startup. Runtime +// reflection imported and evaluated the entire domain HTTP schema surface in +// every cold isolate. `anticipated-errors-derive.ts` retains that authoritative +// reflection for the generator and drift test; this hot module imports only its +// checked-in literal output. +import { ANTICIPATED_ERROR_IDENTIFIER_LIST } from "./generated/anticipated-error-identifiers" /** * Stable identifiers of all domain HTTP errors annotated with a 4xx `httpApiStatus`. * Tagged errors and v2 definitions both contribute their exact public `_tag`. */ -export const ANTICIPATED_ERROR_IDENTIFIERS: ReadonlySet = deriveAnticipatedIdentifiers() +export const ANTICIPATED_ERROR_IDENTIFIERS: ReadonlySet = new Set(ANTICIPATED_ERROR_IDENTIFIER_LIST) export const isAnticipatedErrorIdentifier = (identifier: string): boolean => ANTICIPATED_ERROR_IDENTIFIERS.has(identifier) diff --git a/packages/domain/src/generated/anticipated-error-identifiers.ts b/packages/domain/src/generated/anticipated-error-identifiers.ts new file mode 100644 index 000000000..0f79eaf19 --- /dev/null +++ b/packages/domain/src/generated/anticipated-error-identifiers.ts @@ -0,0 +1,119 @@ +// This file is generated by packages/domain/scripts/gen-anticipated-errors.ts +// Do not edit manually. +// +// Anticipated 4xx error identifiers are reflected from the domain HTTP +// contracts outside production so worker startup imports only this literal. + +export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ + "@maple/http/ai-triage/AiTriageForbiddenError", + "@maple/http/ai-triage/AiTriageValidationError", + "@maple/http/anomalies/AnomalyForbiddenError", + "@maple/http/anomalies/AnomalyIncidentNotFoundError", + "@maple/http/anomalies/AnomalyLinkedIssueNotFoundError", + "@maple/http/errors/ActorNotFoundError", + "@maple/http/errors/AlertDestinationInUseError", + "@maple/http/errors/AlertDestinationNotFoundError", + "@maple/http/errors/AlertForbiddenError", + "@maple/http/errors/AlertIncidentNotFoundError", + "@maple/http/errors/AlertRecipientSelectionError", + "@maple/http/errors/AlertRuleDestinationNotFoundError", + "@maple/http/errors/AlertRuleNotFoundError", + "@maple/http/errors/AlertValidationError", + "@maple/http/errors/ApiKeyNotFoundError", + "@maple/http/errors/BillingConflictError", + "@maple/http/errors/BillingForbiddenError", + "@maple/http/errors/BillingPaymentRequiredError", + "@maple/http/errors/BillingProfileUnavailableError", + "@maple/http/errors/BillingRateLimitedError", + "@maple/http/errors/BillingRequestError", + "@maple/http/errors/ChatToolInvalidInputError", + "@maple/http/errors/ChatToolNotApplicableError", + "@maple/http/errors/ChatToolNotFoundError", + "@maple/http/errors/CliDeviceConflictError", + "@maple/http/errors/CliDeviceExpiredError", + "@maple/http/errors/CliDeviceNotFoundError", + "@maple/http/errors/CliDeviceRateLimitError", + "@maple/http/errors/DashboardConcurrencyError", + "@maple/http/errors/DashboardNotFoundError", + "@maple/http/errors/DashboardTemplateNotFoundError", + "@maple/http/errors/DashboardValidationError", + "@maple/http/errors/DashboardVersionNotFoundError", + "@maple/http/errors/DigestNotFoundError", + "@maple/http/errors/ErrorForbiddenError", + "@maple/http/errors/ErrorIssueLeaseConflictError", + "@maple/http/errors/ErrorIssueNotFoundError", + "@maple/http/errors/ErrorIssueTransitionError", + "@maple/http/errors/ErrorValidationError", + "@maple/http/errors/IngestAttributeMappingNotFoundError", + "@maple/http/errors/IngestAttributeMappingValidationError", + "@maple/http/errors/IntegrationsForbiddenError", + "@maple/http/errors/IntegrationsNotConnectedError", + "@maple/http/errors/IntegrationsRevokedError", + "@maple/http/errors/IntegrationsValidationError", + "@maple/http/errors/McpOAuthAuthorizationConflictError", + "@maple/http/errors/McpOAuthAuthorizationExpiredError", + "@maple/http/errors/McpOAuthAuthorizationNotFoundError", + "@maple/http/errors/MobileDeviceNotFoundError", + "@maple/http/errors/OrgClickHouseSettingsForbiddenError", + "@maple/http/errors/OrgClickHouseSettingsUpstreamRejectedError", + "@maple/http/errors/OrgClickHouseSettingsValidationError", + "@maple/http/errors/OrganizationAccessDeniedError", + "@maple/http/errors/OrganizationForbiddenError", + "@maple/http/errors/QueryEngineValidationError", + "@maple/http/errors/RawSqlValidationError", + "@maple/http/errors/RecommendationIssueNotFoundError", + "@maple/http/errors/ScrapeTargetNotFoundError", + "@maple/http/errors/ScrapeTargetValidationError", + "@maple/http/errors/SelfHostedAuthDisabledError", + "@maple/http/errors/SelfHostedInvalidPasswordError", + "@maple/http/errors/ShareNotFoundError", + "@maple/http/errors/ShareRangeInvalidError", + "@maple/http/errors/ShareRateLimitedError", + "@maple/http/errors/ShareSignInRequiredError", + "@maple/http/errors/ShareUnsupportedWidgetError", + "@maple/http/errors/ShareVariableInvalidError", + "@maple/http/errors/ShareWidgetNotFoundError", + "@maple/http/errors/ShareWrongOrgError", + "@maple/http/errors/UnauthorizedError", + "@maple/http/errors/UnknownVcsProviderError", + "@maple/http/errors/VcsCommitNotFoundError", + "@maple/http/errors/VcsCommitShaInvalidError", + "@maple/http/errors/VcsInstallationGoneError", + "@maple/http/errors/VcsRateLimitedError", + "@maple/http/errors/VcsRepoUnavailableError", + "@maple/http/errors/VcsRepositoryBlockedError", + "@maple/http/errors/VcsWebhookParseError", + "@maple/http/errors/VcsWebhookSignatureError", + "@maple/http/errors/WarehouseQuotaExceededError", + "@maple/http/errors/WarehouseValidationError", + "@maple/http/investigations/InvestigationNotFoundError", + "@maple/http/investigations/InvestigationQuotaError", + "@maple/http/investigations/InvestigationValidationError", + "@maple/http/v1/V1RequestValidationError", + "@maple/http/v2/CursorInvalidError", + "@maple/http/v2/CursorSortMismatchError", + "@maple/http/v2/InsufficientPermissionsError", + "@maple/http/v2/InsufficientScopeError", + "@maple/http/v2/InvalidCredentialsError", + "@maple/http/v2/InvalidRequestError", + "@maple/http/v2/LogIdInvalidError", + "@maple/http/v2/LogNotFoundError", + "@maple/http/v2/LogQueryInvalidError", + "@maple/http/v2/MetricQueryInvalidError", + "@maple/http/v2/OrganizationAccessDeniedError", + "@maple/http/v2/ParameterInvalidError", + "@maple/http/v2/ParameterMissingError", + "@maple/http/v2/RateLimitError", + "@maple/http/v2/RouteNotFoundError", + "@maple/http/v2/ServiceNotFoundError", + "@maple/http/v2/SessionReplayNotFoundError", + "@maple/http/v2/SessionReplayRangeTooLargeError", + "@maple/http/v2/SpanNotFoundError", + "@maple/http/v2/TelemetryBreakdownFilterRequiredError", + "@maple/http/v2/TelemetryBucketCountTooLargeError", + "@maple/http/v2/TelemetryRangeTooLargeError", + "@maple/http/v2/TimeRangeInvalidError", + "@maple/http/v2/TraceNotFoundError", + "@maple/http/v2/TraceQueryInvalidError", + "HttpApiSchemaError", +]