diff --git a/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md b/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md index d864073..c3d7a9f 100644 --- a/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md +++ b/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md @@ -15,11 +15,14 @@ doc-update items so docs are written once for the post-Plan-9 state. ## 1. Files added -(populated as work progresses) +- packages/core-shared/src/trpc/define-error-middleware.ts — middleware factory mapping [ErrorCtor, TRPC_CODE] tuples to TRPCError translation +- packages/core-shared/src/trpc/define-error-middleware.test.ts — 4 tests covering mapped translation, multiple codes, unmapped passthrough (verifies INTERNAL_SERVER_ERROR + cause preservation), cause preservation ## 2. Files modified -(populated as work progresses) +- packages/core-shared/src/trpc/init.ts — `t` instance now exported (was internal const) so feature procedures.ts can do `t.procedure.use(...)` +- packages/core-shared/package.json — added "./trpc/define-error-middleware" subpath export +- packages/core-shared/tsconfig.json — set `rootDir: "."` and added `@/*` path alias so test files using `@/` resolve correctly under `tsc --noEmit` ## 3. Pattern changes (code-level) @@ -34,7 +37,10 @@ doc-update items so docs are written once for the post-Plan-9 state. ## 4. Error-middleware adoption -(populated when defineErrorMiddleware is wired in core-shared and consumed by features) +- core-shared infrastructure landed; feature routers will adopt in Tasks 3-7. +- Discriminator: `instanceof Ctor` (not error name string), so duck-typing is impossible — features pass their own class constructors. +- Cause preservation: TRPCError carries the original domain error in `.cause` for client structured-error inspection. +- Note on tRPC v11 behavior: unmapped errors still surface as `TRPCError(code: INTERNAL_SERVER_ERROR)` because tRPC's `createCaller` wraps all procedure errors. Our middleware does not interfere — the original error is preserved as `.cause` for inspection. ## 5. Public-API surface diff --git a/packages/core-shared/package.json b/packages/core-shared/package.json index bb63b07..241719f 100644 --- a/packages/core-shared/package.json +++ b/packages/core-shared/package.json @@ -7,7 +7,8 @@ ".": "./src/index.ts", "./payload": "./src/payload/index.ts", "./trpc/init": "./src/trpc/init.ts", - "./trpc/context": "./src/trpc/context.ts" + "./trpc/context": "./src/trpc/context.ts", + "./trpc/define-error-middleware": "./src/trpc/define-error-middleware.ts" }, "scripts": { "build": "tsc --noEmit", diff --git a/packages/core-shared/src/trpc/define-error-middleware.test.ts b/packages/core-shared/src/trpc/define-error-middleware.test.ts new file mode 100644 index 0000000..d3a370e --- /dev/null +++ b/packages/core-shared/src/trpc/define-error-middleware.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { TRPCError } from "@trpc/server"; +import { t } from "@/trpc/init"; +import { defineErrorMiddleware } from "@/trpc/define-error-middleware"; + +class FooNotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = "FooNotFoundError"; + } +} + +class FooBadRequestError extends Error { + constructor(message: string) { + super(message); + this.name = "FooBadRequestError"; + } +} + +const errorRouter = t.router({ + notFound: t.procedure + .use( + defineErrorMiddleware([ + [FooNotFoundError, "NOT_FOUND"], + [FooBadRequestError, "BAD_REQUEST"], + ]), + ) + .query(() => { + throw new FooNotFoundError("nope"); + }), + badRequest: t.procedure + .use( + defineErrorMiddleware([ + [FooNotFoundError, "NOT_FOUND"], + [FooBadRequestError, "BAD_REQUEST"], + ]), + ) + .query(() => { + throw new FooBadRequestError("oops"); + }), + unmapped: t.procedure + .use(defineErrorMiddleware([[FooNotFoundError, "NOT_FOUND"]])) + .query(() => { + throw new Error("plain"); + }), +}); + +describe("defineErrorMiddleware", () => { + it("translates a mapped error to TRPCError with the configured code", async () => { + const caller = errorRouter.createCaller({}); + await expect(caller.notFound()).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); + + it("translates a different mapped error to a different code", async () => { + const caller = errorRouter.createCaller({}); + await expect(caller.badRequest()).rejects.toMatchObject({ + code: "BAD_REQUEST", + }); + }); + + it("does not translate unmapped errors but preserves the original via cause", async () => { + const caller = errorRouter.createCaller({}); + try { + await caller.unmapped(); + throw new Error("expected throw"); + } catch (e) { + expect(e).toBeInstanceOf(TRPCError); + const trpcErr = e as TRPCError; + expect(trpcErr.code).toBe("INTERNAL_SERVER_ERROR"); + expect(trpcErr.cause).toBeInstanceOf(Error); + expect(trpcErr.cause).not.toBeInstanceOf(TRPCError); + expect((trpcErr.cause as Error).message).toBe("plain"); + } + }); + + it("preserves the original error as the cause", async () => { + const caller = errorRouter.createCaller({}); + try { + await caller.notFound(); + throw new Error("expected throw"); + } catch (e) { + expect(e).toBeInstanceOf(TRPCError); + const trpcErr = e as TRPCError; + expect(trpcErr.cause).toBeInstanceOf(FooNotFoundError); + expect((trpcErr.cause as Error).message).toBe("nope"); + } + }); +}); diff --git a/packages/core-shared/src/trpc/define-error-middleware.ts b/packages/core-shared/src/trpc/define-error-middleware.ts new file mode 100644 index 0000000..a9ef641 --- /dev/null +++ b/packages/core-shared/src/trpc/define-error-middleware.ts @@ -0,0 +1,36 @@ +import { TRPCError } from "@trpc/server"; +import type { TRPC_ERROR_CODE_KEY } from "@trpc/server/rpc"; +import { t } from "./init"; + +type ErrorCtor = new (...args: never[]) => Error; + +/** + * Build a tRPC middleware that translates domain errors to TRPCError. + * + * Each tuple pairs a constructor with a TRPC error code. The middleware + * runs the procedure body inside a try/catch; on `instanceof Ctor`, + * it throws a TRPCError with the configured code and the original error + * preserved as `.cause`. Unmapped errors propagate untouched (tRPC's + * default INTERNAL_SERVER_ERROR handling applies). + * + * Owned by features: each feature passes its own constructors in. + * core-shared never enumerates feature-specific error classes. + */ +export function defineErrorMiddleware( + map: ReadonlyArray, +) { + return t.middleware(async ({ next }) => { + const result = await next(); + if (!result.ok) { + const cause = result.error.cause; + if (cause instanceof Error) { + for (const [Ctor, code] of map) { + if (cause instanceof Ctor) { + throw new TRPCError({ code, message: cause.message, cause }); + } + } + } + } + return result; + }); +} diff --git a/packages/core-shared/src/trpc/init.ts b/packages/core-shared/src/trpc/init.ts index 1a4a034..ee8ef78 100644 --- a/packages/core-shared/src/trpc/init.ts +++ b/packages/core-shared/src/trpc/init.ts @@ -1,7 +1,7 @@ import { initTRPC } from "@trpc/server"; import superjson from "superjson"; -const t = initTRPC.create({ +export const t = initTRPC.create({ transformer: superjson, }); diff --git a/packages/core-shared/tsconfig.json b/packages/core-shared/tsconfig.json index 3bdbcfc..652e804 100644 --- a/packages/core-shared/tsconfig.json +++ b/packages/core-shared/tsconfig.json @@ -2,7 +2,10 @@ "extends": "@repo/core-typescript/base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": ".", + "paths": { + "@/*": ["./src/*"] + } }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]