9 tasks, TDD throughout, one commit per task: 1. Refactor changelog scaffold 2. core-shared/trpc/define-error-middleware.ts factory + tests 3. auth migration (3 use cases — schemas + presenter + procedures.ts + router + ./ui + tests) 4. blog migration (3 use cases) 5. marketing-pages migration (2 use cases) 6. navigation migration (1 use case) 7. media migration (3 use cases) 8. Final verification + boundary sweep 9. ADR-013 + final changelog summary Every step has concrete code; no placeholders. Self-review shows full spec coverage (R1–R30) with each rule mapped to one or more tasks. Spec: docs/superpowers/specs/2026-05-06-input-output-unification-design.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2723 lines
100 KiB
Markdown
2723 lines
100 KiB
Markdown
# Plan 9 — Input/Output Unification + Presenter Pattern + Feature-Scoped Error Mapping + Public-Surface Cleanup
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Make every use case the single source of truth for its input AND output contract. Schemas live in the use-case file, are imported by both the controller and the tRPC procedure, and the use-case body runtime-validates output via `.parse(...)`. Controllers gain co-located `function presenter(...)` (Lazar pattern). Domain errors map to `TRPCError` via per-feature middleware (`defineErrorMiddleware` factory in `core-shared/trpc/`). Per-feature public surface is split: root `.` = contracts; new `./ui` subpath = UI artifacts.
|
||
|
||
**Architecture:** Use-case file becomes the only place an input/output shape is defined; everywhere else imports `xInputSchema` / `xOutputSchema` / `XInput` / `XOutput`. Each feature gets `integrations/api/procedures.ts` exporting an `xProcedure` built from `t.procedure.use(defineErrorMiddleware([[ErrorCtor, "TRPC_CODE"], ...]))`. Routers swap `publicProcedure` → `xProcedure` and `.input(z.object({...}))` → `.input(xInputSchema)`. Each feature's `src/index.ts` keeps only contracts (types, errors, schemas, `IXUseCase` aliases, router type, constants). Query builders move to `src/ui/index.ts` exposed via the new `./ui` subpath.
|
||
|
||
**Tech Stack:** TypeScript, Zod 3, tRPC 11, InversifyJS 6, Vitest 3, `@repo/core-shared`, `@repo/core-testing`.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-05-06-input-output-unification-design.md` — read first if any task is unclear.
|
||
|
||
**Branch:** `feature/io-unification` (or execute on `main` directly per project convention).
|
||
|
||
**Refactor changelog:** Maintain `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md` throughout — every task ends with a changelog update.
|
||
|
||
---
|
||
|
||
## Cross-cutting conventions (re-read at start of every task)
|
||
|
||
- **TDD always** — failing test, run, RED, implement, run, GREEN, refactor, commit.
|
||
- **Source files use relative imports**; test files use `@/` alias.
|
||
- **No central error map** — `core-shared` MUST NOT enumerate any feature's error class. Each feature's `procedures.ts` owns its tuples.
|
||
- **Object-shaped inputs only** — every `xInputSchema` is a `z.ZodObject` (use `z.object({}).strict()` for void; wrap primitive args in an object).
|
||
- **Presenter always for non-void output** — even identity `(x) => x` (R11). Void use cases skip the presenter (R12).
|
||
- **Don't touch CLAUDE.md / AGENTS.md / docs/guides/** during this plan — those updates are deferred per spec §10.
|
||
- **Commit per task** with the message in each task's final step.
|
||
- **After each task** — `pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries`. All green.
|
||
- **Update the refactor changelog** at the end of each task before commit.
|
||
|
||
---
|
||
|
||
## Task 1: Refactor changelog scaffold
|
||
|
||
**Files:**
|
||
- Create: `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md`
|
||
|
||
- [ ] **Step 1: Verify the directory exists**
|
||
|
||
```bash
|
||
ls docs/superpowers/refactor-logs/
|
||
```
|
||
|
||
Expected: existing `2026-05-05-lazar-pattern-conformance.md` is listed; the directory exists.
|
||
|
||
- [ ] **Step 2: Create the changelog file**
|
||
|
||
Write `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md` with this exact content:
|
||
|
||
```markdown
|
||
# Refactor Changelog — Input/Output Unification + Presenter + Error Middleware + Public-Surface Cleanup
|
||
|
||
**Started:** 2026-05-06
|
||
**Spec:** [2026-05-06-input-output-unification-design.md](../specs/2026-05-06-input-output-unification-design.md)
|
||
**Plan:** [2026-05-06-plan-9-io-unification.md](../plans/2026-05-06-plan-9-io-unification.md)
|
||
**Branch:** feature/io-unification
|
||
|
||
This document captures every architectural change made during Plan 9
|
||
execution, organized by category. After the plan is merged, use the
|
||
"Doc update checklist" at the bottom to update external docs in a
|
||
single follow-up pass — combined with the still-pending Plan 8
|
||
doc-update items so docs are written once for the post-Plan-9 state.
|
||
|
||
---
|
||
|
||
## 1. Files added
|
||
|
||
(populated as work progresses)
|
||
|
||
## 2. Files modified
|
||
|
||
(populated as work progresses)
|
||
|
||
## 3. Pattern changes (code-level)
|
||
|
||
### 3.1 Use-case files — input + output schemas + runtime parse
|
||
(populated when use cases are migrated)
|
||
|
||
### 3.2 Controller files — presenter + unknown input + view return type
|
||
(populated when controllers are migrated)
|
||
|
||
### 3.3 tRPC integration — feature-scoped procedures, schema reuse from use cases
|
||
(populated when routers are migrated)
|
||
|
||
## 4. Error-middleware adoption
|
||
|
||
(populated when defineErrorMiddleware is wired in core-shared and consumed by features)
|
||
|
||
## 5. Public-API surface
|
||
|
||
### 5.1 ./ui subpath added per feature
|
||
(populated as features adopt the subpath)
|
||
|
||
### 5.2 Feature root index.ts cleanup
|
||
(populated as features clean up their root exports)
|
||
|
||
## 6. Test additions
|
||
|
||
### 6.1 R25 — output-validation tests (use case)
|
||
(populated when use cases gain malformed-input "repo lied" tests)
|
||
|
||
### 6.2 R26 — router error-mapping tests
|
||
(populated when feature routers gain TRPCError-translation tests)
|
||
|
||
### 6.3 R27/R28 — presenter shape tests
|
||
(populated when controllers with non-identity presenters add view-shape assertions)
|
||
|
||
## 7. Open issues / deferred decisions
|
||
|
||
(populated as encountered)
|
||
|
||
---
|
||
|
||
## Doc update checklist (deferred — combined with paused Plan 8 doc pass)
|
||
|
||
After Plan 9 lands, the still-pending Plan 8 doc-update items resume
|
||
and now also pick up Plan 9 rules. Each item below covers BOTH plans'
|
||
material so we touch every file once.
|
||
|
||
- [ ] `CLAUDE.md` — Key Conventions: append schema-in-use-case rule, presenter rule, controller `unknown` input rule, `./ui` subpath rule, schema-export-from-root rule
|
||
- [ ] `AGENTS.md` (root) — Per-Package Conventions: document `./ui` subpath, schema reachability from feature root, factory-bound use cases / controllers (carry-over from Plan 8)
|
||
- [ ] `docs/guides/adding-a-feature.md` — restructure to use the Plan-9 use-case template (with input + output schemas + parse), the Plan-9 controller template (with presenter), and the new `procedures.ts` step (per-feature error map)
|
||
- [ ] `docs/guides/tdd-workflow.md` — update mock decision tree + worked example to factory injection (Plan 8) + R25 output-validation test pattern (Plan 9) + R26 router-error-mapping test pattern (Plan 9)
|
||
- [ ] `docs/guides/testing-strategy.md` — Mocking section: direct factory injection (Plan 8); R25/R26/R27/R28 test obligations (Plan 9)
|
||
- [ ] `docs/architecture/vertical-feature-spec.md` — §6 file shape: schemas + presenter + procedures.ts; §10 testing: R25/R26/R27/R28
|
||
- [ ] `docs/architecture/overview.md` — data-flow box: add input schema, output schema, presenter, error-middleware lanes
|
||
- [ ] `docs/architecture/dependency-flow.md` — verify (no expected change beyond a `./ui` mention)
|
||
- [ ] `docs/decisions/adr-012-lazar-conformance.md` — append note that input/output unification + presenter + error middleware land in ADR-013
|
||
- [ ] `docs/decisions/adr-013-input-output-unification.md` — created in this plan's final task (Task 9); link from prior ADRs
|
||
- [ ] Per-feature `AGENTS.md` (auth/blog/media/marketing-pages/navigation) — Plan 8 file paths + Plan 9 schema/presenter/procedures patterns
|
||
- [ ] `packages/core-testing/AGENTS.md` — note R25/R26 test patterns
|
||
- [ ] `packages/core-shared/AGENTS.md` — document `defineErrorMiddleware` and the `t` re-export
|
||
- [ ] Plan 8 plan/spec — add a one-line annotation that some controller/router patterns shifted in Plan 9; link to this changelog
|
||
|
||
---
|
||
|
||
## Notes for the doc-update pass author
|
||
|
||
When updating external docs, apply these substitutions globally:
|
||
|
||
| Old reference | New reference |
|
||
|---|---|
|
||
| Use case takes `{ x }` directly typed | Use case takes `XInput` (`z.infer<typeof xInputSchema>`); schema lives in same file |
|
||
| Use case returns `Promise<Entity>` | Use case returns `Promise<XOutput>` validated by `xOutputSchema.parse(...)` |
|
||
| Controller imports `z` and defines local `inputSchema` | Controller imports `xInputSchema` from the use-case file |
|
||
| Controller input typed `Partial<z.infer<...>>` | Controller input typed `unknown` |
|
||
| Controller returns `Promise<Entity>` | Controller has `function presenter(value: XOutput)`; returns `Promise<ReturnType<typeof presenter>>` (or `Promise<void>` for void use cases) |
|
||
| Router uses `publicProcedure` from `core-shared/trpc/init` | Router uses feature's `xProcedure` from `./procedures` |
|
||
| Router redefines input schema as `z.object({...})` in `.input(...)` | Router uses `.input(xInputSchema)` imported from the use-case file |
|
||
| Domain error reaches the wire as a generic `TRPCError({ code: "INTERNAL_SERVER_ERROR" })` | Domain error mapped to specific code by `defineErrorMiddleware` in `procedures.ts` |
|
||
| Frontend imports `articleBySlugQuery` from `@repo/blog` | Frontend imports from `@repo/blog/ui` |
|
||
```
|
||
|
||
- [ ] **Step 3: Verify the file is readable**
|
||
|
||
```bash
|
||
ls -la docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
|
||
head -20 docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
|
||
git commit -m "$(cat <<'EOF'
|
||
docs(refactor-log): scaffold Plan 9 input/output unification changelog
|
||
|
||
Empty section template plus the doc-update checklist that the
|
||
post-Plan-9 follow-up pass will work through (combined with the still-
|
||
pending Plan 8 items so docs are written once).
|
||
|
||
Spec: docs/superpowers/specs/2026-05-06-input-output-unification-design.md §10, R30.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: `core-shared` plumbing — `defineErrorMiddleware`
|
||
|
||
**Files:**
|
||
- Modify: `packages/core-shared/src/trpc/init.ts` (export `t` instance)
|
||
- Create: `packages/core-shared/src/trpc/define-error-middleware.ts`
|
||
- Create: `packages/core-shared/src/trpc/define-error-middleware.test.ts`
|
||
- Modify: `packages/core-shared/package.json` (add export `./trpc/define-error-middleware`)
|
||
|
||
- [ ] **Step 1: Read the current `init.ts`**
|
||
|
||
```bash
|
||
cat packages/core-shared/src/trpc/init.ts
|
||
```
|
||
|
||
Expected to see (verify exact content before editing):
|
||
|
||
```typescript
|
||
import { initTRPC } from "@trpc/server";
|
||
import superjson from "superjson";
|
||
|
||
const t = initTRPC.create({
|
||
transformer: superjson,
|
||
});
|
||
|
||
export const router = t.router;
|
||
export const publicProcedure = t.procedure;
|
||
export const middleware = t.middleware;
|
||
```
|
||
|
||
- [ ] **Step 2: Add `t` to the exports of `init.ts`**
|
||
|
||
Edit `packages/core-shared/src/trpc/init.ts`. Change `const t = initTRPC.create(...)` to `export const t = initTRPC.create(...)`. Final file:
|
||
|
||
```typescript
|
||
import { initTRPC } from "@trpc/server";
|
||
import superjson from "superjson";
|
||
|
||
export const t = initTRPC.create({
|
||
transformer: superjson,
|
||
});
|
||
|
||
export const router = t.router;
|
||
export const publicProcedure = t.procedure;
|
||
export const middleware = t.middleware;
|
||
```
|
||
|
||
- [ ] **Step 3: Write the failing test**
|
||
|
||
Create `packages/core-shared/src/trpc/define-error-middleware.test.ts`:
|
||
|
||
```typescript
|
||
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("rethrows unmapped errors without translation", async () => {
|
||
const caller = errorRouter.createCaller({});
|
||
await expect(caller.unmapped()).rejects.toBeInstanceOf(Error);
|
||
// Not a TRPCError — propagates so tRPC default handling kicks in
|
||
await expect(caller.unmapped()).rejects.not.toBeInstanceOf(TRPCError);
|
||
});
|
||
|
||
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");
|
||
}
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify RED**
|
||
|
||
```bash
|
||
pnpm test --filter @repo/core-shared -- define-error-middleware
|
||
```
|
||
|
||
Expected: FAIL with `Cannot find module '@/trpc/define-error-middleware'` (or equivalent — the module doesn't exist yet).
|
||
|
||
- [ ] **Step 5: Implement `defineErrorMiddleware`**
|
||
|
||
Create `packages/core-shared/src/trpc/define-error-middleware.ts`:
|
||
|
||
```typescript
|
||
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 }) => {
|
||
try {
|
||
return await next();
|
||
} catch (e) {
|
||
if (e instanceof Error) {
|
||
for (const [Ctor, code] of map) {
|
||
if (e instanceof Ctor) {
|
||
throw new TRPCError({ code, message: e.message, cause: e });
|
||
}
|
||
}
|
||
}
|
||
throw e;
|
||
}
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run the test to verify GREEN**
|
||
|
||
```bash
|
||
pnpm test --filter @repo/core-shared -- define-error-middleware
|
||
```
|
||
|
||
Expected: 4 tests pass.
|
||
|
||
- [ ] **Step 7: Add the new module to `package.json` exports**
|
||
|
||
Edit `packages/core-shared/package.json`. Add a new entry under `exports`:
|
||
|
||
```jsonc
|
||
{
|
||
"exports": {
|
||
".": "./src/index.ts",
|
||
"./payload": "./src/payload/index.ts",
|
||
"./trpc/init": "./src/trpc/init.ts",
|
||
"./trpc/context": "./src/trpc/context.ts",
|
||
"./trpc/define-error-middleware": "./src/trpc/define-error-middleware.ts"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Verify boundaries + typecheck**
|
||
|
||
```bash
|
||
pnpm install
|
||
pnpm typecheck
|
||
pnpm lint
|
||
pnpm test
|
||
pnpm turbo boundaries
|
||
```
|
||
|
||
All green. If `pnpm install` is a no-op because `package.json` only added an `exports` entry, that's fine.
|
||
|
||
- [ ] **Step 9: Update changelog**
|
||
|
||
Append to `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md`:
|
||
|
||
Under §1 Files added:
|
||
```
|
||
- 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, cause preservation
|
||
```
|
||
|
||
Under §2 Files modified:
|
||
```
|
||
- 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
|
||
```
|
||
|
||
Under §4 Error-middleware adoption:
|
||
```
|
||
- 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.
|
||
```
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add packages/core-shared docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
|
||
git commit -m "$(cat <<'EOF'
|
||
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.
|
||
|
||
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(...).
|
||
|
||
Refactor log: §1, §2, §4
|
||
Spec: R13–R17
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Migrate `auth` feature
|
||
|
||
**Files:**
|
||
- Modify: `packages/auth/src/application/use-cases/sign-in.use-case.ts`
|
||
- Modify: `packages/auth/src/application/use-cases/sign-in.use-case.test.ts`
|
||
- Modify: `packages/auth/src/application/use-cases/sign-up.use-case.ts`
|
||
- Modify: `packages/auth/src/application/use-cases/sign-up.use-case.test.ts`
|
||
- Modify: `packages/auth/src/application/use-cases/sign-out.use-case.ts`
|
||
- Modify: `packages/auth/src/application/use-cases/sign-out.use-case.test.ts`
|
||
- Modify: `packages/auth/src/interface-adapters/controllers/sign-in.controller.ts`
|
||
- Modify: `packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts`
|
||
- Modify: `packages/auth/src/interface-adapters/controllers/sign-up.controller.ts`
|
||
- Modify: `packages/auth/src/interface-adapters/controllers/sign-up.controller.test.ts`
|
||
- Modify: `packages/auth/src/interface-adapters/controllers/sign-out.controller.ts`
|
||
- Modify: `packages/auth/src/interface-adapters/controllers/sign-out.controller.test.ts`
|
||
- Create: `packages/auth/src/integrations/api/procedures.ts`
|
||
- Modify: `packages/auth/src/integrations/api/router.ts`
|
||
- Modify or Create: `packages/auth/src/integrations/api/router.test.ts` (add R26)
|
||
- Create: `packages/auth/src/ui/index.ts`
|
||
- Modify: `packages/auth/src/index.ts` (export schemas)
|
||
- Modify: `packages/auth/package.json` (add `./ui` subpath)
|
||
|
||
### 3.A — sign-in use case (input + output schemas + R25 test)
|
||
|
||
- [ ] **Step 1: Read current sign-in use case**
|
||
|
||
```bash
|
||
cat packages/auth/src/application/use-cases/sign-in.use-case.ts
|
||
```
|
||
|
||
Note current input shape (`{ username, password }`) and current return type (`Promise<{ session: Session; cookie: Cookie }>`).
|
||
|
||
- [ ] **Step 2: Write a failing R25 output-validation test FIRST**
|
||
|
||
Edit `packages/auth/src/application/use-cases/sign-in.use-case.test.ts`. Add a new test (keep existing tests intact):
|
||
|
||
```typescript
|
||
import { signInOutputSchema } from "@/application/use-cases/sign-in.use-case";
|
||
// ... (preserve other imports)
|
||
|
||
describe("signInUseCase output validation (R25)", () => {
|
||
it("throws when authenticationService returns a malformed session", async () => {
|
||
const users = new MockUsersRepository();
|
||
const seed = userFactory.build({ username: "alice" });
|
||
await users.createUser(seed);
|
||
|
||
const auth = {
|
||
verifyPassword: async () => true,
|
||
// session missing required fields → should fail signInOutputSchema.parse
|
||
createSession: async () => ({ session: { id: 123 }, cookie: null }),
|
||
} as unknown as IAuthenticationService;
|
||
|
||
const useCase = signInUseCase(users, auth);
|
||
await expect(useCase({ username: "alice", password: "x" })).rejects.toThrow(/parse|invalid/i);
|
||
});
|
||
|
||
it("exports an output schema that mirrors the success shape", () => {
|
||
expect(signInOutputSchema).toBeDefined();
|
||
const parsed = signInOutputSchema.safeParse({
|
||
session: { id: "s1", userId: "u1", expiresAt: new Date() },
|
||
cookie: { name: "session", value: "s1", attributes: {} },
|
||
});
|
||
expect(parsed.success).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
(Add `IAuthenticationService` to the imports if it's not already there; resolve from `@/application/services/authentication.service.interface`.)
|
||
|
||
- [ ] **Step 3: Run RED**
|
||
|
||
```bash
|
||
pnpm test --filter @repo/auth -- sign-in.use-case
|
||
```
|
||
|
||
Expected: new tests FAIL — `signInOutputSchema` is not exported.
|
||
|
||
- [ ] **Step 4: Update `sign-in.use-case.ts` with input + output schemas + parse**
|
||
|
||
Replace `packages/auth/src/application/use-cases/sign-in.use-case.ts` with:
|
||
|
||
```typescript
|
||
import { z } from "zod";
|
||
|
||
import { AuthenticationError } from "../../entities/errors/auth";
|
||
import { cookieSchema } from "../../entities/models/cookie";
|
||
import { sessionSchema } from "../../entities/models/session";
|
||
import type { IUsersRepository } from "../repositories/users.repository.interface";
|
||
import type { IAuthenticationService } from "../services/authentication.service.interface";
|
||
|
||
// ── Input ────────────────────────────────────────────────────────────────
|
||
export const signInInputSchema = z
|
||
.object({
|
||
username: z.string().min(3).max(31),
|
||
password: z.string().min(6).max(255),
|
||
})
|
||
.strict();
|
||
export type SignInInput = z.infer<typeof signInInputSchema>;
|
||
|
||
// ── Output ───────────────────────────────────────────────────────────────
|
||
export const signInOutputSchema = z.object({
|
||
session: sessionSchema,
|
||
cookie: cookieSchema,
|
||
});
|
||
export type SignInOutput = z.infer<typeof signInOutputSchema>;
|
||
|
||
// ── Use case ─────────────────────────────────────────────────────────────
|
||
export type ISignInUseCase = ReturnType<typeof signInUseCase>;
|
||
|
||
export const signInUseCase =
|
||
(usersRepository: IUsersRepository, authenticationService: IAuthenticationService) =>
|
||
async (input: SignInInput): Promise<SignInOutput> => {
|
||
const existingUser = await usersRepository.getUserByUsername(input.username);
|
||
if (!existingUser) {
|
||
throw new AuthenticationError("User does not exist");
|
||
}
|
||
const validPassword = await authenticationService.verifyPassword(
|
||
existingUser.passwordHash,
|
||
input.password,
|
||
);
|
||
if (!validPassword) {
|
||
throw new AuthenticationError("Incorrect username or password");
|
||
}
|
||
const result = await authenticationService.createSession(existingUser);
|
||
return signInOutputSchema.parse(result);
|
||
};
|
||
```
|
||
|
||
If `cookieSchema` and `sessionSchema` aren't already exported from their entity files, this is the moment to add the exports (they ARE typically exported per entity-models convention; verify):
|
||
|
||
```bash
|
||
grep -n "export const sessionSchema\|export const cookieSchema" packages/auth/src/entities/models/*.ts
|
||
```
|
||
|
||
If a schema export is missing, add it (the entity file already has the Zod schema definition; just `export` it).
|
||
|
||
- [ ] **Step 5: Run GREEN**
|
||
|
||
```bash
|
||
pnpm test --filter @repo/auth -- sign-in.use-case
|
||
```
|
||
|
||
Expected: all sign-in use-case tests pass (preexisting + new R25 ones).
|
||
|
||
### 3.B — sign-up use case (same pattern)
|
||
|
||
- [ ] **Step 6: Update `sign-up.use-case.ts` analogously**
|
||
|
||
Read the current file, then add:
|
||
|
||
```typescript
|
||
export const signUpInputSchema = z
|
||
.object({
|
||
username: z.string().min(3).max(31),
|
||
password: z.string().min(6).max(255),
|
||
confirmPassword: z.string().min(6).max(255),
|
||
})
|
||
.strict()
|
||
.refine((d) => d.password === d.confirmPassword, {
|
||
message: "Passwords do not match",
|
||
path: ["confirmPassword"],
|
||
});
|
||
export type SignUpInput = z.infer<typeof signUpInputSchema>;
|
||
|
||
export const signUpOutputSchema = z.object({
|
||
session: sessionSchema,
|
||
cookie: cookieSchema,
|
||
});
|
||
export type SignUpOutput = z.infer<typeof signUpOutputSchema>;
|
||
```
|
||
|
||
Update the factory signature: `(input: SignUpInput): Promise<SignUpOutput>`. End body with `return signUpOutputSchema.parse(result)`.
|
||
|
||
- [ ] **Step 7: Add R25 test for sign-up + run**
|
||
|
||
Mirror the sign-in R25 test in `sign-up.use-case.test.ts`, then:
|
||
|
||
```bash
|
||
pnpm test --filter @repo/auth -- sign-up.use-case
|
||
```
|
||
|
||
Expected: all green.
|
||
|
||
### 3.C — sign-out use case (void output)
|
||
|
||
- [ ] **Step 8: Update `sign-out.use-case.ts`**
|
||
|
||
Sign-out has input `{ sessionId: string }` and returns `void`. Per R5 (uniform input) + R12 (no presenter for void):
|
||
|
||
```typescript
|
||
import { z } from "zod";
|
||
import type { IAuthenticationService } from "../services/authentication.service.interface";
|
||
|
||
export const signOutInputSchema = z.object({ sessionId: z.string() }).strict();
|
||
export type SignOutInput = z.infer<typeof signOutInputSchema>;
|
||
|
||
// No xOutputSchema — use case returns void.
|
||
|
||
export type ISignOutUseCase = ReturnType<typeof signOutUseCase>;
|
||
|
||
export const signOutUseCase =
|
||
(authenticationService: IAuthenticationService) =>
|
||
async (input: SignOutInput): Promise<void> => {
|
||
await authenticationService.invalidateSession(input.sessionId);
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 9: Run sign-out tests**
|
||
|
||
```bash
|
||
pnpm test --filter @repo/auth -- sign-out.use-case
|
||
```
|
||
|
||
Expected: all green. (Sign-out has no R25 test — void output has nothing to validate.)
|
||
|
||
### 3.D — sign-in controller (presenter + unknown input)
|
||
|
||
- [ ] **Step 10: Update sign-in controller test FIRST**
|
||
|
||
Edit `packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts`. The new contract: controller takes `unknown`, returns the presenter's view (here: a Cookie). Change the existing tests to construct invalid input as `unknown` and verify the schema error path; verify the success path returns the cookie shape.
|
||
|
||
```typescript
|
||
import { describe, it, expect } from "vitest";
|
||
import { signInController } from "@/interface-adapters/controllers/sign-in.controller";
|
||
import { signInUseCase } from "@/application/use-cases/sign-in.use-case";
|
||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||
import { InputParseError } from "@/entities/errors/common";
|
||
import { userFactory } from "@/__factories__/user.factory";
|
||
|
||
describe("signInController", () => {
|
||
it("returns a cookie on successful sign-in", async () => {
|
||
const users = new MockUsersRepository();
|
||
const auth = new MockAuthenticationService(users);
|
||
const seedUser = userFactory.build({ username: "alice" });
|
||
await users.createUser(seedUser);
|
||
|
||
const useCase = signInUseCase(users, auth);
|
||
const controller = signInController(useCase);
|
||
|
||
const result = await controller({
|
||
username: "alice",
|
||
password: seedUser.passwordHash.replace("hashed_", ""),
|
||
});
|
||
expect(result).toBeDefined();
|
||
expect(result.name).toBeTruthy();
|
||
expect(result.value).toBeTruthy();
|
||
});
|
||
|
||
it("throws InputParseError on invalid input", async () => {
|
||
const users = new MockUsersRepository();
|
||
const auth = new MockAuthenticationService(users);
|
||
const useCase = signInUseCase(users, auth);
|
||
const controller = signInController(useCase);
|
||
|
||
await expect(controller({ username: "ab" } as unknown)).rejects.toBeInstanceOf(InputParseError);
|
||
});
|
||
|
||
it("throws InputParseError when input is not an object", async () => {
|
||
const users = new MockUsersRepository();
|
||
const auth = new MockAuthenticationService(users);
|
||
const useCase = signInUseCase(users, auth);
|
||
const controller = signInController(useCase);
|
||
|
||
await expect(controller("garbage" as unknown)).rejects.toBeInstanceOf(InputParseError);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 11: Run RED**
|
||
|
||
```bash
|
||
pnpm test --filter @repo/auth -- sign-in.controller
|
||
```
|
||
|
||
Expected: FAIL — controller still has old shape, third test (`"garbage"`) might pass by coincidence; the type signature check on input may be the visible failure.
|
||
|
||
- [ ] **Step 12: Update sign-in controller**
|
||
|
||
Replace `packages/auth/src/interface-adapters/controllers/sign-in.controller.ts` with:
|
||
|
||
```typescript
|
||
import { InputParseError } from "../../entities/errors/common";
|
||
import {
|
||
signInInputSchema,
|
||
type ISignInUseCase,
|
||
type SignInOutput,
|
||
} from "../../application/use-cases/sign-in.use-case";
|
||
|
||
function presenter(value: SignInOutput) {
|
||
return value.cookie;
|
||
}
|
||
|
||
export type ISignInController = ReturnType<typeof signInController>;
|
||
|
||
export const signInController =
|
||
(signInUseCase: ISignInUseCase) =>
|
||
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||
const parsed = signInInputSchema.safeParse(input);
|
||
if (!parsed.success) {
|
||
throw new InputParseError("Invalid sign-in input", { cause: parsed.error });
|
||
}
|
||
const result = await signInUseCase(parsed.data);
|
||
return presenter(result);
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 13: Run GREEN**
|
||
|
||
```bash
|
||
pnpm test --filter @repo/auth -- sign-in.controller
|
||
```
|
||
|
||
Expected: 3 tests pass.
|
||
|
||
### 3.E — sign-up controller (same pattern)
|
||
|
||
- [ ] **Step 14: Update `sign-up.controller.ts` and its test**
|
||
|
||
Test pattern mirrors sign-in. Controller pattern:
|
||
|
||
```typescript
|
||
import { InputParseError } from "../../entities/errors/common";
|
||
import {
|
||
signUpInputSchema,
|
||
type ISignUpUseCase,
|
||
type SignUpOutput,
|
||
} from "../../application/use-cases/sign-up.use-case";
|
||
|
||
function presenter(value: SignUpOutput) {
|
||
return value.cookie;
|
||
}
|
||
|
||
export type ISignUpController = ReturnType<typeof signUpController>;
|
||
|
||
export const signUpController =
|
||
(signUpUseCase: ISignUpUseCase) =>
|
||
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||
const parsed = signUpInputSchema.safeParse(input);
|
||
if (!parsed.success) {
|
||
throw new InputParseError("Invalid sign-up input", { cause: parsed.error });
|
||
}
|
||
const result = await signUpUseCase(parsed.data);
|
||
return presenter(result);
|
||
};
|
||
```
|
||
|
||
Run `pnpm test --filter @repo/auth -- sign-up.controller`. Expected: green.
|
||
|
||
### 3.F — sign-out controller (no presenter — void)
|
||
|
||
- [ ] **Step 15: Update `sign-out.controller.ts` and its test**
|
||
|
||
Controller (R12 — no presenter, returns `Promise<void>`):
|
||
|
||
```typescript
|
||
import { InputParseError } from "../../entities/errors/common";
|
||
import {
|
||
signOutInputSchema,
|
||
type ISignOutUseCase,
|
||
} from "../../application/use-cases/sign-out.use-case";
|
||
|
||
export type ISignOutController = ReturnType<typeof signOutController>;
|
||
|
||
export const signOutController =
|
||
(signOutUseCase: ISignOutUseCase) =>
|
||
async (input: unknown): Promise<void> => {
|
||
const parsed = signOutInputSchema.safeParse(input);
|
||
if (!parsed.success) {
|
||
throw new InputParseError("Invalid sign-out input", { cause: parsed.error });
|
||
}
|
||
await signOutUseCase(parsed.data);
|
||
};
|
||
```
|
||
|
||
Test pattern (the existing test likely passes a string sessionId — change to pass an object):
|
||
|
||
```typescript
|
||
import { describe, it, expect } from "vitest";
|
||
import { signOutController } from "@/interface-adapters/controllers/sign-out.controller";
|
||
import { signOutUseCase } from "@/application/use-cases/sign-out.use-case";
|
||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||
import { InputParseError } from "@/entities/errors/common";
|
||
|
||
describe("signOutController", () => {
|
||
it("returns void on successful sign-out", async () => {
|
||
const users = new MockUsersRepository();
|
||
const auth = new MockAuthenticationService(users);
|
||
const useCase = signOutUseCase(auth);
|
||
const controller = signOutController(useCase);
|
||
|
||
const result = await controller({ sessionId: "any" });
|
||
expect(result).toBeUndefined();
|
||
});
|
||
|
||
it("throws InputParseError on missing sessionId", async () => {
|
||
const users = new MockUsersRepository();
|
||
const auth = new MockAuthenticationService(users);
|
||
const useCase = signOutUseCase(auth);
|
||
const controller = signOutController(useCase);
|
||
|
||
await expect(controller({} as unknown)).rejects.toBeInstanceOf(InputParseError);
|
||
});
|
||
});
|
||
```
|
||
|
||
Run: `pnpm test --filter @repo/auth -- sign-out.controller`. Expected: green.
|
||
|
||
### 3.G — `procedures.ts` (auth error map)
|
||
|
||
- [ ] **Step 16: Create `packages/auth/src/integrations/api/procedures.ts`**
|
||
|
||
```typescript
|
||
import { t } from "@repo/core-shared/trpc/init";
|
||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||
|
||
import {
|
||
AuthenticationError,
|
||
UnauthenticatedError,
|
||
UnauthorizedError,
|
||
} from "../../entities/errors/auth";
|
||
import { InputParseError } from "../../entities/errors/common";
|
||
|
||
export const authProcedure = t.procedure.use(
|
||
defineErrorMiddleware([
|
||
[InputParseError, "BAD_REQUEST"],
|
||
[AuthenticationError, "UNAUTHORIZED"],
|
||
[UnauthenticatedError, "UNAUTHORIZED"],
|
||
[UnauthorizedError, "FORBIDDEN"],
|
||
]),
|
||
);
|
||
```
|
||
|
||
### 3.H — Router migration
|
||
|
||
- [ ] **Step 17: Replace router with feature procedure + schema imports**
|
||
|
||
Replace `packages/auth/src/integrations/api/router.ts`:
|
||
|
||
```typescript
|
||
import { router } from "@repo/core-shared/trpc/init";
|
||
|
||
import { authContainer } from "../../di/container";
|
||
import { AUTH_SYMBOLS } from "../../di/symbols";
|
||
|
||
import { signInInputSchema } from "../../application/use-cases/sign-in.use-case";
|
||
import { signUpInputSchema } from "../../application/use-cases/sign-up.use-case";
|
||
import { signOutInputSchema } from "../../application/use-cases/sign-out.use-case";
|
||
|
||
import type { ISignInController } from "../../interface-adapters/controllers/sign-in.controller";
|
||
import type { ISignUpController } from "../../interface-adapters/controllers/sign-up.controller";
|
||
import type { ISignOutController } from "../../interface-adapters/controllers/sign-out.controller";
|
||
|
||
import { authProcedure } from "./procedures";
|
||
|
||
export const authRouter = router({
|
||
signIn: authProcedure.input(signInInputSchema).mutation(({ input }) => {
|
||
const ctrl = authContainer.get<ISignInController>(AUTH_SYMBOLS.ISignInController);
|
||
return ctrl(input);
|
||
}),
|
||
|
||
signUp: authProcedure.input(signUpInputSchema).mutation(({ input }) => {
|
||
const ctrl = authContainer.get<ISignUpController>(AUTH_SYMBOLS.ISignUpController);
|
||
return ctrl(input);
|
||
}),
|
||
|
||
signOut: authProcedure.input(signOutInputSchema).mutation(({ input }) => {
|
||
const ctrl = authContainer.get<ISignOutController>(AUTH_SYMBOLS.ISignOutController);
|
||
return ctrl(input);
|
||
}),
|
||
});
|
||
|
||
export type AuthRouter = typeof authRouter;
|
||
```
|
||
|
||
### 3.I — Router test (R26 error mapping)
|
||
|
||
- [ ] **Step 18: Read existing `router.test.ts` (if any)**
|
||
|
||
```bash
|
||
ls packages/auth/src/integrations/api/
|
||
cat packages/auth/src/integrations/api/router.test.ts 2>/dev/null
|
||
```
|
||
|
||
If it doesn't exist, create it. If it exists, add R26 tests to it.
|
||
|
||
- [ ] **Step 19: Write/extend router test for R26**
|
||
|
||
Create or modify `packages/auth/src/integrations/api/router.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, it, expect, beforeEach } from "vitest";
|
||
import { TRPCError } from "@trpc/server";
|
||
|
||
import { authRouter } from "@/integrations/api/router";
|
||
import { authContainer } from "@/di/container";
|
||
import { AUTH_SYMBOLS } from "@/di/symbols";
|
||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||
import type { IUsersRepository } from "@/application/repositories/users.repository.interface";
|
||
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
|
||
|
||
describe("authRouter (R26 error mapping)", () => {
|
||
beforeEach(() => {
|
||
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
||
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
||
}
|
||
if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) {
|
||
authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService);
|
||
}
|
||
const users = new MockUsersRepository();
|
||
const auth = new MockAuthenticationService(users);
|
||
authContainer.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository).toConstantValue(users);
|
||
authContainer
|
||
.bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService)
|
||
.toConstantValue(auth);
|
||
});
|
||
|
||
it("translates AuthenticationError → UNAUTHORIZED on missing user", async () => {
|
||
const caller = authRouter.createCaller({});
|
||
try {
|
||
await caller.signIn({ username: "ghost", password: "long-enough" });
|
||
throw new Error("expected throw");
|
||
} catch (e) {
|
||
expect(e).toBeInstanceOf(TRPCError);
|
||
expect((e as TRPCError).code).toBe("UNAUTHORIZED");
|
||
}
|
||
});
|
||
|
||
it("translates BAD_REQUEST when zod parse fails at the procedure boundary", async () => {
|
||
const caller = authRouter.createCaller({});
|
||
try {
|
||
await caller.signIn({ username: "ab", password: "x" } as unknown as {
|
||
username: string;
|
||
password: string;
|
||
});
|
||
throw new Error("expected throw");
|
||
} catch (e) {
|
||
expect(e).toBeInstanceOf(TRPCError);
|
||
expect((e as TRPCError).code).toBe("BAD_REQUEST");
|
||
}
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 20: Run RED then GREEN**
|
||
|
||
```bash
|
||
pnpm test --filter @repo/auth -- router
|
||
```
|
||
|
||
Expected: tests pass against the new router.
|
||
|
||
### 3.J — Public-API surface cleanup
|
||
|
||
- [ ] **Step 21: Read current `src/index.ts`**
|
||
|
||
```bash
|
||
cat packages/auth/src/index.ts
|
||
```
|
||
|
||
(Currently exports `User`/`Session`/`Cookie`/`AuthRouter`/errors/`SESSION_COOKIE`. Auth has no query builders to move — its `ui/query.ts` is `export {}`.)
|
||
|
||
- [ ] **Step 22: Create `src/ui/index.ts`**
|
||
|
||
Auth has no UI artifacts yet. Create a placeholder so the `./ui` subpath resolves:
|
||
|
||
```typescript
|
||
// packages/auth/src/ui/index.ts
|
||
// Auth has no React Query option builders today (all auth procedures are
|
||
// mutations). This file is the public UI surface for future components
|
||
// and queries — extend rather than re-add to root index.ts.
|
||
export {};
|
||
```
|
||
|
||
If `packages/auth/src/ui/query.ts` exists with `export {};`, leave it (or delete; whichever is cleaner). Re-export from `index.ts`:
|
||
|
||
```bash
|
||
# verify
|
||
cat packages/auth/src/ui/query.ts
|
||
```
|
||
|
||
If it's just `export {}`, leave the file as-is — `ui/index.ts` is the new public surface.
|
||
|
||
- [ ] **Step 23: Update `src/index.ts` to export schemas + IUseCase aliases**
|
||
|
||
Replace `packages/auth/src/index.ts`:
|
||
|
||
```typescript
|
||
export type { User } from "./entities/models/user";
|
||
export type { Session } from "./entities/models/session";
|
||
export type { Cookie } from "./entities/models/cookie";
|
||
export type { AuthRouter } from "./integrations/api/router";
|
||
export {
|
||
AuthenticationError,
|
||
UnauthenticatedError,
|
||
UnauthorizedError,
|
||
} from "./entities/errors/auth";
|
||
export { InputParseError } from "./entities/errors/common";
|
||
export { SESSION_COOKIE } from "./config";
|
||
|
||
// Use case schemas + types (Plan 9 R18)
|
||
export {
|
||
signInInputSchema,
|
||
signInOutputSchema,
|
||
type SignInInput,
|
||
type SignInOutput,
|
||
type ISignInUseCase,
|
||
} from "./application/use-cases/sign-in.use-case";
|
||
export {
|
||
signUpInputSchema,
|
||
signUpOutputSchema,
|
||
type SignUpInput,
|
||
type SignUpOutput,
|
||
type ISignUpUseCase,
|
||
} from "./application/use-cases/sign-up.use-case";
|
||
export {
|
||
signOutInputSchema,
|
||
type SignOutInput,
|
||
type ISignOutUseCase,
|
||
} from "./application/use-cases/sign-out.use-case";
|
||
|
||
// Controller type aliases
|
||
export type { ISignInController } from "./interface-adapters/controllers/sign-in.controller";
|
||
export type { ISignUpController } from "./interface-adapters/controllers/sign-up.controller";
|
||
export type { ISignOutController } from "./interface-adapters/controllers/sign-out.controller";
|
||
```
|
||
|
||
- [ ] **Step 24: Add `./ui` subpath to `package.json`**
|
||
|
||
Edit `packages/auth/package.json`. Add to `exports`:
|
||
|
||
```jsonc
|
||
{
|
||
"exports": {
|
||
".": "./src/index.ts",
|
||
"./ui": "./src/ui/index.ts",
|
||
"./cms": "./src/integrations/cms/index.ts",
|
||
"./api": "./src/integrations/api/router.ts",
|
||
"./di/bind-production": "./src/di/bind-production.ts"
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.K — Verify
|
||
|
||
- [ ] **Step 25: Full validation**
|
||
|
||
```bash
|
||
pnpm install
|
||
pnpm typecheck
|
||
pnpm lint
|
||
pnpm test
|
||
pnpm turbo boundaries
|
||
```
|
||
|
||
All green. Tests should number the same as before plus 4 new ones (1× R25 sign-in, 1× R25 sign-up, 2× R26 router).
|
||
|
||
### 3.L — Update changelog
|
||
|
||
- [ ] **Step 26: Append auth entries to the refactor changelog**
|
||
|
||
Under §1 Files added:
|
||
```
|
||
- packages/auth/src/integrations/api/procedures.ts — authProcedure with feature error map (InputParse → BAD_REQUEST, Auth/Unauthenticated → UNAUTHORIZED, Unauthorized → FORBIDDEN)
|
||
- packages/auth/src/ui/index.ts — placeholder UI surface (no queries today; mutations only)
|
||
- packages/auth/src/integrations/api/router.test.ts — R26 error-mapping tests for signIn (UNAUTHORIZED on missing user, BAD_REQUEST on bad input)
|
||
```
|
||
|
||
Under §2 Files modified:
|
||
```
|
||
- packages/auth/src/application/use-cases/sign-in.use-case.ts — input + output schemas; output.parse before return; SignInInput/SignInOutput types exported
|
||
- packages/auth/src/application/use-cases/sign-up.use-case.ts — input + output schemas (with confirmPassword refine); output.parse; types exported
|
||
- packages/auth/src/application/use-cases/sign-out.use-case.ts — input schema only (void output, no presenter); SignOutInput exported
|
||
- packages/auth/src/interface-adapters/controllers/sign-in.controller.ts — presenter returning cookie; unknown input; ReturnType<typeof presenter> return type
|
||
- packages/auth/src/interface-adapters/controllers/sign-up.controller.ts — presenter returning cookie; unknown input
|
||
- packages/auth/src/interface-adapters/controllers/sign-out.controller.ts — no presenter (void); unknown input; Promise<void> return
|
||
- packages/auth/src/integrations/api/router.ts — uses authProcedure, .input(xInputSchema)
|
||
- packages/auth/src/index.ts — schemas + types now exported from feature root
|
||
- packages/auth/package.json — added ./ui subpath
|
||
- All affected use-case + controller tests
|
||
```
|
||
|
||
Under §3.1 / §3.2 / §3.3 / §5.1 / §5.2 / §6.1 / §6.2 — append the auth-specific summary one-liner (e.g., "auth migrated, all 3 use cases").
|
||
|
||
- [ ] **Step 27: Commit**
|
||
|
||
```bash
|
||
git add packages/auth packages/core-shared docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
|
||
git commit -m "$(cat <<'EOF'
|
||
refactor(auth): unify use-case I/O schemas + presenter + feature error map
|
||
|
||
Per Plan 9 (spec R1-R28):
|
||
- Use cases: input + output schemas (signIn, signUp); input-only for
|
||
signOut (void output). Use case body validates output via
|
||
outputSchema.parse before returning.
|
||
- Controllers: receive `unknown`; safeParse with the use-case schema;
|
||
presenter (returning cookie) for signIn/signUp; void return for
|
||
signOut.
|
||
- New integrations/api/procedures.ts with authProcedure built via
|
||
defineErrorMiddleware([[InputParseError,"BAD_REQUEST"],
|
||
[AuthenticationError,"UNAUTHORIZED"], [UnauthenticatedError,
|
||
"UNAUTHORIZED"], [UnauthorizedError,"FORBIDDEN"]]).
|
||
- Router uses authProcedure + .input(xInputSchema) for every procedure.
|
||
- src/index.ts exports schemas + types + IUseCase/IController aliases.
|
||
- package.json gains ./ui subpath; src/ui/index.ts placeholder
|
||
(auth has no query builders today).
|
||
- New tests: R25 output-validation per use case (signIn, signUp);
|
||
R26 router error-mapping (UNAUTHORIZED on missing user,
|
||
BAD_REQUEST on schema fail).
|
||
|
||
Refactor log: §1, §2, §3.1, §3.2, §3.3, §5.1, §5.2, §6.1, §6.2
|
||
Spec: R1–R6, R8–R15, R18, R19, R22–R26
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Migrate `blog` feature
|
||
|
||
**Files:**
|
||
- Modify: every blog use-case + test (`get-articles`, `create-article`, `get-article-by-slug`)
|
||
- Modify: every blog controller + test (same three)
|
||
- Create: `packages/blog/src/integrations/api/procedures.ts`
|
||
- Modify: `packages/blog/src/integrations/api/router.ts`
|
||
- Modify or Create: `packages/blog/src/integrations/api/router.test.ts`
|
||
- Modify: `packages/blog/src/index.ts`
|
||
- Move: `articleBySlugQuery`, `listArticlesQuery` from current `src/index.ts` → `src/ui/index.ts`
|
||
- Modify: `packages/blog/package.json` (add `./ui`)
|
||
|
||
### 4.A — `get-articles` use case
|
||
|
||
- [ ] **Step 1: Read current files for context**
|
||
|
||
```bash
|
||
cat packages/blog/src/application/use-cases/get-articles.use-case.ts
|
||
cat packages/blog/src/application/use-cases/get-articles.use-case.test.ts
|
||
```
|
||
|
||
Note: input is currently `options?: { status?, authorId?, limit?, offset? }`; output is `Promise<Article[]>`.
|
||
|
||
- [ ] **Step 2: Write failing R25 test for `get-articles`**
|
||
|
||
Add to `packages/blog/src/application/use-cases/get-articles.use-case.test.ts`:
|
||
|
||
```typescript
|
||
import { getArticlesOutputSchema } from "@/application/use-cases/get-articles.use-case";
|
||
import { z } from "zod";
|
||
|
||
describe("getArticlesUseCase output validation (R25)", () => {
|
||
it("throws when the repository returns a malformed article", async () => {
|
||
const repo = new MockArticlesRepository();
|
||
// bypass the mock's createArticle (which is typed) by reaching into _articles directly
|
||
(repo as unknown as { _articles: unknown[] })._articles.push({ id: 123 });
|
||
|
||
const useCase = getArticlesUseCase(repo);
|
||
await expect(useCase({})).rejects.toBeInstanceOf(z.ZodError);
|
||
});
|
||
|
||
it("exports an output schema that mirrors Article[]", () => {
|
||
expect(getArticlesOutputSchema).toBeDefined();
|
||
expect(getArticlesOutputSchema.safeParse([]).success).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run RED**
|
||
|
||
```bash
|
||
pnpm test --filter @repo/blog -- get-articles.use-case
|
||
```
|
||
|
||
Expected: FAIL — `getArticlesOutputSchema` does not exist; current `getArticlesUseCase` accepts `options?` not `XInput`.
|
||
|
||
- [ ] **Step 4: Update `get-articles.use-case.ts`**
|
||
|
||
Replace with:
|
||
|
||
```typescript
|
||
import { z } from "zod";
|
||
|
||
import { articleSchema, articleStatusSchema } from "../../entities/models/article";
|
||
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
|
||
|
||
export const getArticlesInputSchema = z
|
||
.object({
|
||
status: articleStatusSchema.optional(),
|
||
authorId: z.string().optional(),
|
||
limit: z.number().int().positive().optional(),
|
||
offset: z.number().int().nonnegative().optional(),
|
||
})
|
||
.strict();
|
||
export type GetArticlesInput = z.infer<typeof getArticlesInputSchema>;
|
||
|
||
export const getArticlesOutputSchema = z.array(articleSchema);
|
||
export type GetArticlesOutput = z.infer<typeof getArticlesOutputSchema>;
|
||
|
||
export type IGetArticlesUseCase = ReturnType<typeof getArticlesUseCase>;
|
||
|
||
export const getArticlesUseCase =
|
||
(articlesRepository: IArticlesRepository) =>
|
||
async (input: GetArticlesInput): Promise<GetArticlesOutput> => {
|
||
const result = await articlesRepository.getArticles(input);
|
||
return getArticlesOutputSchema.parse(result);
|
||
};
|
||
```
|
||
|
||
The existing `getArticlesUseCase()` (no-arg) call in tests breaks — update those calls to pass `{}`.
|
||
|
||
- [ ] **Step 5: Update existing `get-articles.use-case.test.ts` calls**
|
||
|
||
Find every `await useCase()` (no args) in this file and replace with `await useCase({})`. Also find any `await useCase({ status: "..." })` — those still work but verify they conform to the new schema.
|
||
|
||
- [ ] **Step 6: Run GREEN**
|
||
|
||
```bash
|
||
pnpm test --filter @repo/blog -- get-articles.use-case
|
||
```
|
||
|
||
Expected: all green (existing tests + 2 new R25 tests).
|
||
|
||
### 4.B — `create-article` use case
|
||
|
||
- [ ] **Step 7: Update `create-article.use-case.ts`**
|
||
|
||
```typescript
|
||
import { z } from "zod";
|
||
|
||
import { articleSchema } from "../../entities/models/article";
|
||
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
|
||
|
||
export const createArticleInputSchema = z
|
||
.object({
|
||
title: z.string().min(1).max(255),
|
||
content: z.unknown().optional(),
|
||
authorId: z.string(),
|
||
slug: z.string().optional(),
|
||
})
|
||
.strict();
|
||
export type CreateArticleInput = z.infer<typeof createArticleInputSchema>;
|
||
|
||
export const createArticleOutputSchema = articleSchema;
|
||
export type CreateArticleOutput = z.infer<typeof createArticleOutputSchema>;
|
||
|
||
function generateSlug(title: string): string {
|
||
return title
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, "-")
|
||
.replace(/^-+|-+$/g, "");
|
||
}
|
||
|
||
export type ICreateArticleUseCase = ReturnType<typeof createArticleUseCase>;
|
||
|
||
export const createArticleUseCase =
|
||
(articlesRepository: IArticlesRepository) =>
|
||
async (input: CreateArticleInput): Promise<CreateArticleOutput> => {
|
||
const now = new Date();
|
||
const article = {
|
||
id: crypto.randomUUID(),
|
||
title: input.title,
|
||
slug: input.slug ?? generateSlug(input.title),
|
||
content: input.content ?? null,
|
||
status: "draft" as const,
|
||
authorId: input.authorId,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
const result = await articlesRepository.createArticle(article);
|
||
return createArticleOutputSchema.parse(result);
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 8: Add R25 test for create-article**
|
||
|
||
In `create-article.use-case.test.ts`, add:
|
||
|
||
```typescript
|
||
import { createArticleOutputSchema } from "@/application/use-cases/create-article.use-case";
|
||
import { z } from "zod";
|
||
|
||
describe("createArticleUseCase output validation (R25)", () => {
|
||
it("throws when repository returns a malformed article", async () => {
|
||
const repo = {
|
||
createArticle: async () => ({ id: 1 }) as unknown as never,
|
||
} as unknown as IArticlesRepository;
|
||
const useCase = createArticleUseCase(repo);
|
||
await expect(
|
||
useCase({ title: "X", authorId: "u1" }),
|
||
).rejects.toBeInstanceOf(z.ZodError);
|
||
});
|
||
});
|
||
```
|
||
|
||
(Add `IArticlesRepository` to imports if needed.)
|
||
|
||
Run: `pnpm test --filter @repo/blog -- create-article.use-case`. Expected: green.
|
||
|
||
### 4.C — `get-article-by-slug` use case
|
||
|
||
- [ ] **Step 9: Update `get-article-by-slug.use-case.ts`**
|
||
|
||
```typescript
|
||
import { z } from "zod";
|
||
|
||
import { ArticleNotFoundError } from "../../entities/errors/article";
|
||
import { articleSchema } from "../../entities/models/article";
|
||
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
|
||
|
||
export const getArticleBySlugInputSchema = z
|
||
.object({ slug: z.string().min(1) })
|
||
.strict();
|
||
export type GetArticleBySlugInput = z.infer<typeof getArticleBySlugInputSchema>;
|
||
|
||
export const getArticleBySlugOutputSchema = articleSchema;
|
||
export type GetArticleBySlugOutput = z.infer<typeof getArticleBySlugOutputSchema>;
|
||
|
||
export type IGetArticleBySlugUseCase = ReturnType<typeof getArticleBySlugUseCase>;
|
||
|
||
export const getArticleBySlugUseCase =
|
||
(articlesRepository: IArticlesRepository) =>
|
||
async (input: GetArticleBySlugInput): Promise<GetArticleBySlugOutput> => {
|
||
const article = await articlesRepository.getArticleBySlug(input.slug);
|
||
if (!article) {
|
||
throw new ArticleNotFoundError(`Article with slug "${input.slug}" not found`);
|
||
}
|
||
return getArticleBySlugOutputSchema.parse(article);
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 10: Add R25 test for get-article-by-slug**
|
||
|
||
In `get-article-by-slug.use-case.test.ts`, add a test that the repository returning a malformed article triggers a parse error. Run test. Green.
|
||
|
||
### 4.D — Controllers (3) — presenter (identity for now) + unknown input
|
||
|
||
- [ ] **Step 11: Update `get-articles.controller.ts`**
|
||
|
||
```typescript
|
||
import { InputParseError } from "../../entities/errors/common";
|
||
import {
|
||
getArticlesInputSchema,
|
||
type GetArticlesOutput,
|
||
type IGetArticlesUseCase,
|
||
} from "../../application/use-cases/get-articles.use-case";
|
||
|
||
function presenter(value: GetArticlesOutput) {
|
||
// identity for now (R11 — every non-void controller has a presenter)
|
||
return value;
|
||
}
|
||
|
||
export type IGetArticlesController = ReturnType<typeof getArticlesController>;
|
||
|
||
export const getArticlesController =
|
||
(getArticlesUseCase: IGetArticlesUseCase) =>
|
||
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||
const parsed = getArticlesInputSchema.safeParse(input);
|
||
if (!parsed.success) {
|
||
throw new InputParseError("Invalid get-articles input", { cause: parsed.error });
|
||
}
|
||
const result = await getArticlesUseCase(parsed.data);
|
||
return presenter(result);
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 12: Update `get-articles.controller.test.ts` calls**
|
||
|
||
The current test calls `controller({})` (still works) and `controller({ status: "published" })` (still works). The third test uses `{ limit: "not a number" }` cast — still works because schema rejects. No changes needed *unless* the call signature of `controller` changed (input is now `unknown` instead of `Partial<...>`). Check test compiles:
|
||
|
||
```bash
|
||
pnpm typecheck --filter @repo/blog
|
||
```
|
||
|
||
If TS complains about `controller({ ... } as ...)`, simplify by passing the value directly (the param is `unknown`, anything fits):
|
||
|
||
```typescript
|
||
await expect(controller({ limit: "not a number" })).rejects.toBeInstanceOf(InputParseError);
|
||
```
|
||
|
||
Run tests. Green.
|
||
|
||
- [ ] **Step 13: Update `create-article.controller.ts`**
|
||
|
||
```typescript
|
||
import { InputParseError } from "../../entities/errors/common";
|
||
import {
|
||
createArticleInputSchema,
|
||
type CreateArticleOutput,
|
||
type ICreateArticleUseCase,
|
||
} from "../../application/use-cases/create-article.use-case";
|
||
|
||
function presenter(value: CreateArticleOutput) {
|
||
return value;
|
||
}
|
||
|
||
export type ICreateArticleController = ReturnType<typeof createArticleController>;
|
||
|
||
export const createArticleController =
|
||
(createArticleUseCase: ICreateArticleUseCase) =>
|
||
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||
const parsed = createArticleInputSchema.safeParse(input);
|
||
if (!parsed.success) {
|
||
throw new InputParseError("Invalid create-article input", { cause: parsed.error });
|
||
}
|
||
const result = await createArticleUseCase(parsed.data);
|
||
return presenter(result);
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 14: Update `get-article-by-slug.controller.ts`**
|
||
|
||
```typescript
|
||
import { InputParseError } from "../../entities/errors/common";
|
||
import {
|
||
getArticleBySlugInputSchema,
|
||
type GetArticleBySlugOutput,
|
||
type IGetArticleBySlugUseCase,
|
||
} from "../../application/use-cases/get-article-by-slug.use-case";
|
||
|
||
function presenter(value: GetArticleBySlugOutput) {
|
||
return value;
|
||
}
|
||
|
||
export type IGetArticleBySlugController = ReturnType<typeof getArticleBySlugController>;
|
||
|
||
export const getArticleBySlugController =
|
||
(getArticleBySlugUseCase: IGetArticleBySlugUseCase) =>
|
||
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||
const parsed = getArticleBySlugInputSchema.safeParse(input);
|
||
if (!parsed.success) {
|
||
throw new InputParseError("Invalid get-article-by-slug input", { cause: parsed.error });
|
||
}
|
||
const result = await getArticleBySlugUseCase(parsed.data);
|
||
return presenter(result);
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 15: Run all blog controller tests**
|
||
|
||
```bash
|
||
pnpm test --filter @repo/blog -- controllers
|
||
```
|
||
|
||
Expected: green. Update any test failures by removing `as Partial<...>` casts (input is now `unknown`).
|
||
|
||
### 4.E — `procedures.ts` (blog error map)
|
||
|
||
- [ ] **Step 16: Create `packages/blog/src/integrations/api/procedures.ts`**
|
||
|
||
```typescript
|
||
import { t } from "@repo/core-shared/trpc/init";
|
||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||
|
||
import { ArticleNotFoundError } from "../../entities/errors/article";
|
||
import { InputParseError } from "../../entities/errors/common";
|
||
|
||
export const blogProcedure = t.procedure.use(
|
||
defineErrorMiddleware([
|
||
[InputParseError, "BAD_REQUEST"],
|
||
[ArticleNotFoundError, "NOT_FOUND"],
|
||
]),
|
||
);
|
||
```
|
||
|
||
### 4.F — Router migration
|
||
|
||
- [ ] **Step 17: Replace `packages/blog/src/integrations/api/router.ts`**
|
||
|
||
```typescript
|
||
import { router } from "@repo/core-shared/trpc/init";
|
||
|
||
import { blogContainer } from "../../di/container";
|
||
import { BLOG_SYMBOLS } from "../../di/symbols";
|
||
|
||
import { getArticlesInputSchema } from "../../application/use-cases/get-articles.use-case";
|
||
import { createArticleInputSchema } from "../../application/use-cases/create-article.use-case";
|
||
import { getArticleBySlugInputSchema } from "../../application/use-cases/get-article-by-slug.use-case";
|
||
|
||
import type { IGetArticlesController } from "../../interface-adapters/controllers/get-articles.controller";
|
||
import type { ICreateArticleController } from "../../interface-adapters/controllers/create-article.controller";
|
||
import type { IGetArticleBySlugController } from "../../interface-adapters/controllers/get-article-by-slug.controller";
|
||
|
||
import { blogProcedure } from "./procedures";
|
||
|
||
export const blogRouter = router({
|
||
articleBySlug: blogProcedure
|
||
.input(getArticleBySlugInputSchema)
|
||
.query(({ input }) => {
|
||
const ctrl = blogContainer.get<IGetArticleBySlugController>(
|
||
BLOG_SYMBOLS.IGetArticleBySlugController,
|
||
);
|
||
return ctrl(input);
|
||
}),
|
||
|
||
listArticles: blogProcedure
|
||
.input(getArticlesInputSchema)
|
||
.query(({ input }) => {
|
||
const ctrl = blogContainer.get<IGetArticlesController>(
|
||
BLOG_SYMBOLS.IGetArticlesController,
|
||
);
|
||
return ctrl(input);
|
||
}),
|
||
|
||
createArticle: blogProcedure
|
||
.input(createArticleInputSchema)
|
||
.mutation(({ input }) => {
|
||
const ctrl = blogContainer.get<ICreateArticleController>(
|
||
BLOG_SYMBOLS.ICreateArticleController,
|
||
);
|
||
return ctrl(input);
|
||
}),
|
||
});
|
||
|
||
export type BlogRouter = typeof blogRouter;
|
||
```
|
||
|
||
### 4.G — Router test (R26)
|
||
|
||
- [ ] **Step 18: Create or extend `packages/blog/src/integrations/api/router.test.ts`**
|
||
|
||
```typescript
|
||
import { describe, it, expect, beforeEach } from "vitest";
|
||
import { TRPCError } from "@trpc/server";
|
||
|
||
import { blogRouter } from "@/integrations/api/router";
|
||
import { blogContainer } from "@/di/container";
|
||
import { BLOG_SYMBOLS } from "@/di/symbols";
|
||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
|
||
|
||
describe("blogRouter (R26 error mapping)", () => {
|
||
beforeEach(() => {
|
||
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
|
||
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
|
||
}
|
||
blogContainer
|
||
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
|
||
.toConstantValue(new MockArticlesRepository());
|
||
});
|
||
|
||
it("translates ArticleNotFoundError → NOT_FOUND", async () => {
|
||
const caller = blogRouter.createCaller({});
|
||
try {
|
||
await caller.articleBySlug({ slug: "missing" });
|
||
throw new Error("expected throw");
|
||
} catch (e) {
|
||
expect(e).toBeInstanceOf(TRPCError);
|
||
expect((e as TRPCError).code).toBe("NOT_FOUND");
|
||
}
|
||
});
|
||
|
||
it("translates zod parse failure → BAD_REQUEST", async () => {
|
||
const caller = blogRouter.createCaller({});
|
||
try {
|
||
await caller.articleBySlug({} as unknown as { slug: string });
|
||
throw new Error("expected throw");
|
||
} catch (e) {
|
||
expect(e).toBeInstanceOf(TRPCError);
|
||
expect((e as TRPCError).code).toBe("BAD_REQUEST");
|
||
}
|
||
});
|
||
});
|
||
```
|
||
|
||
### 4.H — Public-API surface
|
||
|
||
- [ ] **Step 19: Create `packages/blog/src/ui/index.ts`**
|
||
|
||
The current `src/index.ts` re-exports `articleBySlugQuery` and `listArticlesQuery` from `./ui/query`. Move those re-exports to `src/ui/index.ts`:
|
||
|
||
```typescript
|
||
// packages/blog/src/ui/index.ts
|
||
export { articleBySlugQuery, listArticlesQuery } from "./query";
|
||
```
|
||
|
||
- [ ] **Step 20: Update `packages/blog/src/index.ts` (remove queries; add schemas)**
|
||
|
||
```typescript
|
||
export type { Article, ArticleStatus } from "./entities/models/article";
|
||
export type { BlogRouter } from "./integrations/api/router";
|
||
export { ArticleNotFoundError } from "./entities/errors/article";
|
||
export { InputParseError } from "./entities/errors/common";
|
||
|
||
// Use case schemas + types (Plan 9 R18)
|
||
export {
|
||
getArticlesInputSchema,
|
||
getArticlesOutputSchema,
|
||
type GetArticlesInput,
|
||
type GetArticlesOutput,
|
||
type IGetArticlesUseCase,
|
||
} from "./application/use-cases/get-articles.use-case";
|
||
export {
|
||
createArticleInputSchema,
|
||
createArticleOutputSchema,
|
||
type CreateArticleInput,
|
||
type CreateArticleOutput,
|
||
type ICreateArticleUseCase,
|
||
} from "./application/use-cases/create-article.use-case";
|
||
export {
|
||
getArticleBySlugInputSchema,
|
||
getArticleBySlugOutputSchema,
|
||
type GetArticleBySlugInput,
|
||
type GetArticleBySlugOutput,
|
||
type IGetArticleBySlugUseCase,
|
||
} from "./application/use-cases/get-article-by-slug.use-case";
|
||
|
||
// Controller type aliases
|
||
export type { IGetArticlesController } from "./interface-adapters/controllers/get-articles.controller";
|
||
export type { ICreateArticleController } from "./interface-adapters/controllers/create-article.controller";
|
||
export type { IGetArticleBySlugController } from "./interface-adapters/controllers/get-article-by-slug.controller";
|
||
```
|
||
|
||
(Note: `articleBySlugQuery` and `listArticlesQuery` are no longer here.)
|
||
|
||
- [ ] **Step 21: Update `packages/blog/package.json`**
|
||
|
||
```jsonc
|
||
{
|
||
"exports": {
|
||
".": "./src/index.ts",
|
||
"./ui": "./src/ui/index.ts",
|
||
"./cms": "./src/integrations/cms/index.ts",
|
||
"./api": "./src/integrations/api/router.ts",
|
||
"./di/bind-production": "./src/di/bind-production.ts"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 22: Confirm no app/package imports the moved queries from `@repo/blog`**
|
||
|
||
```bash
|
||
grep -rln 'from "@repo/blog"' apps/ packages/ | xargs -I{} grep -l 'articleBySlugQuery\|listArticlesQuery' {} 2>/dev/null
|
||
```
|
||
|
||
Expected: empty (no consumers today). If any are found, change them to `from "@repo/blog/ui"`.
|
||
|
||
### 4.I — Verify + changelog + commit
|
||
|
||
- [ ] **Step 23: Full validation**
|
||
|
||
```bash
|
||
pnpm install
|
||
pnpm typecheck
|
||
pnpm lint
|
||
pnpm test
|
||
pnpm turbo boundaries
|
||
```
|
||
|
||
All green.
|
||
|
||
- [ ] **Step 24: Update changelog with blog entries** (mirror auth section: §1 added — procedures.ts, ui/index.ts, router.test.ts; §2 modified — three use cases, three controllers, router, src/index.ts, package.json + tests; §3.1–3.3, §5.1–5.2, §6.1–6.2 entries).
|
||
|
||
- [ ] **Step 25: Commit**
|
||
|
||
```bash
|
||
git add packages/blog docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
|
||
git commit -m "$(cat <<'EOF'
|
||
refactor(blog): unify use-case I/O schemas + presenter + feature error map
|
||
|
||
Per Plan 9 (spec R1-R28):
|
||
- Use cases: input + output schemas (getArticles, createArticle,
|
||
getArticleBySlug). Output validated via outputSchema.parse before
|
||
return. status field uses articleStatusSchema (was loose `string`).
|
||
- Controllers: receive `unknown`; safeParse with use-case schema;
|
||
identity presenter (R11) on every controller.
|
||
- New integrations/api/procedures.ts with blogProcedure
|
||
([InputParseError → BAD_REQUEST], [ArticleNotFoundError → NOT_FOUND]).
|
||
- Router uses blogProcedure + .input(xInputSchema) for all 3 procedures.
|
||
- src/index.ts: remove articleBySlugQuery/listArticlesQuery re-exports;
|
||
export schemas + types + IUseCase/IController aliases.
|
||
- src/ui/index.ts (NEW): query builders moved here; package.json adds
|
||
./ui subpath.
|
||
- New tests: R25 output-validation per use case; R26 router error-
|
||
mapping (NOT_FOUND on missing slug, BAD_REQUEST on schema fail).
|
||
|
||
Refactor log: §1, §2, §3.1, §3.2, §3.3, §5.1, §5.2, §6.1, §6.2
|
||
Spec: R1–R6, R8–R15, R18–R20, R22–R26
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Migrate `marketing-pages` feature
|
||
|
||
Mirror Task 4 structure for two use cases (`get-page-by-slug`, `get-site-settings`) and two controllers. Site-settings has VOID input (`getSiteSettings()`) per current code — the schema becomes `z.object({}).strict()` (R5).
|
||
|
||
**Files:** all `packages/marketing-pages/src/application/use-cases/*.{ts,test.ts}`, all `packages/marketing-pages/src/interface-adapters/controllers/*.{ts,test.ts}`, `integrations/api/procedures.ts` (new), `integrations/api/router.ts`, `integrations/api/router.test.ts`, `src/index.ts`, `src/ui/index.ts` (new), `package.json`.
|
||
|
||
### 5.A — `get-page-by-slug` use case
|
||
|
||
- [ ] **Step 1: Read current files**
|
||
|
||
```bash
|
||
cat packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts
|
||
cat packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.test.ts
|
||
```
|
||
|
||
- [ ] **Step 2: Rewrite use case**
|
||
|
||
```typescript
|
||
// packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts
|
||
import { z } from "zod";
|
||
|
||
import { PageNotFoundError } from "../../entities/errors/page";
|
||
import { pageSchema } from "../../entities/models/page";
|
||
import type { IPagesRepository } from "../repositories/pages.repository.interface";
|
||
|
||
export const getPageBySlugInputSchema = z.object({ slug: z.string().min(1) }).strict();
|
||
export type GetPageBySlugInput = z.infer<typeof getPageBySlugInputSchema>;
|
||
|
||
export const getPageBySlugOutputSchema = pageSchema;
|
||
export type GetPageBySlugOutput = z.infer<typeof getPageBySlugOutputSchema>;
|
||
|
||
export type IGetPageBySlugUseCase = ReturnType<typeof getPageBySlugUseCase>;
|
||
|
||
export const getPageBySlugUseCase =
|
||
(pagesRepository: IPagesRepository) =>
|
||
async (input: GetPageBySlugInput): Promise<GetPageBySlugOutput> => {
|
||
const page = await pagesRepository.getPageBySlug(input.slug);
|
||
if (!page) {
|
||
throw new PageNotFoundError(`Page with slug "${input.slug}" not found`);
|
||
}
|
||
return getPageBySlugOutputSchema.parse(page);
|
||
};
|
||
```
|
||
|
||
(Verify the current behavior — if `getPageBySlugUseCase` currently returns `undefined` for missing pages instead of throwing, this changes semantics. Read the existing file first; if it returns undefined, KEEP the undefined-return behavior and adjust the schema:
|
||
|
||
```typescript
|
||
export const getPageBySlugOutputSchema = pageSchema;
|
||
// ...
|
||
async (input: GetPageBySlugInput): Promise<GetPageBySlugOutput | undefined> => {
|
||
const page = await pagesRepository.getPageBySlug(input.slug);
|
||
if (!page) return undefined;
|
||
return getPageBySlugOutputSchema.parse(page);
|
||
};
|
||
```
|
||
|
||
Either direction is fine — choose to match the existing test expectations.)
|
||
|
||
- [ ] **Step 3: Add R25 test, run RED → GREEN.**
|
||
|
||
### 5.B — `get-site-settings` use case (void input)
|
||
|
||
- [ ] **Step 4: Rewrite use case**
|
||
|
||
```typescript
|
||
// packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.ts
|
||
import { z } from "zod";
|
||
|
||
import { siteSettingsSchema } from "../../entities/models/site-settings";
|
||
import type { ISiteSettingsRepository } from "../repositories/site-settings.repository.interface";
|
||
|
||
export const getSiteSettingsInputSchema = z.object({}).strict();
|
||
export type GetSiteSettingsInput = z.infer<typeof getSiteSettingsInputSchema>;
|
||
|
||
export const getSiteSettingsOutputSchema = siteSettingsSchema;
|
||
export type GetSiteSettingsOutput = z.infer<typeof getSiteSettingsOutputSchema>;
|
||
|
||
export type IGetSiteSettingsUseCase = ReturnType<typeof getSiteSettingsUseCase>;
|
||
|
||
export const getSiteSettingsUseCase =
|
||
(siteSettingsRepository: ISiteSettingsRepository) =>
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
async (_input: GetSiteSettingsInput): Promise<GetSiteSettingsOutput> => {
|
||
const result = await siteSettingsRepository.getSiteSettings();
|
||
return getSiteSettingsOutputSchema.parse(result);
|
||
};
|
||
```
|
||
|
||
The `_input` is required by R5 (uniform input). The `_` prefix tells lint it's intentional.
|
||
|
||
- [ ] **Step 5: Update existing tests** to pass `{}` as the input (was no-arg). Add R25 test. Run.
|
||
|
||
### 5.C — Controllers
|
||
|
||
- [ ] **Step 6: Update `get-page-by-slug.controller.ts`** with presenter (identity), `unknown` input, schema import. Pattern identical to blog.
|
||
|
||
- [ ] **Step 7: Update `get-site-settings.controller.ts`** similarly. Note: must call `controller({})` in tests.
|
||
|
||
- [ ] **Step 8: Update both controller tests; run all green.**
|
||
|
||
### 5.D — `procedures.ts`
|
||
|
||
- [ ] **Step 9: Create `packages/marketing-pages/src/integrations/api/procedures.ts`**
|
||
|
||
```typescript
|
||
import { t } from "@repo/core-shared/trpc/init";
|
||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||
|
||
import { PageNotFoundError } from "../../entities/errors/page";
|
||
import { InputParseError } from "../../entities/errors/common";
|
||
|
||
export const marketingPagesProcedure = t.procedure.use(
|
||
defineErrorMiddleware([
|
||
[InputParseError, "BAD_REQUEST"],
|
||
[PageNotFoundError, "NOT_FOUND"],
|
||
]),
|
||
);
|
||
```
|
||
|
||
### 5.E — Router
|
||
|
||
- [ ] **Step 10: Update router**
|
||
|
||
```typescript
|
||
import { router } from "@repo/core-shared/trpc/init";
|
||
|
||
import { marketingPagesContainer } from "../../di/container";
|
||
import { MARKETING_PAGES_SYMBOLS } from "../../di/symbols";
|
||
|
||
import { getPageBySlugInputSchema } from "../../application/use-cases/get-page-by-slug.use-case";
|
||
import { getSiteSettingsInputSchema } from "../../application/use-cases/get-site-settings.use-case";
|
||
|
||
import type { IGetPageBySlugController } from "../../interface-adapters/controllers/get-page-by-slug.controller";
|
||
import type { IGetSiteSettingsController } from "../../interface-adapters/controllers/get-site-settings.controller";
|
||
|
||
import { marketingPagesProcedure } from "./procedures";
|
||
|
||
export const marketingPagesRouter = router({
|
||
pageBySlug: marketingPagesProcedure
|
||
.input(getPageBySlugInputSchema)
|
||
.query(({ input }) => {
|
||
const ctrl = marketingPagesContainer.get<IGetPageBySlugController>(
|
||
MARKETING_PAGES_SYMBOLS.IGetPageBySlugController,
|
||
);
|
||
return ctrl(input);
|
||
}),
|
||
|
||
siteSettings: marketingPagesProcedure
|
||
.input(getSiteSettingsInputSchema)
|
||
.query(({ input }) => {
|
||
const ctrl = marketingPagesContainer.get<IGetSiteSettingsController>(
|
||
MARKETING_PAGES_SYMBOLS.IGetSiteSettingsController,
|
||
);
|
||
return ctrl(input);
|
||
}),
|
||
});
|
||
|
||
export type MarketingPagesRouter = typeof marketingPagesRouter;
|
||
```
|
||
|
||
### 5.F — Router test (R26)
|
||
|
||
- [ ] **Step 11: Add R26 test** — assert `PageNotFoundError → NOT_FOUND` and zod-parse → `BAD_REQUEST` (mirrors blog router test).
|
||
|
||
### 5.G — Public-API surface
|
||
|
||
- [ ] **Step 12: Create `packages/marketing-pages/src/ui/index.ts`**
|
||
|
||
```typescript
|
||
export { pageBySlugQuery, siteSettingsQuery } from "./query";
|
||
```
|
||
|
||
- [ ] **Step 13: Update `packages/marketing-pages/src/index.ts`**
|
||
|
||
Remove `pageBySlugQuery, siteSettingsQuery` exports; add schemas + types + IUseCase / IController aliases (mirroring blog's pattern).
|
||
|
||
- [ ] **Step 14: Add `./ui` to `package.json`.**
|
||
|
||
### 5.H — Verify + changelog + commit
|
||
|
||
- [ ] **Step 15: Full validation pass.**
|
||
|
||
```bash
|
||
pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries
|
||
```
|
||
|
||
- [ ] **Step 16: Update changelog with marketing-pages entries.**
|
||
|
||
- [ ] **Step 17: Commit**
|
||
|
||
```bash
|
||
git add packages/marketing-pages docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
|
||
git commit -m "$(cat <<'EOF'
|
||
refactor(marketing-pages): unify use-case I/O schemas + presenter + feature error map
|
||
|
||
Per Plan 9 (spec R1-R28):
|
||
- Use cases: input + output schemas (getPageBySlug, getSiteSettings).
|
||
Site-settings input is z.object({}).strict() per R5 (uniform input).
|
||
- Controllers: unknown input + identity presenter; void output not
|
||
applicable (both use cases return data).
|
||
- New integrations/api/procedures.ts with marketingPagesProcedure
|
||
([InputParseError → BAD_REQUEST], [PageNotFoundError → NOT_FOUND]).
|
||
- Router uses marketingPagesProcedure + .input(xInputSchema).
|
||
- src/index.ts: remove pageBySlugQuery/siteSettingsQuery; export
|
||
schemas + types + IUseCase/IController aliases.
|
||
- src/ui/index.ts (NEW); package.json adds ./ui subpath.
|
||
- R25 output-validation tests + R26 router error-mapping test.
|
||
|
||
Refactor log: §1, §2, §3.1, §3.2, §3.3, §5.1, §5.2, §6.1, §6.2
|
||
Spec: R1–R6, R8–R15, R18–R20, R22–R26
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Migrate `navigation` feature
|
||
|
||
Single use case (`get-header`) — void input, non-void output.
|
||
|
||
**Files:** `application/use-cases/get-header.use-case.{ts,test.ts}`, `interface-adapters/controllers/get-header.controller.{ts,test.ts}`, `integrations/api/procedures.ts` (new), `integrations/api/router.ts`, `integrations/api/router.test.ts`, `src/index.ts`, `src/ui/index.ts` (new), `package.json`.
|
||
|
||
- [ ] **Step 1: Update `get-header.use-case.ts`**
|
||
|
||
```typescript
|
||
import { z } from "zod";
|
||
|
||
import { HeaderNotFoundError } from "../../entities/errors/header";
|
||
import { headerSchema } from "../../entities/models/header";
|
||
import type { IHeaderRepository } from "../repositories/header.repository.interface";
|
||
|
||
export const getHeaderInputSchema = z.object({}).strict();
|
||
export type GetHeaderInput = z.infer<typeof getHeaderInputSchema>;
|
||
|
||
export const getHeaderOutputSchema = headerSchema;
|
||
export type GetHeaderOutput = z.infer<typeof getHeaderOutputSchema>;
|
||
|
||
export type IGetHeaderUseCase = ReturnType<typeof getHeaderUseCase>;
|
||
|
||
export const getHeaderUseCase =
|
||
(headerRepository: IHeaderRepository) =>
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
async (_input: GetHeaderInput): Promise<GetHeaderOutput> => {
|
||
const header = await headerRepository.getHeader();
|
||
if (!header) {
|
||
throw new HeaderNotFoundError("Header global not found");
|
||
}
|
||
return getHeaderOutputSchema.parse(header);
|
||
};
|
||
```
|
||
|
||
(Adjust the `if (!header) throw` only if the existing use case throws on missing; if it returns undefined, mirror that. Read first.)
|
||
|
||
- [ ] **Step 2: Update tests to pass `{}` to the use case + add R25 test.**
|
||
|
||
- [ ] **Step 3: Update `get-header.controller.ts`**
|
||
|
||
```typescript
|
||
import { InputParseError } from "../../entities/errors/common";
|
||
import {
|
||
getHeaderInputSchema,
|
||
type GetHeaderOutput,
|
||
type IGetHeaderUseCase,
|
||
} from "../../application/use-cases/get-header.use-case";
|
||
|
||
function presenter(value: GetHeaderOutput) {
|
||
return value;
|
||
}
|
||
|
||
export type IGetHeaderController = ReturnType<typeof getHeaderController>;
|
||
|
||
export const getHeaderController =
|
||
(getHeaderUseCase: IGetHeaderUseCase) =>
|
||
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||
const parsed = getHeaderInputSchema.safeParse(input);
|
||
if (!parsed.success) {
|
||
throw new InputParseError("Invalid get-header input", { cause: parsed.error });
|
||
}
|
||
const result = await getHeaderUseCase(parsed.data);
|
||
return presenter(result);
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: Update controller test (call with `{}`).**
|
||
|
||
- [ ] **Step 5: Create `packages/navigation/src/integrations/api/procedures.ts`**
|
||
|
||
```typescript
|
||
import { t } from "@repo/core-shared/trpc/init";
|
||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||
|
||
import { HeaderNotFoundError } from "../../entities/errors/header";
|
||
import { InputParseError } from "../../entities/errors/common";
|
||
|
||
export const navigationProcedure = t.procedure.use(
|
||
defineErrorMiddleware([
|
||
[InputParseError, "BAD_REQUEST"],
|
||
[HeaderNotFoundError, "NOT_FOUND"],
|
||
]),
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 6: Update router**
|
||
|
||
```typescript
|
||
import { router } from "@repo/core-shared/trpc/init";
|
||
|
||
import { navigationContainer } from "../../di/container";
|
||
import { NAVIGATION_SYMBOLS } from "../../di/symbols";
|
||
|
||
import { getHeaderInputSchema } from "../../application/use-cases/get-header.use-case";
|
||
import type { IGetHeaderController } from "../../interface-adapters/controllers/get-header.controller";
|
||
|
||
import { navigationProcedure } from "./procedures";
|
||
|
||
export const navigationRouter = router({
|
||
header: navigationProcedure
|
||
.input(getHeaderInputSchema)
|
||
.query(({ input }) => {
|
||
const ctrl = navigationContainer.get<IGetHeaderController>(
|
||
NAVIGATION_SYMBOLS.IGetHeaderController,
|
||
);
|
||
return ctrl(input);
|
||
}),
|
||
});
|
||
|
||
export type NavigationRouter = typeof navigationRouter;
|
||
```
|
||
|
||
- [ ] **Step 7: Add R26 test in `router.test.ts`** — `HeaderNotFoundError → NOT_FOUND` and bad input → `BAD_REQUEST`.
|
||
|
||
- [ ] **Step 8: Create `packages/navigation/src/ui/index.ts`**
|
||
|
||
```typescript
|
||
export { headerQuery } from "./query";
|
||
```
|
||
|
||
- [ ] **Step 9: Update `packages/navigation/src/index.ts`** — remove `headerQuery`; add schemas + types + IUseCase / IController aliases.
|
||
|
||
- [ ] **Step 10: Add `./ui` to `package.json`.**
|
||
|
||
- [ ] **Step 11: Validate all green; update changelog; commit:**
|
||
|
||
```bash
|
||
git add packages/navigation docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
|
||
git commit -m "$(cat <<'EOF'
|
||
refactor(navigation): unify use-case I/O schemas + presenter + feature error map
|
||
|
||
Per Plan 9 (spec R1-R28):
|
||
- getHeader use case: input z.object({}).strict() (R5); output =
|
||
headerSchema parsed at runtime.
|
||
- getHeader controller: unknown input + identity presenter.
|
||
- New integrations/api/procedures.ts with navigationProcedure
|
||
([InputParseError → BAD_REQUEST], [HeaderNotFoundError → NOT_FOUND]).
|
||
- Router uses navigationProcedure + .input(getHeaderInputSchema).
|
||
- src/index.ts: remove headerQuery; export schemas + IUseCase/Controller
|
||
aliases.
|
||
- src/ui/index.ts (NEW); package.json adds ./ui subpath.
|
||
- R25 + R26 tests added.
|
||
|
||
Refactor log: §1, §2, §3.1, §3.2, §3.3, §5.1, §5.2, §6.1, §6.2
|
||
Spec: R1–R6, R8–R15, R18–R20, R22–R26
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Migrate `media` feature
|
||
|
||
Three use cases — `get-media`, `list-media`, `delete-media`. Delete is void output.
|
||
|
||
**Files:** all media use cases + tests, all media controllers + tests, `procedures.ts` (new), `router.ts`, `router.test.ts`, `src/index.ts`, `src/ui/index.ts` (new), `package.json`.
|
||
|
||
### 7.A — `get-media` use case
|
||
|
||
- [ ] **Step 1: Update**
|
||
|
||
```typescript
|
||
import { z } from "zod";
|
||
|
||
import { MediaNotFoundError } from "../../entities/errors/media";
|
||
import { mediaSchema } from "../../entities/models/media";
|
||
import type { IMediaRepository } from "../repositories/media.repository.interface";
|
||
|
||
export const getMediaInputSchema = z.object({ id: z.string().min(1) }).strict();
|
||
export type GetMediaInput = z.infer<typeof getMediaInputSchema>;
|
||
|
||
export const getMediaOutputSchema = mediaSchema;
|
||
export type GetMediaOutput = z.infer<typeof getMediaOutputSchema>;
|
||
|
||
export type IGetMediaUseCase = ReturnType<typeof getMediaUseCase>;
|
||
|
||
export const getMediaUseCase =
|
||
(mediaRepository: IMediaRepository) =>
|
||
async (input: GetMediaInput): Promise<GetMediaOutput> => {
|
||
const media = await mediaRepository.getMedia(input.id);
|
||
if (!media) {
|
||
throw new MediaNotFoundError(`Media with id "${input.id}" not found`);
|
||
}
|
||
return getMediaOutputSchema.parse(media);
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 2: Add R25 test for malformed media. Run.**
|
||
|
||
### 7.B — `list-media` use case
|
||
|
||
- [ ] **Step 3: Update**
|
||
|
||
```typescript
|
||
import { z } from "zod";
|
||
|
||
import { mediaSchema } from "../../entities/models/media";
|
||
import type { IMediaRepository } from "../repositories/media.repository.interface";
|
||
|
||
export const listMediaInputSchema = z
|
||
.object({
|
||
limit: z.number().int().positive().optional(),
|
||
offset: z.number().int().nonnegative().optional(),
|
||
})
|
||
.strict();
|
||
export type ListMediaInput = z.infer<typeof listMediaInputSchema>;
|
||
|
||
export const listMediaOutputSchema = z.array(mediaSchema);
|
||
export type ListMediaOutput = z.infer<typeof listMediaOutputSchema>;
|
||
|
||
export type IListMediaUseCase = ReturnType<typeof listMediaUseCase>;
|
||
|
||
export const listMediaUseCase =
|
||
(mediaRepository: IMediaRepository) =>
|
||
async (input: ListMediaInput): Promise<ListMediaOutput> => {
|
||
const result = await mediaRepository.listMedia(input);
|
||
return listMediaOutputSchema.parse(result);
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: Add R25 test, run.**
|
||
|
||
### 7.C — `delete-media` use case (void output)
|
||
|
||
- [ ] **Step 5: Update**
|
||
|
||
```typescript
|
||
import { z } from "zod";
|
||
|
||
import { MediaNotFoundError } from "../../entities/errors/media";
|
||
import type { IMediaRepository } from "../repositories/media.repository.interface";
|
||
|
||
export const deleteMediaInputSchema = z.object({ id: z.string().min(1) }).strict();
|
||
export type DeleteMediaInput = z.infer<typeof deleteMediaInputSchema>;
|
||
|
||
// No output schema — use case returns void.
|
||
|
||
export type IDeleteMediaUseCase = ReturnType<typeof deleteMediaUseCase>;
|
||
|
||
export const deleteMediaUseCase =
|
||
(mediaRepository: IMediaRepository) =>
|
||
async (input: DeleteMediaInput): Promise<void> => {
|
||
const existing = await mediaRepository.getMedia(input.id);
|
||
if (!existing) {
|
||
throw new MediaNotFoundError(`Media with id "${input.id}" not found`);
|
||
}
|
||
await mediaRepository.deleteMedia(input.id);
|
||
};
|
||
```
|
||
|
||
(Verify against existing logic — adapt to existing throw vs return-undefined behavior.)
|
||
|
||
- [ ] **Step 6: Update tests, run.**
|
||
|
||
### 7.D — Controllers
|
||
|
||
- [ ] **Step 7-9: `get-media.controller.ts`, `list-media.controller.ts` — both with identity presenter; `delete-media.controller.ts` — no presenter, void return.**
|
||
|
||
`delete-media` controller (R12):
|
||
|
||
```typescript
|
||
import { InputParseError } from "../../entities/errors/common";
|
||
import {
|
||
deleteMediaInputSchema,
|
||
type IDeleteMediaUseCase,
|
||
} from "../../application/use-cases/delete-media.use-case";
|
||
|
||
export type IDeleteMediaController = ReturnType<typeof deleteMediaController>;
|
||
|
||
export const deleteMediaController =
|
||
(deleteMediaUseCase: IDeleteMediaUseCase) =>
|
||
async (input: unknown): Promise<void> => {
|
||
const parsed = deleteMediaInputSchema.safeParse(input);
|
||
if (!parsed.success) {
|
||
throw new InputParseError("Invalid delete-media input", { cause: parsed.error });
|
||
}
|
||
await deleteMediaUseCase(parsed.data);
|
||
};
|
||
```
|
||
|
||
`get-media` and `list-media` controllers follow blog's identity-presenter pattern.
|
||
|
||
- [ ] **Step 10: Update all three controller tests; run.**
|
||
|
||
### 7.E — `procedures.ts` (media)
|
||
|
||
- [ ] **Step 11: Create**
|
||
|
||
```typescript
|
||
import { t } from "@repo/core-shared/trpc/init";
|
||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||
|
||
import { MediaNotFoundError } from "../../entities/errors/media";
|
||
import { InputParseError } from "../../entities/errors/common";
|
||
|
||
export const mediaProcedure = t.procedure.use(
|
||
defineErrorMiddleware([
|
||
[InputParseError, "BAD_REQUEST"],
|
||
[MediaNotFoundError, "NOT_FOUND"],
|
||
]),
|
||
);
|
||
```
|
||
|
||
### 7.F — Router
|
||
|
||
- [ ] **Step 12: Update**
|
||
|
||
```typescript
|
||
import { router } from "@repo/core-shared/trpc/init";
|
||
|
||
import { mediaContainer } from "../../di/container";
|
||
import { MEDIA_SYMBOLS } from "../../di/symbols";
|
||
|
||
import { getMediaInputSchema } from "../../application/use-cases/get-media.use-case";
|
||
import { listMediaInputSchema } from "../../application/use-cases/list-media.use-case";
|
||
import { deleteMediaInputSchema } from "../../application/use-cases/delete-media.use-case";
|
||
|
||
import type { IGetMediaController } from "../../interface-adapters/controllers/get-media.controller";
|
||
import type { IListMediaController } from "../../interface-adapters/controllers/list-media.controller";
|
||
import type { IDeleteMediaController } from "../../interface-adapters/controllers/delete-media.controller";
|
||
|
||
import { mediaProcedure } from "./procedures";
|
||
|
||
export const mediaRouter = router({
|
||
getMedia: mediaProcedure.input(getMediaInputSchema).query(({ input }) => {
|
||
const ctrl = mediaContainer.get<IGetMediaController>(MEDIA_SYMBOLS.IGetMediaController);
|
||
return ctrl(input);
|
||
}),
|
||
listMedia: mediaProcedure.input(listMediaInputSchema).query(({ input }) => {
|
||
const ctrl = mediaContainer.get<IListMediaController>(MEDIA_SYMBOLS.IListMediaController);
|
||
return ctrl(input);
|
||
}),
|
||
deleteMedia: mediaProcedure.input(deleteMediaInputSchema).mutation(({ input }) => {
|
||
const ctrl = mediaContainer.get<IDeleteMediaController>(MEDIA_SYMBOLS.IDeleteMediaController);
|
||
return ctrl(input);
|
||
}),
|
||
});
|
||
|
||
export type MediaRouter = typeof mediaRouter;
|
||
```
|
||
|
||
### 7.G — Router test (R26)
|
||
|
||
- [ ] **Step 13: Add R26 test** — `MediaNotFoundError → NOT_FOUND`, `BAD_REQUEST` on bad input.
|
||
|
||
### 7.H — Public-API surface
|
||
|
||
- [ ] **Step 14: Create `packages/media/src/ui/index.ts`**
|
||
|
||
Media has no UI today. Placeholder:
|
||
|
||
```typescript
|
||
// packages/media/src/ui/index.ts
|
||
// Media has no React Query option builders today. This file is the
|
||
// public UI surface for future components and queries — extend rather
|
||
// than re-add to root index.ts.
|
||
export {};
|
||
```
|
||
|
||
- [ ] **Step 15: Update `packages/media/src/index.ts`** — add schemas + types + IUseCase / IController aliases (mirroring blog).
|
||
|
||
- [ ] **Step 16: Add `./ui` to `packages/media/package.json`.**
|
||
|
||
### 7.I — Verify + changelog + commit
|
||
|
||
- [ ] **Step 17: Full validation.**
|
||
|
||
```bash
|
||
pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries
|
||
```
|
||
|
||
- [ ] **Step 18: Update changelog.**
|
||
|
||
- [ ] **Step 19: Commit**
|
||
|
||
```bash
|
||
git add packages/media docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
|
||
git commit -m "$(cat <<'EOF'
|
||
refactor(media): unify use-case I/O schemas + presenter + feature error map
|
||
|
||
Per Plan 9 (spec R1-R28):
|
||
- Use cases: input + output schemas (getMedia, listMedia); deleteMedia
|
||
has input schema only (void output, R12 — no presenter).
|
||
- Controllers: unknown input + identity presenter on getMedia/listMedia;
|
||
Promise<void> on deleteMedia.
|
||
- New integrations/api/procedures.ts with mediaProcedure
|
||
([InputParseError → BAD_REQUEST], [MediaNotFoundError → NOT_FOUND]).
|
||
- Router uses mediaProcedure + .input(xInputSchema).
|
||
- src/index.ts exports schemas + types; src/ui/index.ts placeholder
|
||
(media has no queries today); package.json adds ./ui subpath.
|
||
- R25 + R26 tests added.
|
||
|
||
Refactor log: §1, §2, §3.1, §3.2, §3.3, §5.1, §5.2, §6.1, §6.2
|
||
Spec: R1–R6, R8–R15, R18–R20, R22–R26
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Final verification + boundary sweep
|
||
|
||
**Files:** none modified directly. Verification only. If any straggler imports surface (e.g., a test still casting input as `Partial<XInput>`), fix in this task with a separate small commit.
|
||
|
||
- [ ] **Step 1: Run full validation**
|
||
|
||
```bash
|
||
pnpm install
|
||
pnpm typecheck
|
||
pnpm lint
|
||
pnpm test
|
||
pnpm turbo boundaries
|
||
pnpm build
|
||
```
|
||
|
||
All green.
|
||
|
||
- [ ] **Step 2: Verify Plan 9 acceptance criteria from spec §8**
|
||
|
||
Check each:
|
||
|
||
```bash
|
||
# Every use case exports xInputSchema:
|
||
grep -L "export const .*InputSchema" packages/*/src/application/use-cases/*.ts | grep -v test
|
||
# Expected: empty (every non-test file has a schema)
|
||
|
||
# Every non-void use case exports xOutputSchema:
|
||
# (manual review; void use cases are sign-out and delete-media)
|
||
grep -L "export const .*OutputSchema" packages/*/src/application/use-cases/*.ts | grep -v test
|
||
# Expected: only sign-out.use-case.ts and delete-media.use-case.ts
|
||
|
||
# Every controller has presenter or returns void:
|
||
grep -L "function presenter\|Promise<void>" packages/*/src/interface-adapters/controllers/*.ts | grep -v test
|
||
# Expected: empty
|
||
|
||
# Every feature has procedures.ts:
|
||
ls packages/{auth,blog,marketing-pages,navigation,media}/src/integrations/api/procedures.ts
|
||
# Expected: 5 files listed
|
||
|
||
# Every feature package.json has ./ui:
|
||
for f in auth blog marketing-pages navigation media; do
|
||
grep -q '"./ui"' packages/$f/package.json && echo "$f: OK" || echo "$f: MISSING"
|
||
done
|
||
# Expected: 5 × OK
|
||
```
|
||
|
||
- [ ] **Step 3: Spot-check one tRPC error response per feature**
|
||
|
||
Pick one router test per feature; ensure each emits the expected `TRPCError.code`. Already covered by R26 tests in each feature's `router.test.ts` — verify output:
|
||
|
||
```bash
|
||
pnpm test -- router.test
|
||
```
|
||
|
||
- [ ] **Step 4: If anything is non-conformant, fix it inline and commit**
|
||
|
||
```bash
|
||
git add <files>
|
||
git commit -m "chore(plan-9): straggler fixes from final verification
|
||
|
||
Plan 9 acceptance sweep caught <issue summary>. Refactor log §7."
|
||
```
|
||
|
||
If everything's green, no commit needed.
|
||
|
||
- [ ] **Step 5: Update changelog with verification summary** under §7 (Open issues / deferred decisions or a new "Verification" subsection).
|
||
|
||
```markdown
|
||
## 7. Open issues / deferred decisions
|
||
|
||
(Plan 9 verification, 2026-05-06):
|
||
- All R1–R28 conformance checks passed against the spec.
|
||
- Tests: <N> total (was <M> pre-Plan-9). Net delta: <delta> (R25/R26 additions).
|
||
- typecheck / lint / boundaries / build / test all green.
|
||
- Doc-update pass remains deferred — combined with paused Plan 8 items, executes in a single follow-up pass.
|
||
```
|
||
|
||
(Fill in actual numbers from `pnpm test` output.)
|
||
|
||
---
|
||
|
||
## Task 9: ADR-013 + final changelog summary
|
||
|
||
**Files:**
|
||
- Create: `docs/decisions/adr-013-input-output-unification.md`
|
||
- Modify: `docs/decisions/adr-012-lazar-conformance.md` (one-line cross-reference)
|
||
- Modify: `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md` (top "Summary" section)
|
||
|
||
- [ ] **Step 1: Create ADR-013**
|
||
|
||
Write `docs/decisions/adr-013-input-output-unification.md`:
|
||
|
||
```markdown
|
||
# ADR-013: Use-Case Input/Output Unification + Presenter Pattern + Feature-Scoped Error Mapping
|
||
|
||
**Status:** Accepted
|
||
**Date:** 2026-05-06
|
||
**Supersedes:** none — extends ADR-008 (per-feature DI), ADR-011 (TDD foundation), ADR-012 (Lazar conformance)
|
||
**Spec:** docs/superpowers/specs/2026-05-06-input-output-unification-design.md
|
||
**Plan:** docs/superpowers/plans/2026-05-06-plan-9-io-unification.md
|
||
**Refactor log:** docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
|
||
|
||
## Context
|
||
|
||
Plan 8 (ADR-012) established factory-function use cases and one-controller-
|
||
per-use-case. But the input contract was still defined three times — once
|
||
in the tRPC procedure's `.input(z.object({...}))`, once in the controller's
|
||
local `const inputSchema`, and once implicitly in the use case's TypeScript
|
||
parameter type. The three definitions drifted: the controller's
|
||
`z.string().min(3).max(31)` was stricter than the tRPC version's
|
||
`z.string()`. Output validation was TypeScript-only — repositories could
|
||
return malformed values and use cases happily passed them through.
|
||
|
||
There was also no consistent error-translation between domain errors
|
||
(`ArticleNotFoundError`, `AuthenticationError`, …) and `TRPCError`,
|
||
meaning the wire response code was unpredictable per feature.
|
||
|
||
Per-feature public-API surfaces conflated UI artifacts (query builders
|
||
imported React Query) with pure contracts (entity types) on the same
|
||
top-level export, making "what does this package expose to whom" muddy.
|
||
|
||
## Decision
|
||
|
||
Adopt four interlocking patterns, codified as 30 RFC-2119 rules in the
|
||
spec:
|
||
|
||
1. **Use-case file is the single source of truth for input AND output
|
||
contracts.** Every use case exports `xInputSchema` (always a
|
||
`z.ZodObject`, even for void inputs via `z.object({}).strict()`) and
|
||
— for non-void use cases — `xOutputSchema`. The use-case body ends
|
||
with `xOutputSchema.parse(result)` before returning. Type aliases
|
||
`XInput`/`XOutput`/`IXUseCase` are exported alongside.
|
||
|
||
2. **Controllers consume the use-case schema; output passes through a
|
||
co-located `function presenter`.** Controllers receive `unknown`,
|
||
`safeParse` against `xInputSchema`, throw `InputParseError` on
|
||
failure, then call the use case and pass the result through a
|
||
top-level `function presenter(value: XOutput)` defined in the same
|
||
file. The controller's return type is `Promise<ReturnType<typeof
|
||
presenter>>`. Identity presenters are permitted and expected for
|
||
pass-through cases — the function form must always exist (R11) so
|
||
adding a transform is a one-line edit. Void-output controllers
|
||
(e.g., `signOutController`, `deleteMediaController`) skip the
|
||
presenter and return `Promise<void>` (R12).
|
||
|
||
3. **Feature-scoped error→TRPCError middleware.** Each feature's
|
||
`integrations/api/procedures.ts` exports an `xProcedure` built from
|
||
`t.procedure.use(defineErrorMiddleware([[ErrorCtor, "TRPC_CODE"],
|
||
...]))`. The factory `defineErrorMiddleware` lives in
|
||
`core-shared/trpc/`; it discriminates by `instanceof` and preserves
|
||
the original error as `TRPCError.cause`. **`core-shared` never
|
||
enumerates feature-specific error classes** — each feature passes its
|
||
own constructors in via its own `procedures.ts`. Routers use the
|
||
feature's `xProcedure` instead of bare `publicProcedure` and
|
||
`.input(xInputSchema)` instead of redefining input shapes.
|
||
|
||
4. **Per-feature public surface split.** Feature root `.` exports only
|
||
contracts: domain types, domain errors, schemas, `IXUseCase`/`IXController`
|
||
aliases, router type, constants. UI artifacts (query builders,
|
||
future React components) move to a new `./ui` subpath
|
||
(`src/ui/index.ts`). Apps that need queries import from
|
||
`@repo/<feature>/ui`; apps that need the type-only contract import
|
||
from `@repo/<feature>`.
|
||
|
||
## Consequences
|
||
|
||
### Positive
|
||
|
||
- **Single source of truth for I/O contracts.** Schema drift is no
|
||
longer possible — there's one definition, imported by everyone.
|
||
- **Runtime-validated outputs.** `xOutputSchema.parse(...)` catches
|
||
"repo returned malformed data" bugs at the layer that owns the
|
||
contract, instead of silently flowing wrong shapes downstream.
|
||
- **Predictable error responses.** Every domain error maps to a known
|
||
`TRPCError.code` via the per-feature middleware; clients can rely on
|
||
status codes.
|
||
- **Discoverable transforms via presenter.** When a view needs to drop
|
||
fields, rename them, or serialize dates, the presenter function is
|
||
already there — change one function body. No structural refactor.
|
||
- **Clean public surface.** Feature root packages no longer pretend to
|
||
be UI packages; apps make explicit choices about what they need.
|
||
- **Frontend gets schemas for free.** Forms can `import { signInInputSchema
|
||
} from "@repo/auth"` and feed it into `react-hook-form` + `zodResolver`
|
||
with the same constraints the backend enforces.
|
||
|
||
### Negative
|
||
|
||
- **More code.** Every use case grows by ~10 lines (input + output
|
||
schema + parse). Every controller grows by ~5 lines (presenter,
|
||
even if identity). Acceptable cost for the consistency.
|
||
- **Per-feature `procedures.ts` boilerplate.** Five new files (~10
|
||
lines each) — one per feature. Maintaining the error map is one of
|
||
the few feature-level chores; new error classes need adding to the
|
||
map.
|
||
- **Schemas run twice on the tRPC path** (tRPC's `.input()` parse +
|
||
controller's `safeParse`). Negligible cost; zero behavioral risk
|
||
because both use the same schema. Defense-in-depth value when the
|
||
controller is invoked from non-tRPC entry points.
|
||
- **Apps with existing imports may need updating** — `articleBySlugQuery`,
|
||
`pageBySlugQuery`, etc. now live behind `@repo/<feature>/ui`.
|
||
(At Plan 9 land time, no apps consume these yet, so the cost is
|
||
forward-only.)
|
||
|
||
## Alternatives considered
|
||
|
||
- **Keep schemas in controllers (Lazar's reference pattern).** Lazar
|
||
has only one validation layer (server actions skip `.input()`), so
|
||
one schema is sufficient. Our entry point is tRPC, which insists on
|
||
a schema for type inference — putting the canonical schema in the
|
||
controller and exporting it for the router was considered. Rejected
|
||
because the use case is the contract owner; schemas describe the
|
||
*operation*, not the *transport*.
|
||
|
||
- **Centralized error-name → code map in `core-shared`.** Considered
|
||
using `error.name` discrimination with a small global registry.
|
||
Rejected because it violates feature ownership — `core-shared` would
|
||
need to know about every feature's error classes. The
|
||
`defineErrorMiddleware` factory cleanly inverts the dependency:
|
||
`core-shared` provides the plumbing, features pass their own
|
||
constructors.
|
||
|
||
- **Validate outputs only in tests.** Considered using TypeScript
|
||
types alone for output, deferring runtime validation to
|
||
contract-suite tests. Rejected because the cost of `.parse()` on
|
||
return is trivial and the bug-catching value at runtime is real
|
||
(Payload integrations have surprised us before).
|
||
|
||
- **Presenters only when reshaping.** Considered Lazar's actual rule
|
||
(presenter only when there's a transform). Rejected (R11) because
|
||
the discoverable hook for future shaping is worth the trivial
|
||
identity-function boilerplate.
|
||
|
||
- **Presenters in a separate `presenters/` folder.** Considered as a
|
||
concession to "controllers = thin orchestration". Rejected because
|
||
Lazar's reference co-locates the presenter with its consumer — the
|
||
controller — keeping the contract visible in one file.
|
||
|
||
- **Shared `./schemas` subpath.** Considered exposing schemas only via
|
||
a dedicated subpath instead of the feature root. Rejected because
|
||
schemas ARE feature contracts — they belong with the other contracts
|
||
(types, errors). Adding a fourth subpath felt like ceremony.
|
||
|
||
## Acceptance verification (Task 8, 2026-05-06)
|
||
|
||
- All Plan 9 acceptance criteria from spec §8 met.
|
||
- Tests: <N> total. Spec coverage: every R1–R28 represented.
|
||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries
|
||
&& pnpm build` green.
|
||
- Five feature-level R26 router-error-mapping tests demonstrate domain
|
||
error → `TRPCError.code` translation works end-to-end.
|
||
|
||
(Fill in N from actual test count after Task 8 verification.)
|
||
|
||
## References
|
||
|
||
- Spec: `docs/superpowers/specs/2026-05-06-input-output-unification-design.md`
|
||
- Plan: `docs/superpowers/plans/2026-05-06-plan-9-io-unification.md`
|
||
- Refactor log: `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md`
|
||
- Reference (Lazar's blog post + repo): https://github.com/nikolovlazar/nextjs-clean-architecture
|
||
- Prior ADRs: ADR-008 (per-feature DI), ADR-011 (TDD foundation), ADR-012 (Lazar conformance)
|
||
```
|
||
|
||
- [ ] **Step 2: Append cross-reference to ADR-012**
|
||
|
||
Edit `docs/decisions/adr-012-lazar-conformance.md`. At the very end (after the existing References section), add a new section:
|
||
|
||
```markdown
|
||
|
||
## Update — 2026-05-06
|
||
|
||
Plan 9 (ADR-013) further unifies the input/output schema story:
|
||
schemas now live in the use-case file (a refinement of §What we
|
||
adopted #1's "factory-function use cases"); controllers gain a
|
||
co-located `function presenter` (extending §What we adopted #4's
|
||
"one-controller-per-use-case"); domain error → `TRPCError`
|
||
translation runs through a per-feature middleware factory (a new
|
||
concern not in this ADR). See `docs/decisions/adr-013-input-output-unification.md`.
|
||
```
|
||
|
||
- [ ] **Step 3: Add Summary section to refactor changelog**
|
||
|
||
Prepend a Summary section to `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md` (right after the front-matter):
|
||
|
||
```markdown
|
||
## Summary
|
||
|
||
**Completed:** 2026-05-06
|
||
**Branch:** main (or feature/io-unification per execution choice)
|
||
**Total commits:** 9 (Tasks 1–9)
|
||
**Net test count change:** <pre> → <post> (+<delta>)
|
||
|
||
| Category | Count |
|
||
|---|---|
|
||
| Files added | 5 × procedures.ts + 5 × ui/index.ts + 1 × define-error-middleware{.ts,.test.ts} + 1 × ADR + 1 × changelog = 14 (approx; some ui/index.ts replace existing query.ts) |
|
||
| Files modified | 12 use cases + 12 controllers + 5 routers + 5 src/index.ts + 5 package.json + 5 router tests + ~25 test files = ~70 |
|
||
| New tRPC error codes mapped | 5 (BAD_REQUEST, NOT_FOUND, UNAUTHORIZED, FORBIDDEN, plus tRPC built-in BAD_REQUEST from zod) |
|
||
|
||
### Tasks completed
|
||
|
||
| Task | Commit | Description |
|
||
|---|---|---|
|
||
| 1 | <sha> | Refactor changelog scaffold |
|
||
| 2 | <sha> | core-shared defineErrorMiddleware + t export |
|
||
| 3 | <sha> | auth migration |
|
||
| 4 | <sha> | blog migration |
|
||
| 5 | <sha> | marketing-pages migration |
|
||
| 6 | <sha> | navigation migration |
|
||
| 7 | <sha> | media migration |
|
||
| 8 | <sha> | Final verification (no commit if clean) |
|
||
| 9 | <sha> | ADR-013 + changelog summary |
|
||
|
||
### Conformance verification (Task 8)
|
||
|
||
Spec acceptance criteria §8: all met.
|
||
- Every use case exports xInputSchema; non-void use cases also export xOutputSchema.
|
||
- Every non-void controller has a top-level presenter and uses ReturnType<typeof presenter>.
|
||
- Every feature has procedures.ts with feature-scoped error map.
|
||
- core-shared/trpc/define-error-middleware.ts is the only plumbing in core-shared; no central name→code registry.
|
||
- Per-feature package.json has ./ui subpath; root index.ts no longer exports query builders.
|
||
- R25 (output-validation) test exists per non-void use case.
|
||
- R26 (router error-mapping) test exists per feature.
|
||
- pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries && pnpm build all green.
|
||
```
|
||
|
||
(Fill in `<sha>` and `<pre>/<post>/<delta>` from actual git log + test counts.)
|
||
|
||
- [ ] **Step 4: Final commit**
|
||
|
||
```bash
|
||
git add docs/decisions/adr-013-input-output-unification.md docs/decisions/adr-012-lazar-conformance.md docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
|
||
git commit -m "$(cat <<'EOF'
|
||
docs(adr): ADR-013 input/output unification + Plan 9 changelog summary
|
||
|
||
Records the Plan 9 architectural decision (schemas in use-case file,
|
||
runtime output validation, presenter pattern, feature-scoped error
|
||
middleware, ./ui subpath split). ADR-012 gets a one-line cross-
|
||
reference to the new ADR. Refactor log gets a Summary section with
|
||
commit table and conformance checklist.
|
||
|
||
Plan 9 complete. The deferred doc-update pass (CLAUDE.md / AGENTS.md /
|
||
guides) — combined with the still-pending Plan 8 items — is the next
|
||
follow-up.
|
||
|
||
Refactor log: Summary, doc-update checklist
|
||
Spec: R29, R30
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-review (after writing the plan)
|
||
|
||
- [x] Spec coverage: every rule R1–R30 is represented in at least one task.
|
||
- R1, R2, R3, R4, R5: Tasks 3.A–7.B (every use case migration step shows the schema + parse).
|
||
- R6 (error class names): existing classes already set `this.name` per Plan 8 — verified by R26 tests in each feature router test.
|
||
- R7, R8, R9, R10: every controller migration step (3.D, 3.E, 3.F, 4.D, 5.C, 6.3, 7.D).
|
||
- R11, R12: presenter / void distinction shown explicitly per controller.
|
||
- R13, R14, R15: each feature creates `procedures.ts` and updates router (3.G–3.H, 4.E–4.F, 5.D–5.E, 6.5–6.6, 7.E–7.F).
|
||
- R16, R17: Task 2 implements `defineErrorMiddleware` with `instanceof` matching.
|
||
- R18, R19, R20, R21: per-feature `src/index.ts` cleanup + `./ui` subpath + apps unaffected (no current consumers).
|
||
- R22, R23: existing Plan 8 DI pattern retained — no changes needed.
|
||
- R24, R25, R26, R27, R28: tests added per feature; identity presenters mean R27/R28 are vacuously satisfied (no shape change to assert).
|
||
- R29: Task 9 creates ADR-013.
|
||
- R30: Task 1 scaffolds the refactor log.
|
||
- [x] Placeholder scan: no "TBD"/"TODO"; all code blocks are concrete; commit messages are full text.
|
||
- [x] Type consistency: every `xInputSchema`/`XInput` pair lines up; controllers reference `XOutput` types from their use case; every router imports schemas from the same path used in tests.
|
||
- [x] No cross-task references — each per-feature task is self-contained (the auth task does not depend on the blog task being done).
|
||
|
||
## Execution
|
||
|
||
Per `superpowers:subagent-driven-development`: dispatch one implementer subagent per task with full task text + context, then spec compliance review, then code quality review, then proceed.
|