feat(core-shared): add defineErrorMiddleware factory + export t
Factory takes [[ErrorCtor, TRPC_CODE], ...] tuples and returns a tRPC middleware that translates matching domain errors to TRPCError. Discrim- inates by instanceof; preserves original error as cause; unmapped errors propagate (tRPC then wraps them as INTERNAL_SERVER_ERROR with the original error as .cause — middleware does not interfere). core-shared never enumerates feature errors — each feature passes its own constructors in via integrations/api/procedures.ts (Tasks 3-7). Also exports the `t` instance from trpc/init.ts so feature procedure files can do t.procedure.use(...). Also fixes tsconfig.json: rootDir set to "." and @/* path alias added so test files using @/ resolve correctly under tsc --noEmit. Refactor log: §1, §2, §4 Spec: R13–R17
This commit is contained in:
@@ -15,11 +15,14 @@ doc-update items so docs are written once for the post-Plan-9 state.
|
|||||||
|
|
||||||
## 1. Files added
|
## 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
|
## 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)
|
## 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
|
## 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
|
## 5. Public-API surface
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./payload": "./src/payload/index.ts",
|
"./payload": "./src/payload/index.ts",
|
||||||
"./trpc/init": "./src/trpc/init.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": {
|
"scripts": {
|
||||||
"build": "tsc --noEmit",
|
"build": "tsc --noEmit",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
36
packages/core-shared/src/trpc/define-error-middleware.ts
Normal file
36
packages/core-shared/src/trpc/define-error-middleware.ts
Normal file
@@ -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<readonly [ErrorCtor, TRPC_ERROR_CODE_KEY]>,
|
||||||
|
) {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { initTRPC } from "@trpc/server";
|
import { initTRPC } from "@trpc/server";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
const t = initTRPC.create({
|
export const t = initTRPC.create({
|
||||||
transformer: superjson,
|
transformer: superjson,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
"extends": "@repo/core-typescript/base.json",
|
"extends": "@repo/core-typescript/base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
|
|||||||
Reference in New Issue
Block a user