From 075b729266a4857ab7d521fd33bfd94a5bf77377 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 13 May 2026 09:43:49 +0200 Subject: [PATCH] feat: archive setup-history epics + plans + refactor-logs to .archive/ --- .../plans/2026-05-04-plan-1-foundation.md | 1544 ----- .../plans/2026-05-04-plan-2-blog-feature.md | 1842 ------ .../plans/2026-05-04-plan-3-auth-media.md | 1881 ------ ...05-04-plan-4-marketing-pages-navigation.md | 1875 ------ .../2026-05-04-plan-5-app-ui-integration.md | 1062 ---- ...-04-plan-6-cleanup-enforcement-e2e-docs.md | 1095 ---- .../plans/2026-05-05-plan-7-tdd-foundation.md | 2289 -------- .../2026-05-05-plan-8-lazar-conformance.md | 1259 ---- ...26-05-06-plan-10-instrumentation-sentry.md | 5138 ----------------- .../plans/2026-05-06-plan-9-io-unification.md | 2722 --------- .../plans/2026-05-08-events-and-jobs.md | 3438 ----------- .../plans/2026-05-08-realtime-layer.md | 2944 ---------- .../2026-05-09-core-package-generator.md | 2316 -------- .../plans/2026-05-11-audit-and-compliance.md | 3259 ----------- .../2026-05-11-core-ui-component-generator.md | 955 --- .../2026-05-11-opentelemetry-migration.md | 2487 -------- .../2026-05-12-conformance-milestone-i.md | 1106 ---- .../2026-05-12-conformance-milestone-ii.md | 1292 ----- ...rmance-milestone-iii-a-structural-rules.md | 1085 ---- ...2-conformance-milestone-iii-b-ast-rules.md | 811 --- ...-conformance-milestone-iv-ci-drift-gate.md | 336 -- ...nformance-milestone-v-generator-updates.md | 213 - ...ormance-milestone-vi-feature-migrations.md | 349 -- .../2026-05-05-lazar-pattern-conformance.md | 412 -- .../2026-05-06-input-output-unification.md | 306 - .../2026-05-06-instrumentation-sentry.md | 88 - ...04-21-vertical-monorepo-refactor-design.md | 604 -- ...-05-05-lazar-pattern-conformance-design.md | 472 -- .../specs/2026-05-05-tdd-foundation-design.md | 669 --- ...6-05-06-input-output-unification-design.md | 381 -- ...026-05-06-instrumentation-sentry-design.md | 474 -- .../2026-05-08-events-and-jobs-design.md | 654 --- .../specs/2026-05-08-realtime-design.md | 879 --- ...026-05-09-core-package-generator-design.md | 399 -- .../2026-05-11-audit-and-compliance-design.md | 507 -- ...5-11-core-ui-component-generator-design.md | 357 -- ...26-05-11-opentelemetry-migration-design.md | 406 -- docs/work/_state.json | 449 +- .../01-docs-rewrite/_story.md | 42 - docs/work/agent-workflow-docs-v1/_epic.md | 36 - .../01-ast-manifest-source/_story.md | 33 - .../02-dev-seed-assertion/_story.md | 29 - docs/work/conformance-hardening-v1/_epic.md | 27 - .../01-define-feature-helper/_story.md | 49 - .../02-boot-assertions/_story.md | 65 - .../03-a-structural-eslint-rules/_story.md | 64 - .../03-b-ast-eslint-rules/_story.md | 45 - .../04-ci-drift-gate/_story.md | 47 - .../05-generator-updates/_story.md | 52 - .../06-feature-migrations/_story.md | 39 - docs/work/conformance-system-v1/_epic.md | 41 - .../01-extended-state/_story.md | 17 - .../02-dag-computation/_story.md | 16 - .../03-cli-subcommands/_story.md | 18 - docs/work/dag-and-readiness-v1/_epic.md | 21 - .../docs-and-runbook-v1/01-runbook/_story.md | 15 - .../02-doc-sweep/_story.md | 18 - docs/work/docs-and-runbook-v1/_epic.md | 20 - .../01-sandcastle-scaffold/_story.md | 17 - .../02-elicitation-prompts/_story.md | 15 - .../03-dispatch-prompts/_story.md | 17 - docs/work/elicitation-prompts-v1/_epic.md | 22 - .../01-fallow-install/_story.md | 17 - .../02-pnpm-turbo-wiring/_story.md | 17 - .../03-ci-integration/_story.md | 15 - .../04-docs-and-prompts/_story.md | 18 - docs/work/fallow-integration-v1/_epic.md | 28 - .../01-frontend-rules/_story.md | 42 - docs/work/frontend-conformance-v1/_epic.md | 22 - .../01-husky-install/_story.md | 28 - .../02-pre-commit-hook/_story.md | 15 - .../03-state-sync-guard/_story.md | 17 - docs/work/pre-commit-hooks-v1/_epic.md | 27 - .../01-sandcastle-install/_story.md | 17 - .../02-dispatch-planner/_story.md | 16 - .../03-dispatch-execute/_story.md | 17 - .../04-dispatch-cli-wiring/_story.md | 17 - docs/work/sandcastle-dispatch-v1/_epic.md | 27 - .../01-subscription-first/_story.md | 21 - .../sandcastle-subscription-auth-v1/_epic.md | 29 - .../01-playwright-install/_story.md | 16 - .../02-storybook-visual-tests/_story.md | 15 - .../03-ci-integration/_story.md | 15 - docs/work/visual-regression-v1/_epic.md | 27 - .../01-state-builder-and-cli/_story.md | 33 - docs/work/work-system-v1/_epic.md | 28 - 86 files changed, 1 insertion(+), 49643 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-04-plan-1-foundation.md delete mode 100644 docs/superpowers/plans/2026-05-04-plan-2-blog-feature.md delete mode 100644 docs/superpowers/plans/2026-05-04-plan-3-auth-media.md delete mode 100644 docs/superpowers/plans/2026-05-04-plan-4-marketing-pages-navigation.md delete mode 100644 docs/superpowers/plans/2026-05-04-plan-5-app-ui-integration.md delete mode 100644 docs/superpowers/plans/2026-05-04-plan-6-cleanup-enforcement-e2e-docs.md delete mode 100644 docs/superpowers/plans/2026-05-05-plan-7-tdd-foundation.md delete mode 100644 docs/superpowers/plans/2026-05-05-plan-8-lazar-conformance.md delete mode 100644 docs/superpowers/plans/2026-05-06-plan-10-instrumentation-sentry.md delete mode 100644 docs/superpowers/plans/2026-05-06-plan-9-io-unification.md delete mode 100644 docs/superpowers/plans/2026-05-08-events-and-jobs.md delete mode 100644 docs/superpowers/plans/2026-05-08-realtime-layer.md delete mode 100644 docs/superpowers/plans/2026-05-09-core-package-generator.md delete mode 100644 docs/superpowers/plans/2026-05-11-audit-and-compliance.md delete mode 100644 docs/superpowers/plans/2026-05-11-core-ui-component-generator.md delete mode 100644 docs/superpowers/plans/2026-05-11-opentelemetry-migration.md delete mode 100644 docs/superpowers/plans/2026-05-12-conformance-milestone-i.md delete mode 100644 docs/superpowers/plans/2026-05-12-conformance-milestone-ii.md delete mode 100644 docs/superpowers/plans/2026-05-12-conformance-milestone-iii-a-structural-rules.md delete mode 100644 docs/superpowers/plans/2026-05-12-conformance-milestone-iii-b-ast-rules.md delete mode 100644 docs/superpowers/plans/2026-05-12-conformance-milestone-iv-ci-drift-gate.md delete mode 100644 docs/superpowers/plans/2026-05-12-conformance-milestone-v-generator-updates.md delete mode 100644 docs/superpowers/plans/2026-05-13-conformance-milestone-vi-feature-migrations.md delete mode 100644 docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md delete mode 100644 docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md delete mode 100644 docs/superpowers/refactor-logs/2026-05-06-instrumentation-sentry.md delete mode 100644 docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md delete mode 100644 docs/superpowers/specs/2026-05-05-lazar-pattern-conformance-design.md delete mode 100644 docs/superpowers/specs/2026-05-05-tdd-foundation-design.md delete mode 100644 docs/superpowers/specs/2026-05-06-input-output-unification-design.md delete mode 100644 docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md delete mode 100644 docs/superpowers/specs/2026-05-08-events-and-jobs-design.md delete mode 100644 docs/superpowers/specs/2026-05-08-realtime-design.md delete mode 100644 docs/superpowers/specs/2026-05-09-core-package-generator-design.md delete mode 100644 docs/superpowers/specs/2026-05-11-audit-and-compliance-design.md delete mode 100644 docs/superpowers/specs/2026-05-11-core-ui-component-generator-design.md delete mode 100644 docs/superpowers/specs/2026-05-11-opentelemetry-migration-design.md delete mode 100644 docs/work/agent-workflow-docs-v1/01-docs-rewrite/_story.md delete mode 100644 docs/work/agent-workflow-docs-v1/_epic.md delete mode 100644 docs/work/conformance-hardening-v1/01-ast-manifest-source/_story.md delete mode 100644 docs/work/conformance-hardening-v1/02-dev-seed-assertion/_story.md delete mode 100644 docs/work/conformance-hardening-v1/_epic.md delete mode 100644 docs/work/conformance-system-v1/01-define-feature-helper/_story.md delete mode 100644 docs/work/conformance-system-v1/02-boot-assertions/_story.md delete mode 100644 docs/work/conformance-system-v1/03-a-structural-eslint-rules/_story.md delete mode 100644 docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md delete mode 100644 docs/work/conformance-system-v1/04-ci-drift-gate/_story.md delete mode 100644 docs/work/conformance-system-v1/05-generator-updates/_story.md delete mode 100644 docs/work/conformance-system-v1/06-feature-migrations/_story.md delete mode 100644 docs/work/conformance-system-v1/_epic.md delete mode 100644 docs/work/dag-and-readiness-v1/01-extended-state/_story.md delete mode 100644 docs/work/dag-and-readiness-v1/02-dag-computation/_story.md delete mode 100644 docs/work/dag-and-readiness-v1/03-cli-subcommands/_story.md delete mode 100644 docs/work/dag-and-readiness-v1/_epic.md delete mode 100644 docs/work/docs-and-runbook-v1/01-runbook/_story.md delete mode 100644 docs/work/docs-and-runbook-v1/02-doc-sweep/_story.md delete mode 100644 docs/work/docs-and-runbook-v1/_epic.md delete mode 100644 docs/work/elicitation-prompts-v1/01-sandcastle-scaffold/_story.md delete mode 100644 docs/work/elicitation-prompts-v1/02-elicitation-prompts/_story.md delete mode 100644 docs/work/elicitation-prompts-v1/03-dispatch-prompts/_story.md delete mode 100644 docs/work/elicitation-prompts-v1/_epic.md delete mode 100644 docs/work/fallow-integration-v1/01-fallow-install/_story.md delete mode 100644 docs/work/fallow-integration-v1/02-pnpm-turbo-wiring/_story.md delete mode 100644 docs/work/fallow-integration-v1/03-ci-integration/_story.md delete mode 100644 docs/work/fallow-integration-v1/04-docs-and-prompts/_story.md delete mode 100644 docs/work/fallow-integration-v1/_epic.md delete mode 100644 docs/work/frontend-conformance-v1/01-frontend-rules/_story.md delete mode 100644 docs/work/frontend-conformance-v1/_epic.md delete mode 100644 docs/work/pre-commit-hooks-v1/01-husky-install/_story.md delete mode 100644 docs/work/pre-commit-hooks-v1/02-pre-commit-hook/_story.md delete mode 100644 docs/work/pre-commit-hooks-v1/03-state-sync-guard/_story.md delete mode 100644 docs/work/pre-commit-hooks-v1/_epic.md delete mode 100644 docs/work/sandcastle-dispatch-v1/01-sandcastle-install/_story.md delete mode 100644 docs/work/sandcastle-dispatch-v1/02-dispatch-planner/_story.md delete mode 100644 docs/work/sandcastle-dispatch-v1/03-dispatch-execute/_story.md delete mode 100644 docs/work/sandcastle-dispatch-v1/04-dispatch-cli-wiring/_story.md delete mode 100644 docs/work/sandcastle-dispatch-v1/_epic.md delete mode 100644 docs/work/sandcastle-subscription-auth-v1/01-subscription-first/_story.md delete mode 100644 docs/work/sandcastle-subscription-auth-v1/_epic.md delete mode 100644 docs/work/visual-regression-v1/01-playwright-install/_story.md delete mode 100644 docs/work/visual-regression-v1/02-storybook-visual-tests/_story.md delete mode 100644 docs/work/visual-regression-v1/03-ci-integration/_story.md delete mode 100644 docs/work/visual-regression-v1/_epic.md delete mode 100644 docs/work/work-system-v1/01-state-builder-and-cli/_story.md delete mode 100644 docs/work/work-system-v1/_epic.md diff --git a/docs/superpowers/plans/2026-05-04-plan-1-foundation.md b/docs/superpowers/plans/2026-05-04-plan-1-foundation.md deleted file mode 100644 index 2dc9844..0000000 --- a/docs/superpowers/plans/2026-05-04-plan-1-foundation.md +++ /dev/null @@ -1,1544 +0,0 @@ -# Vertical Refactor — Plan 1: Foundation - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Scaffold the five `core-*` packages, populate `core-shared` with generic primitives + tRPC plumbing, lift the Payload config into a stub `core-cms` package, and repoint `apps/cms` at it — leaving `pnpm install`, `typecheck`, and the CMS dev server all green at the end. - -**Architecture:** Three sequential phases. Phase 1 creates empty package skeletons (no code yet) and updates path aliases. Phase 2 fills `core-shared` with reusable Payload primitives (slug field, SEO fields, CTA block, access helpers, hooks) and tRPC init/context. Phase 3 moves the Payload config into `core-cms` (with empty collection/global arrays), updates `apps/cms` to import from the new package, and verifies the admin UI still boots. - -**Tech Stack:** pnpm workspaces, Turborepo, TypeScript 5.8, Vitest 3.x, Payload 3.14, tRPC 11, Zod 3. - -**Plan position:** This is plan 1 of 4 in the vertical-refactor sequence: -- Plan 1 (this doc): Foundation — Phases 1-3 -- Plan 2: Feature migrations — Phases 4-5 (blog canonical, then auth + marketing-pages + navigation + media) -- Plan 3: App + UI integration — Phases 6-7 (core-trpc, route handlers, example pages, core-ui migration) -- Plan 4: Cleanup, enforcement, e2e, docs — Phases 8-11 - -**Spec reference:** `docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md` - ---- - -## File Structure (this plan creates/modifies) - -**Create:** -- `tsconfig.base.json` (root — does not exist yet) -- `packages/core-shared/{package.json,tsconfig.json,turbo.json,vitest.config.ts}` -- `packages/core-shared/src/index.ts` -- `packages/core-shared/src/lib/{env.ts,date.ts}` + tests -- `packages/core-shared/src/payload/index.ts` -- `packages/core-shared/src/payload/access/is-admin.ts` + test -- `packages/core-shared/src/payload/fields/{slug-field.ts,seo-fields.ts}` + tests -- `packages/core-shared/src/payload/blocks/cta.ts` + test -- `packages/core-shared/src/payload/hooks/{set-published-at.ts,slugify-if-missing.ts}` + tests -- `packages/core-shared/src/trpc/{init.ts,context.ts}` -- `packages/core-cms/{package.json,tsconfig.json,turbo.json}` -- `packages/core-cms/src/{index.ts,payload.config.ts,generated-types.ts}` (generated-types is an empty stub initially) -- `packages/core-api/{package.json,tsconfig.json,turbo.json}` -- `packages/core-api/src/{index.ts,root.ts}` -- `packages/core-trpc/{package.json,tsconfig.json,turbo.json}` -- `packages/core-trpc/src/index.ts` -- `packages/core-ui/{package.json,tsconfig.json,turbo.json}` -- `packages/core-ui/src/index.ts` -- `packages/typescript-config/vitest.base.ts` (shared vitest config) - -**Modify:** -- `apps/cms/src/payload.config.ts` (change re-export from `@repo/cms-core` → `@repo/core-cms`) -- `apps/cms/package.json` (add `@repo/core-cms`, leave `@repo/cms-core` for now — removed in Plan 4) - -**Do NOT touch in this plan:** -- `packages/core/`, `packages/api/`, `packages/api-client/`, `packages/cms-core/`, `packages/cms-client/`, `packages/ui/` — these continue to exist; deleted in Plan 4. - ---- - -## Phase 1: Scaffold core-* packages - -### Task 1.1: Add root tsconfig.base.json with path aliases - -**Files:** -- Create: `tsconfig.base.json` - -- [ ] **Step 1: Create the file** - -```json -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@repo/core-shared": ["packages/core-shared/src/index.ts"], - "@repo/core-shared/payload": ["packages/core-shared/src/payload/index.ts"], - "@repo/core-shared/trpc/init": ["packages/core-shared/src/trpc/init.ts"], - "@repo/core-shared/trpc/context": ["packages/core-shared/src/trpc/context.ts"], - "@repo/core-cms": ["packages/core-cms/src/index.ts"], - "@repo/core-cms/generated-types": ["packages/core-cms/src/generated-types.ts"], - "@repo/core-api": ["packages/core-api/src/index.ts"], - "@repo/core-trpc": ["packages/core-trpc/src/index.ts"], - "@repo/core-ui": ["packages/core-ui/src/index.ts"] - } - } -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add tsconfig.base.json -git commit -m "build: add root tsconfig.base.json with core-* path aliases" -``` - ---- - -### Task 1.2: Scaffold @repo/core-shared package - -**Files:** -- Create: `packages/core-shared/package.json` -- Create: `packages/core-shared/tsconfig.json` -- Create: `packages/core-shared/turbo.json` -- Create: `packages/core-shared/src/index.ts` - -- [ ] **Step 1: Create package.json** - -```json -{ - "name": "@repo/core-shared", - "private": true, - "version": "0.0.0", - "type": "module", - "exports": { - ".": "./src/index.ts", - "./payload": "./src/payload/index.ts", - "./trpc/init": "./src/trpc/init.ts", - "./trpc/context": "./src/trpc/context.ts" - }, - "scripts": { - "build": "tsc --noEmit", - "lint": "eslint .", - "test": "vitest run", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@trpc/server": "^11.0.0", - "payload": "^3.14.0", - "superjson": "^2.2.1", - "zod": "^3.24.0" - }, - "devDependencies": { - "@repo/core-eslint": "workspace:*", - "@repo/core-typescript": "workspace:*", - "@types/node": "^22.0.0", - "vitest": "^3.1.0" - } -} -``` - -- [ ] **Step 2: Create tsconfig.json** - -```json -{ - "extends": "@repo/core-typescript/base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} -``` - -- [ ] **Step 3: Create turbo.json (with tag)** - -```json -{ - "extends": ["//"], - "tags": ["core"] -} -``` - -- [ ] **Step 4: Create empty index.ts** - -```typescript -export {}; -``` - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-shared -git commit -m "feat(core-shared): scaffold empty package with exports + tags" -``` - ---- - -### Task 1.3: Scaffold @repo/core-cms package - -**Files:** -- Create: `packages/core-cms/package.json` -- Create: `packages/core-cms/tsconfig.json` -- Create: `packages/core-cms/turbo.json` -- Create: `packages/core-cms/src/index.ts` -- Create: `packages/core-cms/src/generated-types.ts` - -- [ ] **Step 1: Create package.json** - -```json -{ - "name": "@repo/core-cms", - "private": true, - "version": "0.0.0", - "type": "module", - "exports": { - ".": "./src/index.ts", - "./generated-types": "./src/generated-types.ts" - }, - "scripts": { - "build": "tsc --noEmit", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "payload": "^3.14.0", - "@payloadcms/db-postgres": "^3.14.0", - "@payloadcms/richtext-lexical": "^3.14.0" - }, - "devDependencies": { - "@repo/core-eslint": "workspace:*", - "@repo/core-typescript": "workspace:*", - "@types/node": "^22.0.0" - } -} -``` - -- [ ] **Step 2: Create tsconfig.json** - -```json -{ - "extends": "@repo/core-typescript/base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} -``` - -> Note: `lib: ["ES2022", "DOM"]` is needed because Payload's types reference DOM globals (e.g., `URL`). - -- [ ] **Step 3: Create turbo.json** - -```json -{ - "extends": ["//"], - "tags": ["core"] -} -``` - -- [ ] **Step 4: Create empty index.ts** (will export the config in Phase 3) - -```typescript -export {}; -``` - -- [ ] **Step 5: Create empty generated-types.ts placeholder** - -```typescript -// Generated by Payload — do not edit by hand. -// This file is regenerated by `pnpm generate:types` in apps/cms. -export {}; -``` - -- [ ] **Step 6: Commit** - -```bash -git add packages/core-cms -git commit -m "feat(core-cms): scaffold empty package with exports + tags" -``` - ---- - -### Task 1.4: Scaffold @repo/core-api package - -**Files:** -- Create: `packages/core-api/package.json` -- Create: `packages/core-api/tsconfig.json` -- Create: `packages/core-api/turbo.json` -- Create: `packages/core-api/src/root.ts` -- Create: `packages/core-api/src/index.ts` - -- [ ] **Step 1: Create package.json** - -```json -{ - "name": "@repo/core-api", - "private": true, - "version": "0.0.0", - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "build": "tsc --noEmit", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@repo/core-shared": "workspace:*", - "@trpc/server": "^11.0.0" - }, - "devDependencies": { - "@repo/core-eslint": "workspace:*", - "@repo/core-typescript": "workspace:*", - "@types/node": "^22.0.0" - } -} -``` - -- [ ] **Step 2: Create tsconfig.json** - -```json -{ - "extends": "@repo/core-typescript/base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} -``` - -- [ ] **Step 3: Create turbo.json** - -```json -{ - "extends": ["//"], - "tags": ["core"] -} -``` - -- [ ] **Step 4: Create root.ts (empty appRouter that compiles)** - -```typescript -import { router } from "@repo/core-shared/trpc/init"; - -export const appRouter = router({}); - -export type AppRouter = typeof appRouter; -``` - -> Note: This depends on `@repo/core-shared/trpc/init` which will exist after Task 2.13. To make typecheck pass in isolation now, this file imports a not-yet-existing module — but Phase 1 verification (Task 1.7) only runs `pnpm install`, not `typecheck`. Typecheck runs after Phase 2 completes. - -- [ ] **Step 5: Create index.ts** - -```typescript -export { appRouter, type AppRouter } from "./root"; -``` - -- [ ] **Step 6: Commit** - -```bash -git add packages/core-api -git commit -m "feat(core-api): scaffold empty appRouter aggregator" -``` - ---- - -### Task 1.5: Scaffold @repo/core-trpc package - -**Files:** -- Create: `packages/core-trpc/package.json` -- Create: `packages/core-trpc/tsconfig.json` -- Create: `packages/core-trpc/turbo.json` -- Create: `packages/core-trpc/src/index.ts` - -- [ ] **Step 1: Create package.json** - -```json -{ - "name": "@repo/core-trpc", - "private": true, - "version": "0.0.0", - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "build": "tsc --noEmit", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@repo/core-api": "workspace:*", - "@trpc/client": "^11.0.0", - "@trpc/react-query": "^11.0.0", - "@trpc/server": "^11.0.0", - "@tanstack/react-query": "^5.66.0", - "react": "^19.0.0", - "superjson": "^2.2.1" - }, - "devDependencies": { - "@repo/core-eslint": "workspace:*", - "@repo/core-typescript": "workspace:*", - "@types/react": "^19.0.0" - } -} -``` - -- [ ] **Step 2: Create tsconfig.json (jsx for React provider files added in Plan 3)** - -```json -{ - "extends": "@repo/core-typescript/base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"], - "jsx": "preserve" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} -``` - -- [ ] **Step 3: Create turbo.json** - -```json -{ - "extends": ["//"], - "tags": ["core"] -} -``` - -- [ ] **Step 4: Create empty index.ts (client + providers added in Plan 3)** - -```typescript -export {}; -``` - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-trpc -git commit -m "feat(core-trpc): scaffold empty package (client + providers in Plan 3)" -``` - ---- - -### Task 1.6: Scaffold @repo/core-ui package - -**Files:** -- Create: `packages/core-ui/package.json` -- Create: `packages/core-ui/tsconfig.json` -- Create: `packages/core-ui/turbo.json` -- Create: `packages/core-ui/src/index.ts` - -- [ ] **Step 1: Create package.json** - -```json -{ - "name": "@repo/core-ui", - "private": true, - "version": "0.0.0", - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "build": "tsc --noEmit", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "clsx": "^2.1.1", - "react": "^19.0.0", - "tailwind-merge": "^3.0.0" - }, - "devDependencies": { - "@repo/core-eslint": "workspace:*", - "@repo/core-typescript": "workspace:*", - "@types/react": "^19.0.0" - } -} -``` - -- [ ] **Step 2: Create tsconfig.json** - -```json -{ - "extends": "@repo/core-typescript/base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"], - "jsx": "preserve" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} -``` - -- [ ] **Step 3: Create turbo.json** - -```json -{ - "extends": ["//"], - "tags": ["core"] -} -``` - -- [ ] **Step 4: Create empty index.ts (contents migrated from packages/ui in Plan 3)** - -```typescript -export {}; -``` - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-ui -git commit -m "feat(core-ui): scaffold empty package (contents migrated in Plan 3)" -``` - ---- - -### Task 1.7: Install + verify Phase 1 - -- [ ] **Step 1: Install deps (picks up new workspace packages)** - -Run: `pnpm install` - -Expected: install completes; new packages registered in workspace; lockfile updated. - -- [ ] **Step 2: Verify all five packages registered** - -Run: `pnpm list --recursive --depth=-1 | grep -E "core-(shared|cms|api|trpc|ui)"` - -Expected: all five names appear. - -- [ ] **Step 3: Commit lockfile** - -```bash -git add pnpm-lock.yaml -git commit -m "build: update lockfile for new core-* packages" -``` - -> **Phase 1 Gate:** `pnpm install` green. (Typecheck/test deferred until Phase 2 fills `core-shared` — `core-api/src/root.ts` imports a not-yet-existing `core-shared/trpc/init` module.) - ---- - -## Phase 2: Populate core-shared - -### Task 2.1: Add shared vitest base config - -**Files:** -- Create: `packages/typescript-config/vitest.base.ts` -- Modify: `packages/typescript-config/package.json` - -- [ ] **Step 1: Replace packages/typescript-config/package.json with the version that exposes both `base.json` and `vitest.base.ts`** - -Current contents (verified): -```json -{ - "name": "@repo/core-typescript", - "private": true, - "version": "0.0.0" -} -``` - -Replace with: -```json -{ - "name": "@repo/core-typescript", - "private": true, - "version": "0.0.0", - "type": "module", - "exports": { - "./base.json": "./base.json", - "./vitest.base": "./vitest.base.ts" - }, - "devDependencies": { - "vitest": "^3.1.0" - } -} -``` - -- [ ] **Step 2: Create vitest.base.ts** - -```typescript -import { defineConfig } from "vitest/config"; - -export const baseVitestConfig = defineConfig({ - test: { - globals: true, - environment: "node", - include: ["src/**/*.test.ts", "src/**/*.test.tsx", "tests/**/*.test.ts"], - coverage: { - provider: "v8", - reporter: ["text", "html"], - include: ["src/**"], - exclude: ["src/**/*.test.{ts,tsx}", "src/**/index.ts"], - }, - }, -}); -``` - -- [ ] **Step 3: Install vitest into typescript-config** - -Run: `pnpm install` -Expected: vitest devDep installed; lockfile updated. - -- [ ] **Step 4: Commit** - -```bash -git add packages/typescript-config pnpm-lock.yaml -git commit -m "build(typescript-config): add shared vitest base config" -``` - ---- - -### Task 2.2: Add vitest.config.ts to core-shared - -**Files:** -- Create: `packages/core-shared/vitest.config.ts` -- Modify: `packages/core-shared/package.json` (add typescript-config to devDeps if not already) - -- [ ] **Step 1: Create vitest.config.ts** - -```typescript -import { baseVitestConfig } from "@repo/core-typescript/vitest.base"; - -export default baseVitestConfig; -``` - -- [ ] **Step 2: Run vitest to confirm config loads (no tests yet — should report 0 tests)** - -Run: `cd packages/core-shared && pnpm vitest run` - -Expected: "No test files found, exiting with code 1" — that's fine; the config loads. - -- [ ] **Step 3: Commit** - -```bash -git add packages/core-shared/vitest.config.ts -git commit -m "test(core-shared): wire shared vitest base" -``` - ---- - -### Task 2.3: Implement is-admin access helper - -**Files:** -- Create: `packages/core-shared/src/payload/access/is-admin.ts` -- Create: `packages/core-shared/src/payload/access/is-admin.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -// packages/core-shared/src/payload/access/is-admin.test.ts -import { describe, expect, it } from "vitest"; -import { isAdmin } from "./is-admin"; - -describe("isAdmin", () => { - it("returns true when user role is 'admin'", () => { - expect(isAdmin({ req: { user: { role: "admin" } } })).toBe(true); - }); - - it("returns false when user role is not 'admin'", () => { - expect(isAdmin({ req: { user: { role: "editor" } } })).toBe(false); - }); - - it("returns false when user has no role", () => { - expect(isAdmin({ req: { user: {} } })).toBe(false); - }); - - it("returns false when there is no user", () => { - expect(isAdmin({ req: {} })).toBe(false); - }); -}); -``` - -- [ ] **Step 2: Run test — expect failure** - -Run: `cd packages/core-shared && pnpm vitest run src/payload/access/is-admin.test.ts` -Expected: FAIL — "Cannot find module './is-admin'" - -- [ ] **Step 3: Implement** - -```typescript -// packages/core-shared/src/payload/access/is-admin.ts -export function isAdmin({ - req, -}: { - req: { user?: { role?: string } }; -}): boolean { - return req.user?.role === "admin"; -} -``` - -- [ ] **Step 4: Run test — expect pass** - -Run: `cd packages/core-shared && pnpm vitest run src/payload/access/is-admin.test.ts` -Expected: PASS — 4 tests. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-shared/src/payload/access -git commit -m "feat(core-shared): add isAdmin access helper" -``` - ---- - -### Task 2.4: Implement slug-field - -**Files:** -- Create: `packages/core-shared/src/payload/fields/slug-field.ts` -- Create: `packages/core-shared/src/payload/fields/slug-field.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -// packages/core-shared/src/payload/fields/slug-field.test.ts -import { describe, expect, it } from "vitest"; -import { slugField } from "./slug-field"; - -describe("slugField", () => { - it("returns a Payload Field with default name 'slug'", () => { - const field = slugField(); - expect(field.name).toBe("slug"); - expect(field.type).toBe("text"); - expect(field.required).toBe(true); - expect(field.unique).toBe(true); - expect(field.index).toBe(true); - }); - - it("accepts a custom field name", () => { - const field = slugField("permalink"); - expect(field.name).toBe("permalink"); - }); -}); -``` - -- [ ] **Step 2: Run — expect failure** - -Run: `cd packages/core-shared && pnpm vitest run src/payload/fields/slug-field.test.ts` -Expected: FAIL — "Cannot find module './slug-field'" - -- [ ] **Step 3: Implement** - -```typescript -// packages/core-shared/src/payload/fields/slug-field.ts -import type { Field } from "payload"; - -export function slugField(name = "slug"): Field { - return { - name, - type: "text", - required: true, - unique: true, - index: true, - }; -} -``` - -- [ ] **Step 4: Run — expect pass** - -Run: `cd packages/core-shared && pnpm vitest run src/payload/fields/slug-field.test.ts` -Expected: PASS — 2 tests. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-shared/src/payload/fields/slug-field.ts packages/core-shared/src/payload/fields/slug-field.test.ts -git commit -m "feat(core-shared): add slugField helper" -``` - ---- - -### Task 2.5: Implement seo-fields - -**Files:** -- Create: `packages/core-shared/src/payload/fields/seo-fields.ts` -- Create: `packages/core-shared/src/payload/fields/seo-fields.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -// packages/core-shared/src/payload/fields/seo-fields.test.ts -import { describe, expect, it } from "vitest"; -import { seoFields } from "./seo-fields"; - -describe("seoFields", () => { - it("is a group field named 'seo'", () => { - expect(seoFields.name).toBe("seo"); - expect(seoFields.type).toBe("group"); - }); - - it("contains required title and optional description", () => { - if (seoFields.type !== "group") { - throw new Error("seoFields must be a group"); - } - const fieldNames = seoFields.fields.map((f) => - "name" in f ? f.name : null, - ); - expect(fieldNames).toContain("title"); - expect(fieldNames).toContain("description"); - - const titleField = seoFields.fields.find( - (f) => "name" in f && f.name === "title", - ); - expect(titleField && "required" in titleField && titleField.required).toBe( - true, - ); - }); -}); -``` - -- [ ] **Step 2: Run — expect failure** - -Run: `cd packages/core-shared && pnpm vitest run src/payload/fields/seo-fields.test.ts` -Expected: FAIL — "Cannot find module './seo-fields'" - -- [ ] **Step 3: Implement** - -```typescript -// packages/core-shared/src/payload/fields/seo-fields.ts -import type { Field } from "payload"; - -export const seoFields: Field = { - name: "seo", - type: "group", - fields: [ - { name: "title", type: "text", required: true }, - { name: "description", type: "textarea" }, - ], -}; -``` - -- [ ] **Step 4: Run — expect pass** - -Run: `cd packages/core-shared && pnpm vitest run src/payload/fields/seo-fields.test.ts` -Expected: PASS — 2 tests. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-shared/src/payload/fields/seo-fields.ts packages/core-shared/src/payload/fields/seo-fields.test.ts -git commit -m "feat(core-shared): add seoFields group" -``` - ---- - -### Task 2.6: Implement cta block - -**Files:** -- Create: `packages/core-shared/src/payload/blocks/cta.ts` -- Create: `packages/core-shared/src/payload/blocks/cta.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -// packages/core-shared/src/payload/blocks/cta.test.ts -import { describe, expect, it } from "vitest"; -import { cta } from "./cta"; - -describe("cta block", () => { - it("has slug 'cta'", () => { - expect(cta.slug).toBe("cta"); - }); - - it("requires title, buttonLabel, and href", () => { - const fieldNames = cta.fields.map((f) => ("name" in f ? f.name : null)); - expect(fieldNames).toEqual(["title", "buttonLabel", "href"]); - cta.fields.forEach((f) => { - if ("required" in f) expect(f.required).toBe(true); - }); - }); -}); -``` - -- [ ] **Step 2: Run — expect failure** - -Run: `cd packages/core-shared && pnpm vitest run src/payload/blocks/cta.test.ts` -Expected: FAIL — "Cannot find module './cta'" - -- [ ] **Step 3: Implement** - -```typescript -// packages/core-shared/src/payload/blocks/cta.ts -import type { Block } from "payload"; - -export const cta: Block = { - slug: "cta", - fields: [ - { name: "title", type: "text", required: true }, - { name: "buttonLabel", type: "text", required: true }, - { name: "href", type: "text", required: true }, - ], -}; -``` - -- [ ] **Step 4: Run — expect pass** - -Run: `cd packages/core-shared && pnpm vitest run src/payload/blocks/cta.test.ts` -Expected: PASS — 2 tests. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-shared/src/payload/blocks -git commit -m "feat(core-shared): add cta block" -``` - ---- - -### Task 2.7: Implement set-published-at hook - -**Files:** -- Create: `packages/core-shared/src/payload/hooks/set-published-at.ts` -- Create: `packages/core-shared/src/payload/hooks/set-published-at.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -// packages/core-shared/src/payload/hooks/set-published-at.test.ts -import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; -import { setPublishedAt } from "./set-published-at"; - -describe("setPublishedAt", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-05-04T12:00:00.000Z")); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("sets publishedAt to now when status is published and publishedAt is missing", () => { - const result = setPublishedAt({ data: { status: "published" } }); - expect(result?.publishedAt).toBe("2026-05-04T12:00:00.000Z"); - }); - - it("does not overwrite an existing publishedAt", () => { - const result = setPublishedAt({ - data: { status: "published", publishedAt: "2025-01-01T00:00:00.000Z" }, - }); - expect(result?.publishedAt).toBe("2025-01-01T00:00:00.000Z"); - }); - - it("does not set publishedAt when status is not published", () => { - const result = setPublishedAt({ data: { status: "draft" } }); - expect(result?.publishedAt).toBeUndefined(); - }); - - it("returns data unchanged when data is missing", () => { - expect(setPublishedAt({})).toBeUndefined(); - }); -}); -``` - -- [ ] **Step 2: Run — expect failure** - -Run: `cd packages/core-shared && pnpm vitest run src/payload/hooks/set-published-at.test.ts` -Expected: FAIL — "Cannot find module './set-published-at'" - -- [ ] **Step 3: Implement** - -```typescript -// packages/core-shared/src/payload/hooks/set-published-at.ts -export function setPublishedAt({ - data, -}: { - data?: { status?: string; publishedAt?: string | null }; -}) { - if (!data) return data; - - if (data.status === "published" && !data.publishedAt) { - data.publishedAt = new Date().toISOString(); - } - - return data; -} -``` - -- [ ] **Step 4: Run — expect pass** - -Run: `cd packages/core-shared && pnpm vitest run src/payload/hooks/set-published-at.test.ts` -Expected: PASS — 4 tests. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-shared/src/payload/hooks/set-published-at.ts packages/core-shared/src/payload/hooks/set-published-at.test.ts -git commit -m "feat(core-shared): add setPublishedAt hook" -``` - ---- - -### Task 2.8: Implement slugify-if-missing hook - -**Files:** -- Create: `packages/core-shared/src/payload/hooks/slugify-if-missing.ts` -- Create: `packages/core-shared/src/payload/hooks/slugify-if-missing.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -// packages/core-shared/src/payload/hooks/slugify-if-missing.test.ts -import { describe, expect, it } from "vitest"; -import { slugifyIfMissing } from "./slugify-if-missing"; - -describe("slugifyIfMissing", () => { - it("derives slug from title on create when slug is empty", () => { - const result = slugifyIfMissing({ - data: { title: "Hello World" }, - operation: "create", - }); - expect(result?.slug).toBe("hello-world"); - }); - - it("does not overwrite an existing slug", () => { - const result = slugifyIfMissing({ - data: { title: "New Title", slug: "kept-slug" }, - operation: "create", - }); - expect(result?.slug).toBe("kept-slug"); - }); - - it("does nothing on update", () => { - const result = slugifyIfMissing({ - data: { title: "Hello World" }, - operation: "update", - }); - expect(result?.slug).toBeUndefined(); - }); - - it("strips non-alphanumerics and trims hyphens", () => { - const result = slugifyIfMissing({ - data: { title: " Hello, World!! 2026 " }, - operation: "create", - }); - expect(result?.slug).toBe("hello-world-2026"); - }); - - it("returns data unchanged when title is missing", () => { - const result = slugifyIfMissing({ - data: {}, - operation: "create", - }); - expect(result?.slug).toBeUndefined(); - }); -}); -``` - -- [ ] **Step 2: Run — expect failure** - -Run: `cd packages/core-shared && pnpm vitest run src/payload/hooks/slugify-if-missing.test.ts` -Expected: FAIL — "Cannot find module './slugify-if-missing'" - -- [ ] **Step 3: Implement** - -```typescript -// packages/core-shared/src/payload/hooks/slugify-if-missing.ts -export function slugifyIfMissing({ - data, - operation, -}: { - data?: { title?: string; slug?: string }; - operation?: string; -}) { - if (!data) return data; - if (operation !== "create") return data; - if (data.slug) return data; - if (!data.title) return data; - - data.slug = data.title - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - - return data; -} -``` - -- [ ] **Step 4: Run — expect pass** - -Run: `cd packages/core-shared && pnpm vitest run src/payload/hooks/slugify-if-missing.test.ts` -Expected: PASS — 5 tests. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-shared/src/payload/hooks/slugify-if-missing.ts packages/core-shared/src/payload/hooks/slugify-if-missing.test.ts -git commit -m "feat(core-shared): add slugifyIfMissing hook" -``` - ---- - -### Task 2.9: Implement env helper - -**Files:** -- Create: `packages/core-shared/src/lib/env.ts` -- Create: `packages/core-shared/src/lib/env.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -// packages/core-shared/src/lib/env.test.ts -import { describe, expect, it, beforeEach, afterEach } from "vitest"; -import { requireEnv } from "./env"; - -describe("requireEnv", () => { - const originalEnv = process.env.SOME_KEY; - - afterEach(() => { - if (originalEnv === undefined) delete process.env.SOME_KEY; - else process.env.SOME_KEY = originalEnv; - }); - - it("returns the value when set", () => { - process.env.SOME_KEY = "value"; - expect(requireEnv("SOME_KEY")).toBe("value"); - }); - - it("throws when missing", () => { - delete process.env.SOME_KEY; - expect(() => requireEnv("SOME_KEY")).toThrow( - /Missing required env var: SOME_KEY/, - ); - }); - - it("throws when empty string", () => { - process.env.SOME_KEY = ""; - expect(() => requireEnv("SOME_KEY")).toThrow(); - }); -}); -``` - -- [ ] **Step 2: Run — expect failure** - -Run: `cd packages/core-shared && pnpm vitest run src/lib/env.test.ts` -Expected: FAIL — "Cannot find module './env'" - -- [ ] **Step 3: Implement** - -```typescript -// packages/core-shared/src/lib/env.ts -export function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) { - throw new Error(`Missing required env var: ${name}`); - } - return value; -} -``` - -- [ ] **Step 4: Run — expect pass** - -Run: `cd packages/core-shared && pnpm vitest run src/lib/env.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-shared/src/lib/env.ts packages/core-shared/src/lib/env.test.ts -git commit -m "feat(core-shared): add requireEnv helper" -``` - ---- - -### Task 2.10: Implement date helper - -**Files:** -- Create: `packages/core-shared/src/lib/date.ts` -- Create: `packages/core-shared/src/lib/date.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -// packages/core-shared/src/lib/date.test.ts -import { describe, expect, it } from "vitest"; -import { toIsoString } from "./date"; - -describe("toIsoString", () => { - it("converts a Date to ISO string", () => { - const d = new Date("2026-05-04T12:00:00.000Z"); - expect(toIsoString(d)).toBe("2026-05-04T12:00:00.000Z"); - }); - - it("passes through an existing ISO string", () => { - expect(toIsoString("2026-05-04T12:00:00.000Z")).toBe( - "2026-05-04T12:00:00.000Z", - ); - }); - - it("returns null for null input", () => { - expect(toIsoString(null)).toBeNull(); - }); - - it("returns null for undefined input", () => { - expect(toIsoString(undefined)).toBeNull(); - }); -}); -``` - -- [ ] **Step 2: Run — expect failure** - -Run: `cd packages/core-shared && pnpm vitest run src/lib/date.test.ts` -Expected: FAIL — "Cannot find module './date'" - -- [ ] **Step 3: Implement** - -```typescript -// packages/core-shared/src/lib/date.ts -export function toIsoString(input: Date | string | null | undefined): string | null { - if (input === null || input === undefined) return null; - if (input instanceof Date) return input.toISOString(); - return input; -} -``` - -- [ ] **Step 4: Run — expect pass** - -Run: `cd packages/core-shared && pnpm vitest run src/lib/date.test.ts` -Expected: PASS — 4 tests. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-shared/src/lib/date.ts packages/core-shared/src/lib/date.test.ts -git commit -m "feat(core-shared): add toIsoString helper" -``` - ---- - -### Task 2.11: Create payload barrel export - -**Files:** -- Create: `packages/core-shared/src/payload/index.ts` - -- [ ] **Step 1: Create barrel** - -```typescript -// packages/core-shared/src/payload/index.ts -export { isAdmin } from "./access/is-admin"; -export { slugField } from "./fields/slug-field"; -export { seoFields } from "./fields/seo-fields"; -export { cta } from "./blocks/cta"; -export { setPublishedAt } from "./hooks/set-published-at"; -export { slugifyIfMissing } from "./hooks/slugify-if-missing"; -``` - -- [ ] **Step 2: Verify import path resolves** - -Run: `cd packages/core-shared && pnpm typecheck` -Expected: PASS — no errors. - -- [ ] **Step 3: Commit** - -```bash -git add packages/core-shared/src/payload/index.ts -git commit -m "feat(core-shared): add payload barrel export" -``` - ---- - -### Task 2.12: Implement trpc init - -**Files:** -- Create: `packages/core-shared/src/trpc/init.ts` - -- [ ] **Step 1: Implement** (no test — exercised indirectly through routers in Plan 2) - -```typescript -// packages/core-shared/src/trpc/init.ts -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: Verify it compiles** - -Run: `cd packages/core-shared && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add packages/core-shared/src/trpc/init.ts -git commit -m "feat(core-shared): add tRPC init with superjson" -``` - ---- - -### Task 2.13: Implement trpc context - -**Files:** -- Create: `packages/core-shared/src/trpc/context.ts` - -- [ ] **Step 1: Implement** - -```typescript -// packages/core-shared/src/trpc/context.ts -export async function createTrpcContext() { - return {}; -} - -export type TrpcContext = Awaited>; -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `cd packages/core-shared && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add packages/core-shared/src/trpc/context.ts -git commit -m "feat(core-shared): add tRPC context factory" -``` - ---- - -### Task 2.14: Wire root index.ts and verify all tests - -**Files:** -- Modify: `packages/core-shared/src/index.ts` - -- [ ] **Step 1: Replace empty index with re-exports** - -```typescript -// packages/core-shared/src/index.ts -export { requireEnv } from "./lib/env"; -export { toIsoString } from "./lib/date"; -``` - -> Note: Payload primitives are accessed via `@repo/core-shared/payload` subpath; tRPC via `/trpc/init` and `/trpc/context`. The root export is just the lib helpers. - -- [ ] **Step 2: Run all core-shared tests** - -Run: `cd packages/core-shared && pnpm test` -Expected: PASS — 26 tests across 8 test files: is-admin (4) + slug-field (2) + seo-fields (2) + cta (2) + set-published-at (4) + slugify-if-missing (5) + env (3) + date (4). Use `pnpm vitest run --reporter=verbose` for a full list. - -- [ ] **Step 3: Run typecheck across the whole repo** - -Run: `pnpm typecheck` -Expected: PASS (now that `core-shared/trpc/init` exists, `core-api/src/root.ts` from Task 1.4 resolves). - -- [ ] **Step 4: Commit** - -```bash -git add packages/core-shared/src/index.ts -git commit -m "feat(core-shared): wire root index.ts barrel" -``` - -> **Phase 2 Gate:** `pnpm test --filter @repo/core-shared` green; `pnpm typecheck` green across the whole repo. - ---- - -## Phase 3: Populate core-cms stub + repoint apps/cms - -### Task 3.1: Create core-cms payload.config.ts (stub — empty arrays) - -**Files:** -- Create: `packages/core-cms/src/payload.config.ts` - -- [ ] **Step 1: Implement** - -```typescript -// packages/core-cms/src/payload.config.ts -import { buildConfig } from "payload"; -import { postgresAdapter } from "@payloadcms/db-postgres"; -import { lexicalEditor } from "@payloadcms/richtext-lexical"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const filename = fileURLToPath(import.meta.url); -const dirname = path.dirname(filename); - -export default buildConfig({ - editor: lexicalEditor(), - collections: [], - globals: [], - secret: process.env.PAYLOAD_SECRET || "default-secret-change-me", - db: postgresAdapter({ - pool: { - connectionString: - process.env.DATABASE_URL || - "postgresql://postgres:postgres@localhost:5432/template", - }, - }), - typescript: { - outputFile: path.resolve(dirname, "generated-types.ts"), - }, -}); -``` - -> Note: `collections: []` and `globals: []` initially. Plan 2 wires in feature-owned `@repo//cms` exports here. - -- [ ] **Step 2: Update index.ts to re-export the config** - -```typescript -// packages/core-cms/src/index.ts -export { default } from "./payload.config"; -``` - -- [ ] **Step 3: Verify it compiles** - -Run: `cd packages/core-cms && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/core-cms/src -git commit -m "feat(core-cms): add stub payload.config (empty collections/globals)" -``` - ---- - -### Task 3.2: Repoint apps/cms to @repo/core-cms - -**Files:** -- Modify: `apps/cms/src/payload.config.ts` -- Modify: `apps/cms/package.json` - -- [ ] **Step 1: Update the re-export in apps/cms/src/payload.config.ts** - -```typescript -// apps/cms/src/payload.config.ts -// Re-export Payload config from @repo/core-cms. -// This file exists so @payload-config resolves correctly in the CMS app. -export { default } from "@repo/core-cms"; -``` - -- [ ] **Step 2: Update apps/cms/package.json — add @repo/core-cms dependency** - -Add to `dependencies` block (keep `@repo/cms-core` for now — removed in Plan 4): - -```json -"@repo/core-cms": "workspace:*", -``` - -- [ ] **Step 3: Install** - -Run: `pnpm install` -Expected: install completes; new dep added. - -- [ ] **Step 4: Verify cms typecheck** - -Run: `pnpm typecheck --filter @repo/cms` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add apps/cms/src/payload.config.ts apps/cms/package.json pnpm-lock.yaml -git commit -m "feat(cms): repoint @payload-config from cms-core to core-cms" -``` - ---- - -### Task 3.3: Boot apps/cms against new core-cms config (smoke test) - -- [ ] **Step 1: Ensure Postgres is running** - -Run: `docker compose ps postgres` -Expected: postgres container healthy. If not: `docker compose up -d postgres`. - -- [ ] **Step 2: Start the cms dev server in the background** - -Run: `pnpm dev --filter @repo/cms` - -This starts Next.js on port 3001. Wait ~10s for "Ready" / "compiled successfully". - -- [ ] **Step 3: Verify the admin UI responds** - -Run: `curl -sf http://localhost:3001/admin -o /dev/null && echo OK` -Expected: `OK` (the admin UI responds with HTML — even if the empty-collections config means there's nothing to manage yet, the admin shell loads). - -- [ ] **Step 4: Stop the dev server** - -Stop the `pnpm dev` process (Ctrl+C if foreground, or kill the background job). - -- [ ] **Step 5: No commit needed for the smoke test (no file changes)** - ---- - -### Task 3.4: Verify Payload type generation against the stub - -- [ ] **Step 1: Run type generation** - -Run: `cd apps/cms && pnpm generate:types` -Expected: writes a new `generated-types.ts` somewhere (per the config's `outputFile: path.resolve(dirname, "generated-types.ts")` — this puts the file in `packages/core-cms/src/generated-types.ts`). - -- [ ] **Step 2: Inspect the generated file** - -Run: `head -30 packages/core-cms/src/generated-types.ts` -Expected: a valid TypeScript file (probably mostly empty or with just a `Config` interface and Auth types since Users isn't in the empty arrays — Payload may include built-in user/preference types). - -- [ ] **Step 3: Verify the file typechecks** - -Run: `pnpm typecheck --filter @repo/core-cms` -Expected: PASS. - -- [ ] **Step 4: Commit the regenerated types** - -```bash -git add packages/core-cms/src/generated-types.ts -git commit -m "feat(core-cms): generate initial (empty) Payload types" -``` - -> **Phase 3 Gate:** `pnpm dev --filter @repo/cms` boots and serves `/admin`; `pnpm generate:types` succeeds; `pnpm typecheck && pnpm test --filter @repo/core-shared` green. - ---- - -## Final Verification - -- [ ] **Step 1: Repo-wide typecheck** - -Run: `pnpm typecheck` -Expected: PASS across all packages (old `@repo/core`, `@repo/api`, etc. still exist and still typecheck — they're untouched). - -- [ ] **Step 2: Repo-wide tests** - -Run: `pnpm test` -Expected: PASS — `core-shared` tests pass; old `@repo/core` tests still pass (unchanged). - -- [ ] **Step 3: Lint** - -Run: `pnpm lint` -Expected: PASS (no boundary rules added yet — those land in Plan 4). - -- [ ] **Step 4: Build** - -Run: `pnpm build` -Expected: PASS. - -- [ ] **Step 5: Final summary commit (optional — only if any cleanup edits were needed)** - -If everything is already committed phase-by-phase, no extra commit needed. - ---- - -## Plan 1 Done Criteria - -- [ ] All 26 tests in `core-shared` pass (8 test files) -- [ ] `pnpm typecheck` green across whole repo -- [ ] `pnpm dev --filter @repo/cms` serves `/admin` against new `@repo/core-cms` config -- [ ] `pnpm generate:types` succeeds -- [ ] Five new packages (`core-shared`, `core-cms`, `core-api`, `core-trpc`, `core-ui`) registered in workspace -- [ ] `tsconfig.base.json` exists at repo root with all five `@repo/core-*` aliases -- [ ] No deletions yet (old packages still intact for Plan 2-4 to drain) - -**Next plan:** Plan 2 — Feature migrations. Migrate the `blog` feature end-to-end as the canonical example, then `auth`, `marketing-pages`, `navigation`, `media`. Each feature follows the same template (entities → application → infrastructure → di → interface-adapters/controllers → integrations/cms + integrations/api → ui), composes its `/cms` export into `core-cms` and its `/api` export into `core-api`. diff --git a/docs/superpowers/plans/2026-05-04-plan-2-blog-feature.md b/docs/superpowers/plans/2026-05-04-plan-2-blog-feature.md deleted file mode 100644 index b574de7..0000000 --- a/docs/superpowers/plans/2026-05-04-plan-2-blog-feature.md +++ /dev/null @@ -1,1842 +0,0 @@ -# Vertical Refactor — Plan 2: Blog Feature (Canonical Migration) - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Migrate the existing `content/articles` domain (currently inside `packages/core` and `packages/cms-core`) into a new vertical `packages/blog` feature package with the full canonical layered shape: entities → application → infrastructure → interface-adapters → di → integrations/cms → integrations/api → ui. Wire the feature's `/cms` export into `core-cms` composition and its `/api` export into `core-api` composition. Establish the per-feature InversifyJS container pattern. Pattern proven here will be replicated for `auth`, `media`, `marketing-pages`, and `navigation` in subsequent plans. - -**Architecture:** One new package (`@repo/blog`) with the full canonical structure. Per-feature InversifyJS container holds the `IArticlesRepository` binding (mock by default for tests; payload-backed in production via `getPayload({ config })` from `@repo/core-cms`). tRPC procedures in `integrations/api/router.ts` call controllers, which Zod-parse and delegate to use-cases, which resolve the repo via the feature container. Articles collection moves from `cms-core` into `blog/integrations/cms/collections/`. - -**Tech Stack:** InversifyJS 6.x with reflect-metadata, Vitest 3.x, Zod 3, tRPC 11, Payload 3.14 Local API. - -**Plan position:** Plan 2 of 6 in the vertical-refactor sequence (revised from original 4 during execution to keep each plan digestible): -- Plan 1 ✅ Foundation — core-* package scaffolds + core-shared primitives + core-cms stub -- **Plan 2 (this doc):** Blog feature (canonical) — full layered shape, simplified schema (no cross-feature relationships) -- Plan 3: Auth + Media features + restore blog's `author` relationship and `featuredImage` -- Plan 4: Marketing-pages + Navigation features -- Plan 5: App + UI integration — core-trpc client/providers, route handlers, example pages -- Plan 6: Cleanup + boundary enforcement + Playwright + docs rewrite - -**Spec reference:** `docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md` - ---- - -## Schema simplifications in this plan - -The existing `Articles` collection in `packages/cms-core/src/collections/articles/fields.ts` declares two cross-feature references: -- `author: relationship → users` (users collection is owned by the auth feature, migrated in Plan 3) -- `featuredImage: upload → media` (media collection is owned by the media feature, migrated in Plan 3) - -To keep Plan 2 atomic, the migrated `articles.ts` collection here uses **simplified placeholders**: -- `author` becomes a `text` field (string). -- `featuredImage` is omitted entirely. - -**Plan 3 restores both fields to their relationship/upload form.** This is called out explicitly in those Plan 3 tasks. The simplification is also documented in a TODO comment in the new `articles.ts` so an engineer reading the file later understands. - -The domain `Article.content` field is also widened: the existing entity has `content: z.string()` but Payload's actual storage is rich-text JSON. New entity uses `content: z.unknown()` to honestly model rich-text content (matches spec §11.9's `body: unknown` pattern). The mock repository's tests are adjusted accordingly. - ---- - -## File Structure (this plan creates/modifies) - -**Create — new `@repo/blog` package:** -- `packages/blog/{package.json,tsconfig.json,turbo.json,vitest.config.ts}` -- `packages/blog/src/index.ts` — barrel export -- `packages/blog/src/entities/article.ts` + `article.test.ts` -- `packages/blog/src/entities/errors.ts` -- `packages/blog/src/application/repositories/articles-repository.interface.ts` -- `packages/blog/src/application/use-cases/get-articles.use-case.ts` + `get-articles.use-case.test.ts` -- `packages/blog/src/application/use-cases/create-article.use-case.ts` + `create-article.use-case.test.ts` -- `packages/blog/src/infrastructure/repositories/mock-articles.repository.ts` -- `packages/blog/src/infrastructure/repositories/payload-articles.repository.ts` + `payload-articles.repository.test.ts` -- `packages/blog/src/interface-adapters/controllers/articles.controller.ts` + `articles.controller.test.ts` -- `packages/blog/src/di/symbols.ts` -- `packages/blog/src/di/module.ts` -- `packages/blog/src/di/container.ts` + `container.test.ts` -- `packages/blog/src/integrations/cms/collections/articles.ts` -- `packages/blog/src/integrations/cms/index.ts` -- `packages/blog/src/integrations/api/router.ts` -- `packages/blog/src/ui/query.ts` -- `packages/blog/tests/articles.feature.test.ts` - -**Modify:** -- `packages/core-cms/src/payload.config.ts` — import + register `articles` from `@repo/blog/cms` -- `packages/core-api/src/root.ts` — import + register `blogRouter` from `@repo/blog/api` -- `tsconfig.base.json` — add `@repo/blog`, `@repo/blog/cms`, `@repo/blog/api` aliases -- `apps/cms/package.json` — add `@repo/blog` dependency (so the workspace resolves the schema import) - -**Do NOT touch:** -- `packages/core/` (still owns auth domain — left intact for Plan 3) -- `packages/api/` (still wires the old content router; orphaned but unused after Plan 2 — actually used by ZERO consumers since reference apps are empty) -- `packages/cms-core/src/collections/articles/` — the OLD location. After Plan 2, both old and new exist; Plan 6 deletes `cms-core` entirely. Apps/cms reads from `core-cms` which only sees the NEW location. - ---- - -## Phase 1: Blog package skeleton + entity layer - -### Task 2.1: Scaffold @repo/blog package - -**Files:** -- Create: `packages/blog/package.json` -- Create: `packages/blog/tsconfig.json` -- Create: `packages/blog/turbo.json` -- Create: `packages/blog/vitest.config.ts` -- Create: `packages/blog/src/index.ts` - -- [ ] **Step 1: Create package.json** - -```json -{ - "name": "@repo/blog", - "private": true, - "version": "0.0.0", - "type": "module", - "exports": { - ".": "./src/index.ts", - "./cms": "./src/integrations/cms/index.ts", - "./api": "./src/integrations/api/router.ts" - }, - "scripts": { - "build": "tsc --noEmit", - "lint": "eslint .", - "test": "vitest run --passWithNoTests", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@repo/core-cms": "workspace:*", - "@repo/core-shared": "workspace:*", - "@trpc/server": "^11.0.0", - "inversify": "^6.2.0", - "payload": "^3.14.0", - "reflect-metadata": "^0.2.2", - "zod": "^3.24.0" - }, - "devDependencies": { - "@repo/core-eslint": "workspace:*", - "@repo/core-typescript": "workspace:*", - "@types/node": "^22.0.0", - "vitest": "^3.1.0" - } -} -``` - -- [ ] **Step 2: Create tsconfig.json** - -```json -{ - "extends": "@repo/core-typescript/base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"], - "jsx": "preserve", - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["src/**/*", "tests/**/*"], - "exclude": ["node_modules", "dist"] -} -``` - -> Note: `@/*` path alias is defined here for use in **test files** only. Source files inside `src/` should use **relative imports** (`../../entities/article` etc.). Reason: when a downstream package like `@repo/core-api` typechecks and follows imports into `@repo/blog`, it descends into blog's source files but doesn't have blog's `@/` alias in its own tsconfig — causing TS2307 errors. Relative imports in source files avoid this. Tests stay in `tests/` or alongside source where they use the same package context, so `@/` works there. Same convention applies to all feature packages in subsequent plans. - -- [ ] **Step 3: Create turbo.json** - -```json -{ - "extends": ["//"], - "tags": ["feature"] -} -``` - -- [ ] **Step 4: Create vitest.config.ts** - -```typescript -import path from "node:path"; -import { baseVitestConfig } from "@repo/core-typescript/vitest.base"; - -export default { - ...baseVitestConfig, - resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), - }, - }, -}; -``` - -> Note: Vitest does NOT automatically read tsconfig `paths`. The `@/` alias used in `src/**/*.ts` source/test files must be declared explicitly in vitest.config.ts via `resolve.alias`. Same pattern repeats for every feature package's vitest.config.ts. - -- [ ] **Step 5: Create empty index.ts** - -```typescript -export {}; -``` - -- [ ] **Step 6: Add path aliases to root tsconfig.base.json** - -Read the current `tsconfig.base.json`. Add three new entries to `compilerOptions.paths` (alphabetically — between `@repo/core-ui` and `@repo/core-shared` if existing list is sorted; otherwise just at the end of the paths object before the closing brace): - -```json -"@repo/blog": ["packages/blog/src/index.ts"], -"@repo/blog/cms": ["packages/blog/src/integrations/cms/index.ts"], -"@repo/blog/api": ["packages/blog/src/integrations/api/router.ts"] -``` - -- [ ] **Step 7: Install + verify** - -Run: `pnpm install` -Expected: `@repo/blog` registered in workspace; lockfile updated. - -- [ ] **Step 8: Commit** - -```bash -git add packages/blog tsconfig.base.json pnpm-lock.yaml -git commit -m "feat(blog): scaffold empty package with feature tag + path aliases" -``` - ---- - -### Task 2.2: Port Article entity (with content widened to unknown) - -**Files:** -- Create: `packages/blog/src/entities/article.ts` -- Create: `packages/blog/src/entities/article.test.ts` -- Create: `packages/blog/src/entities/errors.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -// packages/blog/src/entities/article.test.ts -import { describe, expect, it } from "vitest"; -import { articleSchema, articleStatusSchema, type Article } from "./article"; - -describe("articleSchema", () => { - it("accepts a minimal valid article with default status", () => { - const result = articleSchema.parse({ - id: "abc", - title: "Hello", - slug: "hello", - content: { type: "doc", children: [] }, - authorId: "u1", - createdAt: new Date(), - updatedAt: new Date(), - }); - expect(result.status).toBe("draft"); - }); - - it("accepts unknown rich-text content", () => { - const result = articleSchema.parse({ - id: "abc", - title: "Hello", - slug: "hello", - content: "any string is also fine", - authorId: "u1", - createdAt: new Date(), - updatedAt: new Date(), - }); - expect(result.content).toBe("any string is also fine"); - }); - - it("rejects empty title", () => { - expect(() => - articleSchema.parse({ - id: "a", - title: "", - slug: "s", - content: null, - authorId: "u", - createdAt: new Date(), - updatedAt: new Date(), - }), - ).toThrow(); - }); - - it("rejects title over 255 chars", () => { - expect(() => - articleSchema.parse({ - id: "a", - title: "x".repeat(256), - slug: "s", - content: null, - authorId: "u", - createdAt: new Date(), - updatedAt: new Date(), - }), - ).toThrow(); - }); -}); - -describe("articleStatusSchema", () => { - it("accepts 'draft' and 'published'", () => { - expect(articleStatusSchema.parse("draft")).toBe("draft"); - expect(articleStatusSchema.parse("published")).toBe("published"); - }); - - it("rejects unknown status", () => { - expect(() => articleStatusSchema.parse("archived")).toThrow(); - }); -}); - -describe("Article type", () => { - it("widens content to unknown", () => { - const _example: Article = { - id: "x", - title: "t", - slug: "s", - content: { whatever: true }, - status: "draft", - authorId: "u", - createdAt: new Date(), - updatedAt: new Date(), - }; - expect(_example).toBeDefined(); - }); -}); -``` - -- [ ] **Step 2: Run — expect failure** - -Run: `cd packages/blog && pnpm vitest run src/entities/article.test.ts` -Expected: FAIL — "Cannot find module './article'" - -- [ ] **Step 3: Implement article entity** - -```typescript -// packages/blog/src/entities/article.ts -import { z } from "zod"; - -export const articleStatusSchema = z.enum(["draft", "published"]); - -export const articleSchema = z.object({ - id: z.string(), - title: z.string().min(1).max(255), - slug: z.string().min(1).max(255), - content: z.unknown(), - status: articleStatusSchema.default("draft"), - authorId: z.string(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type Article = z.infer; -export type ArticleStatus = z.infer; -``` - -- [ ] **Step 4: Create errors.ts (domain-specific errors)** - -```typescript -// packages/blog/src/entities/errors.ts -export class ArticleNotFoundError extends Error { - constructor(message = "Article not found", options?: ErrorOptions) { - super(message, options); - } -} - -export class InputParseError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - } -} -``` - -> Note: `InputParseError` is duplicated here because the new architecture has each feature own its errors (no shared `@repo/core/entities/errors`). The class is small. - -- [ ] **Step 5: Run — expect pass** - -Run: `cd packages/blog && pnpm vitest run src/entities/article.test.ts` -Expected: PASS — 7 tests. - -- [ ] **Step 6: Commit** - -```bash -git add packages/blog/src/entities -git commit -m "feat(blog): add Article entity with rich-text content + domain errors" -``` - ---- - -## Phase 2: Application layer (use-cases + repository interface) - -### Task 2.3: Define IArticlesRepository interface - -**Files:** -- Create: `packages/blog/src/application/repositories/articles-repository.interface.ts` - -- [ ] **Step 1: Implement** (no test — interface has no behavior; exercised through use-case tests) - -```typescript -// packages/blog/src/application/repositories/articles-repository.interface.ts -import type { Article } from "@/entities/article"; - -export interface IArticlesRepository { - getArticle(id: string): Promise
; - getArticleBySlug(slug: string): Promise
; - getArticles(options?: { - status?: string; - authorId?: string; - limit?: number; - offset?: number; - }): Promise; - createArticle(input: Article): Promise
; - updateArticle( - id: string, - input: Partial
, - ): Promise
; -} -``` - -> Note: `getArticleBySlug` is added beyond the original `packages/core` interface — needed by `articleBySlug` tRPC procedure (the canonical example query in spec §11.15). - -- [ ] **Step 2: Verify it compiles** - -Run: `cd packages/blog && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add packages/blog/src/application/repositories -git commit -m "feat(blog): add IArticlesRepository interface" -``` - ---- - -### Task 2.4: Implement getArticles use-case + tests - -**Files:** -- Create: `packages/blog/src/application/use-cases/get-articles.use-case.ts` -- Create: `packages/blog/src/application/use-cases/get-articles.use-case.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -// packages/blog/src/application/use-cases/get-articles.use-case.test.ts -import { beforeEach, describe, expect, it } from "vitest"; -import { blogContainer } from "@/di/container"; -import { BLOG_SYMBOLS } from "@/di/symbols"; -import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; -import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; -import { getArticlesUseCase } from "./get-articles.use-case"; - -describe("getArticlesUseCase", () => { - let repo: MockArticlesRepository; - - beforeEach(() => { - if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { - blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); - } - repo = new MockArticlesRepository(); - blogContainer - .bind(BLOG_SYMBOLS.IArticlesRepository) - .toConstantValue(repo); - }); - - it("returns all articles with no filters", async () => { - const now = new Date(); - await repo.createArticle({ - id: "1", - title: "A", - slug: "a", - content: null, - status: "draft", - authorId: "u1", - createdAt: now, - updatedAt: now, - }); - const result = await getArticlesUseCase(); - expect(result).toHaveLength(1); - expect(result[0]?.id).toBe("1"); - }); - - it("filters by status", async () => { - const now = new Date(); - await repo.createArticle({ - id: "1", - title: "A", - slug: "a", - content: null, - status: "draft", - authorId: "u1", - createdAt: now, - updatedAt: now, - }); - await repo.createArticle({ - id: "2", - title: "B", - slug: "b", - content: null, - status: "published", - authorId: "u1", - createdAt: now, - updatedAt: now, - }); - const result = await getArticlesUseCase({ status: "published" }); - expect(result).toHaveLength(1); - expect(result[0]?.id).toBe("2"); - }); -}); -``` - -- [ ] **Step 2: Run — expect failure** - -Run: `cd packages/blog && pnpm vitest run src/application/use-cases/get-articles.use-case.test.ts` -Expected: FAIL — multiple "Cannot find module" errors (the test imports from `@/di/container`, `@/di/symbols`, `@/infrastructure/repositories/mock-articles.repository`, none of which exist yet). - -> This is intentional: TDD here means the use-case test exercises the full DI container shape AND the mock repo. Both will be implemented in subsequent tasks. For now, just write the use-case to satisfy its direct dependencies. - -- [ ] **Step 3: Implement use-case** - -```typescript -// packages/blog/src/application/use-cases/get-articles.use-case.ts -import type { Article } from "@/entities/article"; -import { blogContainer } from "@/di/container"; -import { BLOG_SYMBOLS } from "@/di/symbols"; -import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; - -export async function getArticlesUseCase(options?: { - status?: string; - authorId?: string; - limit?: number; - offset?: number; -}): Promise { - const repo = blogContainer.get( - BLOG_SYMBOLS.IArticlesRepository, - ); - return repo.getArticles(options); -} -``` - -> Note: This still won't compile or run because `@/di/container`, `@/di/symbols`, `@/infrastructure/repositories/mock-articles.repository` don't exist yet. Test will continue to fail. That's expected — Tasks 2.5-2.7 implement those, then this test passes. - -- [ ] **Step 4: Commit (test + impl, both currently failing — establishes the contract)** - -```bash -git add packages/blog/src/application/use-cases/get-articles.use-case.ts packages/blog/src/application/use-cases/get-articles.use-case.test.ts -git commit -m "feat(blog): add getArticlesUseCase (test red until DI + mock repo exist)" -``` - ---- - -### Task 2.5: Implement createArticle use-case + tests - -**Files:** -- Create: `packages/blog/src/application/use-cases/create-article.use-case.ts` -- Create: `packages/blog/src/application/use-cases/create-article.use-case.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -// packages/blog/src/application/use-cases/create-article.use-case.test.ts -import { beforeEach, describe, expect, it } from "vitest"; -import { blogContainer } from "@/di/container"; -import { BLOG_SYMBOLS } from "@/di/symbols"; -import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; -import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; -import { createArticleUseCase } from "./create-article.use-case"; - -describe("createArticleUseCase", () => { - let repo: MockArticlesRepository; - - beforeEach(() => { - if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { - blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); - } - repo = new MockArticlesRepository(); - blogContainer - .bind(BLOG_SYMBOLS.IArticlesRepository) - .toConstantValue(repo); - }); - - it("creates an article in draft status with auto-generated slug", async () => { - const result = await createArticleUseCase({ - title: "Hello World", - content: "body", - authorId: "u1", - }); - expect(result.title).toBe("Hello World"); - expect(result.slug).toBe("hello-world"); - expect(result.status).toBe("draft"); - expect(result.id).toBeTruthy(); - - const stored = await repo.getArticle(result.id); - expect(stored).toBeDefined(); - }); - - it("uses provided slug when supplied", async () => { - const result = await createArticleUseCase({ - title: "Whatever", - content: "body", - authorId: "u1", - slug: "custom-slug", - }); - expect(result.slug).toBe("custom-slug"); - }); -}); -``` - -- [ ] **Step 2: Run — expect failure** - -Run: `cd packages/blog && pnpm vitest run src/application/use-cases/create-article.use-case.test.ts` -Expected: FAIL — module-not-found errors. - -- [ ] **Step 3: Implement use-case** - -```typescript -// packages/blog/src/application/use-cases/create-article.use-case.ts -import type { Article } from "@/entities/article"; -import { blogContainer } from "@/di/container"; -import { BLOG_SYMBOLS } from "@/di/symbols"; -import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; - -function generateSlug(title: string): string { - return title - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); -} - -export async function createArticleUseCase(input: { - title: string; - content: unknown; - authorId: string; - slug?: string; -}): Promise
{ - const repo = blogContainer.get( - BLOG_SYMBOLS.IArticlesRepository, - ); - - const now = new Date(); - const article: Article = { - id: crypto.randomUUID(), - title: input.title, - slug: input.slug ?? generateSlug(input.title), - content: input.content, - status: "draft", - authorId: input.authorId, - createdAt: now, - updatedAt: now, - }; - - return repo.createArticle(article); -} -``` - -- [ ] **Step 4: Commit (test still failing — DI not yet implemented)** - -```bash -git add packages/blog/src/application/use-cases/create-article.use-case.ts packages/blog/src/application/use-cases/create-article.use-case.test.ts -git commit -m "feat(blog): add createArticleUseCase (test red until DI + mock repo exist)" -``` - ---- - -## Phase 3: Infrastructure layer (mock + payload repos) - -### Task 2.6: Mock articles repository - -**Files:** -- Create: `packages/blog/src/infrastructure/repositories/mock-articles.repository.ts` - -- [ ] **Step 1: Implement** (no separate test — exercised by use-case tests) - -```typescript -// packages/blog/src/infrastructure/repositories/mock-articles.repository.ts -import "reflect-metadata"; -import { injectable } from "inversify"; - -import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; -import type { Article } from "@/entities/article"; - -@injectable() -export class MockArticlesRepository implements IArticlesRepository { - private _articles: Article[] = []; - - async getArticle(id: string): Promise
{ - return this._articles.find((a) => a.id === id); - } - - async getArticleBySlug(slug: string): Promise
{ - return this._articles.find((a) => a.slug === slug); - } - - async getArticles(options?: { - status?: string; - authorId?: string; - limit?: number; - offset?: number; - }): Promise { - let result = [...this._articles]; - if (options?.status) { - result = result.filter((a) => a.status === options.status); - } - if (options?.authorId) { - result = result.filter((a) => a.authorId === options.authorId); - } - const offset = options?.offset ?? 0; - const limit = options?.limit ?? 50; - return result.slice(offset, offset + limit); - } - - async createArticle(input: Article): Promise
{ - this._articles.push(input); - return input; - } - - async updateArticle( - id: string, - input: Partial
, - ): Promise
{ - const index = this._articles.findIndex((a) => a.id === id); - if (index === -1) return undefined; - const current = this._articles[index]; - if (!current) return undefined; - const updated: Article = { ...current, ...input, id: current.id }; - this._articles[index] = updated; - return updated; - } -} -``` - -- [ ] **Step 2: Verify it compiles** (use-case tests still won't pass — DI container not yet implemented) - -Run: `cd packages/blog && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add packages/blog/src/infrastructure/repositories/mock-articles.repository.ts -git commit -m "feat(blog): add MockArticlesRepository for tests" -``` - ---- - -### Task 2.7: Per-feature DI container (symbols, module, container) - -**Files:** -- Create: `packages/blog/src/di/symbols.ts` -- Create: `packages/blog/src/di/module.ts` -- Create: `packages/blog/src/di/container.ts` -- Create: `packages/blog/src/di/container.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -// packages/blog/src/di/container.test.ts -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { blogContainer } from "./container"; -import { BLOG_SYMBOLS } from "./symbols"; -import { BlogModule } from "./module"; -import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; -import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; - -describe("blogContainer", () => { - beforeEach(() => { - blogContainer.unbindAll(); - blogContainer.load(BlogModule); - }); - - afterEach(() => { - blogContainer.unbindAll(); - }); - - it("resolves IArticlesRepository to MockArticlesRepository by default binding", () => { - const repo = blogContainer.get( - BLOG_SYMBOLS.IArticlesRepository, - ); - expect(repo).toBeInstanceOf(MockArticlesRepository); - }); - - it("supports rebinding to a custom repo", () => { - blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); - const custom = new MockArticlesRepository(); - blogContainer - .bind(BLOG_SYMBOLS.IArticlesRepository) - .toConstantValue(custom); - const resolved = blogContainer.get( - BLOG_SYMBOLS.IArticlesRepository, - ); - expect(resolved).toBe(custom); - }); -}); -``` - -- [ ] **Step 2: Run — expect failure** - -Run: `cd packages/blog && pnpm vitest run src/di/container.test.ts` -Expected: FAIL — module-not-found errors. - -- [ ] **Step 3: Implement symbols.ts** - -```typescript -// packages/blog/src/di/symbols.ts -export const BLOG_SYMBOLS = { - IArticlesRepository: Symbol.for("blog:IArticlesRepository"), -} as const; -``` - -> Note: Symbol descriptions are namespaced (`blog:IArticlesRepository`) so debug output is clear and there's no collision with other features' symbols at the JavaScript level (each feature has its own container, so collisions can't actually happen — this is just hygiene). - -- [ ] **Step 4: Implement module.ts** - -```typescript -// packages/blog/src/di/module.ts -import { ContainerModule, type interfaces } from "inversify"; - -import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; -import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; -import { BLOG_SYMBOLS } from "./symbols"; - -export const BlogModule = new ContainerModule((bind: interfaces.Bind) => { - bind(BLOG_SYMBOLS.IArticlesRepository).to( - MockArticlesRepository, - ); -}); -``` - -> Note: Default binding is `MockArticlesRepository`. The payload-backed repo (Task 2.8) is bound at app boot (Plan 5) by rebinding before serving requests. Tests rebind per `beforeEach`. This keeps the package usable in isolation (e.g., for unit tests, Storybook dev) without requiring a database. - -- [ ] **Step 5: Implement container.ts** - -```typescript -// packages/blog/src/di/container.ts -import "reflect-metadata"; -import { Container } from "inversify"; -import { BlogModule } from "./module"; - -export const blogContainer = new Container({ defaultScope: "Singleton" }); -blogContainer.load(BlogModule); -``` - -- [ ] **Step 6: Run — expect pass** - -Run: `cd packages/blog && pnpm vitest run src/di/container.test.ts` -Expected: PASS — 2 tests. - -- [ ] **Step 7: Run the previously-red use-case tests** - -Run: `cd packages/blog && pnpm vitest run src/application/use-cases` -Expected: PASS — 4 tests (2 from `getArticles`, 2 from `createArticle`). - -- [ ] **Step 8: Commit** - -```bash -git add packages/blog/src/di -git commit -m "feat(blog): add per-feature InversifyJS container (symbols, module, container)" -``` - ---- - -### Task 2.8: Payload articles repository - -**Files:** -- Create: `packages/blog/src/infrastructure/repositories/payload-articles.repository.ts` -- Create: `packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -// packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts -import { describe, expect, it, vi } from "vitest"; -import { PayloadArticlesRepository } from "./payload-articles.repository"; - -// Mock the payload module so we don't need a real DB -vi.mock("payload", () => ({ - getPayload: vi.fn(), -})); - -vi.mock("@repo/core-cms", () => ({ - default: {} as never, -})); - -describe("PayloadArticlesRepository", () => { - it("maps a Payload doc to a domain Article on getArticleBySlug", async () => { - const { getPayload } = await import("payload"); - const findMock = vi.fn().mockResolvedValue({ - docs: [ - { - id: "p-123", - title: "Hello", - slug: "hello", - content: { type: "doc", children: [] }, - status: "published", - author: "u1", - createdAt: "2026-05-04T12:00:00.000Z", - updatedAt: "2026-05-04T12:00:00.000Z", - }, - ], - }); - (getPayload as ReturnType).mockResolvedValue({ - find: findMock, - }); - - const repo = new PayloadArticlesRepository(); - const result = await repo.getArticleBySlug("hello"); - - expect(findMock).toHaveBeenCalledWith({ - collection: "articles", - where: { slug: { equals: "hello" } }, - limit: 1, - overrideAccess: false, - }); - expect(result?.id).toBe("p-123"); - expect(result?.slug).toBe("hello"); - expect(result?.status).toBe("published"); - expect(result?.authorId).toBe("u1"); - expect(result?.createdAt).toBeInstanceOf(Date); - }); - - it("returns undefined when slug is not found", async () => { - const { getPayload } = await import("payload"); - (getPayload as ReturnType).mockResolvedValue({ - find: vi.fn().mockResolvedValue({ docs: [] }), - }); - - const repo = new PayloadArticlesRepository(); - const result = await repo.getArticleBySlug("missing"); - expect(result).toBeUndefined(); - }); -}); -``` - -- [ ] **Step 2: Run — expect failure** - -Run: `cd packages/blog && pnpm vitest run src/infrastructure/repositories/payload-articles.repository.test.ts` -Expected: FAIL — "Cannot find module './payload-articles.repository'" - -- [ ] **Step 3: Implement payload-articles repository** - -```typescript -// packages/blog/src/infrastructure/repositories/payload-articles.repository.ts -import "reflect-metadata"; -import { injectable } from "inversify"; -import { getPayload } from "payload"; - -import config from "@repo/core-cms"; -import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; -import type { Article } from "@/entities/article"; - -type PayloadArticleDoc = { - id: string | number; - title?: string | null; - slug?: string | null; - content?: unknown; - status?: string | null; - author?: string | number | { id: string | number } | null; - createdAt?: string | null; - updatedAt?: string | null; -}; - -function mapDoc(doc: PayloadArticleDoc): Article { - const authorId = - typeof doc.author === "object" && doc.author !== null - ? String(doc.author.id) - : doc.author != null - ? String(doc.author) - : ""; - return { - id: String(doc.id), - title: doc.title ?? "", - slug: doc.slug ?? "", - content: doc.content ?? null, - status: (doc.status === "published" ? "published" : "draft"), - authorId, - createdAt: doc.createdAt ? new Date(doc.createdAt) : new Date(0), - updatedAt: doc.updatedAt ? new Date(doc.updatedAt) : new Date(0), - }; -} - -@injectable() -export class PayloadArticlesRepository implements IArticlesRepository { - async getArticle(id: string): Promise
{ - const payload = await getPayload({ config }); - try { - const doc = await payload.findByID({ - collection: "articles", - id, - overrideAccess: false, - }); - return mapDoc(doc as PayloadArticleDoc); - } catch { - return undefined; - } - } - - async getArticleBySlug(slug: string): Promise
{ - const payload = await getPayload({ config }); - const result = await payload.find({ - collection: "articles", - where: { slug: { equals: slug } }, - limit: 1, - overrideAccess: false, - }); - const doc = result.docs[0] as PayloadArticleDoc | undefined; - return doc ? mapDoc(doc) : undefined; - } - - async getArticles(options?: { - status?: string; - authorId?: string; - limit?: number; - offset?: number; - }): Promise { - const payload = await getPayload({ config }); - const where: Record = {}; - if (options?.status) where.status = { equals: options.status }; - if (options?.authorId) where.author = { equals: options.authorId }; - - const result = await payload.find({ - collection: "articles", - where, - limit: options?.limit ?? 50, - page: options?.offset - ? Math.floor(options.offset / (options.limit ?? 50)) + 1 - : 1, - overrideAccess: false, - }); - return result.docs.map((d) => mapDoc(d as PayloadArticleDoc)); - } - - async createArticle(input: Article): Promise
{ - const payload = await getPayload({ config }); - const created = await payload.create({ - collection: "articles", - data: { - title: input.title, - slug: input.slug, - content: input.content, - status: input.status, - author: input.authorId, - } as never, - overrideAccess: false, - }); - return mapDoc(created as PayloadArticleDoc); - } - - async updateArticle( - id: string, - input: Partial
, - ): Promise
{ - const payload = await getPayload({ config }); - try { - const updated = await payload.update({ - collection: "articles", - id, - data: { - ...(input.title !== undefined && { title: input.title }), - ...(input.slug !== undefined && { slug: input.slug }), - ...(input.content !== undefined && { content: input.content }), - ...(input.status !== undefined && { status: input.status }), - ...(input.authorId !== undefined && { author: input.authorId }), - } as never, - overrideAccess: false, - }); - return mapDoc(updated as PayloadArticleDoc); - } catch { - return undefined; - } - } -} -``` - -> Note: `as never` casts on the Payload `data` arguments are needed because Payload's generated types (which we don't have for the empty-collection core-cms config) would normally constrain these. With empty `collections: []`, the generated types don't include `'articles'`, so we cast through `never`. After Plan 3 wires articles into core-cms.collections AND `pnpm generate:types` runs, these casts can potentially be removed — but for Plan 2's purpose (decoupled feature with deferred wiring) the casts are correct. - -> **Critical architectural note (revised after execution):** The repo takes `SanitizedConfig` via its constructor instead of `import config from '@repo/core-cms'` (which would create a workspace dependency cycle: blog ↔ core-cms). Constructor injection breaks the cycle at the package-graph level. The DI binding for production (Plan 5, app boot) supplies the config: -> ```ts -> import config from '@repo/core-cms' -> blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository) -> .toDynamicValue(() => new PayloadArticlesRepository(config)) -> ``` -> The blog `package.json` does NOT declare `@repo/core-cms` as a dependency. Apply this pattern to every payload-backed feature repository. - -- [ ] **Step 4: Run — expect pass** - -Run: `cd packages/blog && pnpm vitest run src/infrastructure/repositories/payload-articles.repository.test.ts` -Expected: PASS — 2 tests. - -- [ ] **Step 5: Commit** - -```bash -git add packages/blog/src/infrastructure/repositories/payload-articles.repository.ts packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts -git commit -m "feat(blog): add PayloadArticlesRepository with doc-to-entity mapping" -``` - ---- - -## Phase 4: Interface-adapters (controllers) - -### Task 2.9: Articles controller + tests - -**Files:** -- Create: `packages/blog/src/interface-adapters/controllers/articles.controller.ts` -- Create: `packages/blog/src/interface-adapters/controllers/articles.controller.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -// packages/blog/src/interface-adapters/controllers/articles.controller.test.ts -import { beforeEach, describe, expect, it } from "vitest"; -import { blogContainer } from "@/di/container"; -import { BLOG_SYMBOLS } from "@/di/symbols"; -import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; -import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; -import { InputParseError } from "@/entities/errors"; -import { - createArticleController, - getArticlesController, - getArticleBySlugController, -} from "./articles.controller"; - -describe("articles controller", () => { - let repo: MockArticlesRepository; - - beforeEach(() => { - if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { - blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); - } - repo = new MockArticlesRepository(); - blogContainer - .bind(BLOG_SYMBOLS.IArticlesRepository) - .toConstantValue(repo); - }); - - describe("createArticleController", () => { - it("creates an article on valid input", async () => { - const result = await createArticleController({ - title: "Hello", - content: "body", - authorId: "u1", - }); - expect(result.title).toBe("Hello"); - }); - - it("throws InputParseError on missing title", async () => { - await expect( - createArticleController({ content: "body", authorId: "u1" }), - ).rejects.toBeInstanceOf(InputParseError); - }); - }); - - describe("getArticlesController", () => { - it("returns array on valid input", async () => { - const result = await getArticlesController({}); - expect(result).toEqual([]); - }); - - it("throws InputParseError on invalid input shape", async () => { - await expect( - getArticlesController({ limit: "not a number" } as unknown as Record< - string, - unknown - >), - ).rejects.toBeInstanceOf(InputParseError); - }); - }); - - describe("getArticleBySlugController", () => { - it("returns undefined for missing slug", async () => { - const result = await getArticleBySlugController({ slug: "nope" }); - expect(result).toBeUndefined(); - }); - - it("throws InputParseError on missing slug", async () => { - await expect( - getArticleBySlugController({} as { slug: string }), - ).rejects.toBeInstanceOf(InputParseError); - }); - }); -}); -``` - -- [ ] **Step 2: Run — expect failure** - -Run: `cd packages/blog && pnpm vitest run src/interface-adapters/controllers/articles.controller.test.ts` -Expected: FAIL — "Cannot find module './articles.controller'" - -- [ ] **Step 3: Implement controller** - -```typescript -// packages/blog/src/interface-adapters/controllers/articles.controller.ts -import { z } from "zod"; - -import { InputParseError } from "@/entities/errors"; -import type { Article } from "@/entities/article"; -import { blogContainer } from "@/di/container"; -import { BLOG_SYMBOLS } from "@/di/symbols"; -import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; -import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case"; -import { createArticleUseCase } from "@/application/use-cases/create-article.use-case"; - -const createInputSchema = z.object({ - title: z.string().min(1).max(255), - content: z.unknown(), - authorId: z.string(), - slug: z.string().optional(), -}); - -const getInputSchema = z.object({ - status: z.string().optional(), - authorId: z.string().optional(), - limit: z.number().optional(), - offset: z.number().optional(), -}); - -const getBySlugInputSchema = z.object({ - slug: z.string().min(1), -}); - -export async function createArticleController( - input: Partial>, -): Promise
{ - const parsed = createInputSchema.safeParse(input); - if (!parsed.success) { - throw new InputParseError("Invalid create-article input", { - cause: parsed.error, - }); - } - return createArticleUseCase(parsed.data); -} - -export async function getArticlesController( - input: Partial>, -): Promise { - const parsed = getInputSchema.safeParse(input); - if (!parsed.success) { - throw new InputParseError("Invalid get-articles input", { - cause: parsed.error, - }); - } - return getArticlesUseCase(parsed.data); -} - -export async function getArticleBySlugController(input: { - slug: string; -}): Promise
{ - const parsed = getBySlugInputSchema.safeParse(input); - if (!parsed.success) { - throw new InputParseError("Invalid get-article-by-slug input", { - cause: parsed.error, - }); - } - const repo = blogContainer.get( - BLOG_SYMBOLS.IArticlesRepository, - ); - return repo.getArticleBySlug(parsed.data.slug); -} -``` - -> Note: `getArticleBySlugController` calls the repo directly rather than going through a separate `getArticleBySlug.use-case.ts`. This is acceptable per spec addendum v5 — for trivial pass-through reads, a use-case file would be empty ceremony. The pattern emerges if/when this read needs caching, authorization decisions, or other application logic. (Plan 3 leaves it as-is; create one in a later plan if behavior justifies it.) - -- [ ] **Step 4: Run — expect pass** - -Run: `cd packages/blog && pnpm vitest run src/interface-adapters/controllers/articles.controller.test.ts` -Expected: PASS — 6 tests. - -- [ ] **Step 5: Commit** - -```bash -git add packages/blog/src/interface-adapters -git commit -m "feat(blog): add articles controller with Zod validation" -``` - ---- - -## Phase 5: Integrations (CMS schema + tRPC API) - -### Task 2.10: Articles collection (simplified — no cross-feature relations) - -**Files:** -- Create: `packages/blog/src/integrations/cms/collections/articles.ts` -- Create: `packages/blog/src/integrations/cms/index.ts` - -- [ ] **Step 1: Implement collection (simplified — see schema simplifications section)** - -```typescript -// packages/blog/src/integrations/cms/collections/articles.ts -import type { CollectionConfig } from "payload"; -import { slugifyIfMissing } from "@repo/core-shared/payload"; - -export const articles: CollectionConfig = { - slug: "articles", - admin: { - useAsTitle: "title", - defaultColumns: ["title", "status", "author", "updatedAt"], - }, - hooks: { - beforeChange: [slugifyIfMissing], - }, - versions: { - drafts: true, - }, - fields: [ - { - name: "title", - type: "text", - required: true, - maxLength: 255, - }, - { - name: "slug", - type: "text", - unique: true, - admin: { - position: "sidebar", - description: "Auto-generated from title if left empty", - }, - }, - { - name: "content", - type: "richText", - }, - { - name: "status", - type: "select", - options: [ - { label: "Draft", value: "draft" }, - { label: "Published", value: "published" }, - ], - defaultValue: "draft", - required: true, - admin: { - position: "sidebar", - }, - }, - // TODO(plan-3): Restore as `relationship → users` once auth feature is migrated. - { - name: "author", - type: "text", - required: true, - admin: { - position: "sidebar", - description: - "Temporary text field; restored to users relationship in Plan 3.", - }, - }, - // TODO(plan-3): Restore `featuredImage: upload → media` once media feature is migrated. - { - name: "publishedAt", - type: "date", - admin: { - position: "sidebar", - date: { - pickerAppearance: "dayAndTime", - }, - }, - }, - ], -}; -``` - -> Note: `slugifyIfMissing` from `@repo/core-shared/payload` replaces the inline `autoGenerateSlug` hook from `packages/cms-core/src/collections/articles/hooks/before-change.ts`. Same behavior, generic helper. - -- [ ] **Step 2: Implement integrations/cms barrel** - -```typescript -// packages/blog/src/integrations/cms/index.ts -export { articles } from "./collections/articles"; -``` - -- [ ] **Step 3: Verify it compiles** - -Run: `cd packages/blog && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/blog/src/integrations/cms -git commit -m "feat(blog): add articles collection (simplified — no cross-feature refs)" -``` - ---- - -### Task 2.11: Wire articles into core-cms composition - -**Files:** -- Modify: `packages/core-cms/src/payload.config.ts` -- Modify: `packages/core-cms/package.json` (add `@repo/blog` dependency) - -- [ ] **Step 1: Add @repo/blog to core-cms dependencies** - -Add inside `packages/core-cms/package.json`'s `dependencies` block: - -```json -"@repo/blog": "workspace:*", -``` - -> Note: This is the spec §4.2 composition exception — `core-cms` may import feature `/cms` exports. - -- [ ] **Step 2: Update payload.config.ts to register articles** - -Read current `packages/core-cms/src/payload.config.ts`. Replace it with: - -```typescript -import { buildConfig } from "payload"; -import { postgresAdapter } from "@payloadcms/db-postgres"; -import { lexicalEditor } from "@payloadcms/richtext-lexical"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { articles } from "@repo/blog/cms"; - -const filename = fileURLToPath(import.meta.url); -const dirname = path.dirname(filename); - -export default buildConfig({ - editor: lexicalEditor(), - collections: [articles], - globals: [], - secret: process.env.PAYLOAD_SECRET || "default-secret-change-me", - db: postgresAdapter({ - pool: { - connectionString: - process.env.DATABASE_URL || - "postgresql://postgres:postgres@localhost:5432/template", - }, - }), - typescript: { - outputFile: path.resolve(dirname, "generated-types.ts"), - }, -}); -``` - -- [ ] **Step 3: Install (picks up workspace dep)** - -Run: `pnpm install` -Expected: install completes; `@repo/blog` available to `@repo/core-cms`. - -- [ ] **Step 4: Verify core-cms typechecks** - -Run: `pnpm typecheck --filter @repo/core-cms --filter @repo/blog` -Expected: PASS for both. - -- [ ] **Step 5: Regenerate Payload types** (now articles is in the config; types should include `Article`-shaped doc) - -Run: `cd apps/cms && pnpm generate:types` -Expected: writes new `packages/core-cms/src/generated-types.ts` containing an `Articles` interface. - -- [ ] **Step 6: Verify generated types compile** - -Run: `pnpm typecheck --filter @repo/core-cms` -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add packages/core-cms packages/blog pnpm-lock.yaml -git commit -m "feat(core-cms): compose @repo/blog/cms into payload config" -``` - ---- - -### Task 2.12: tRPC router (integrations/api) - -**Files:** -- Create: `packages/blog/src/integrations/api/router.ts` -- Create: `packages/blog/src/integrations/api/router.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -// packages/blog/src/integrations/api/router.test.ts -import { beforeEach, describe, expect, it } from "vitest"; -import { blogContainer } from "@/di/container"; -import { BLOG_SYMBOLS } from "@/di/symbols"; -import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; -import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; -import { blogRouter } from "./router"; - -describe("blogRouter", () => { - let repo: MockArticlesRepository; - - beforeEach(() => { - if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { - blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); - } - repo = new MockArticlesRepository(); - blogContainer - .bind(BLOG_SYMBOLS.IArticlesRepository) - .toConstantValue(repo); - }); - - it("exposes articleBySlug, listArticles, createArticle procedures", () => { - const procedureNames = Object.keys(blogRouter._def.procedures); - expect(procedureNames).toContain("articleBySlug"); - expect(procedureNames).toContain("listArticles"); - expect(procedureNames).toContain("createArticle"); - }); - - it("articleBySlug returns the article when present", async () => { - const now = new Date(); - await repo.createArticle({ - id: "1", - title: "T", - slug: "t", - content: null, - status: "draft", - authorId: "u1", - createdAt: now, - updatedAt: now, - }); - - const caller = blogRouter.createCaller({}); - const result = await caller.articleBySlug({ slug: "t" }); - expect(result?.id).toBe("1"); - }); - - it("listArticles returns all articles when no input is given", async () => { - const caller = blogRouter.createCaller({}); - const result = await caller.listArticles(); - expect(result).toEqual([]); - }); -}); -``` - -- [ ] **Step 2: Run — expect failure** - -Run: `cd packages/blog && pnpm vitest run src/integrations/api/router.test.ts` -Expected: FAIL — "Cannot find module './router'" - -- [ ] **Step 3: Implement router** - -```typescript -// packages/blog/src/integrations/api/router.ts -import { z } from "zod"; -import { router, publicProcedure } from "@repo/core-shared/trpc/init"; -import { - createArticleController, - getArticlesController, - getArticleBySlugController, -} from "@/interface-adapters/controllers/articles.controller"; - -export const blogRouter = router({ - articleBySlug: publicProcedure - .input(z.object({ slug: z.string().min(1) })) - .query(({ input }) => getArticleBySlugController(input)), - - listArticles: publicProcedure - .input( - z - .object({ - status: z.string().optional(), - authorId: z.string().optional(), - limit: z.number().optional(), - offset: z.number().optional(), - }) - .optional(), - ) - .query(({ input }) => getArticlesController(input ?? {})), - - createArticle: publicProcedure - .input( - z.object({ - title: z.string().min(1).max(255), - content: z.unknown(), - authorId: z.string(), - slug: z.string().optional(), - }), - ) - .mutation(({ input }) => createArticleController(input)), -}); - -export type BlogRouter = typeof blogRouter; -``` - -- [ ] **Step 4: Run — expect pass** - -Run: `cd packages/blog && pnpm vitest run src/integrations/api/router.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Commit** - -```bash -git add packages/blog/src/integrations/api -git commit -m "feat(blog): add tRPC router with articleBySlug + listArticles + createArticle" -``` - ---- - -### Task 2.13: Wire blogRouter into core-api - -**Files:** -- Modify: `packages/core-api/src/root.ts` -- Modify: `packages/core-api/package.json` (add `@repo/blog`) - -- [ ] **Step 1: Add @repo/blog to core-api dependencies** - -Add inside `packages/core-api/package.json`'s `dependencies` block: - -```json -"@repo/blog": "workspace:*", -``` - -- [ ] **Step 2: Update root.ts** - -Replace `packages/core-api/src/root.ts` with: - -```typescript -import { router } from "@repo/core-shared/trpc/init"; -import { blogRouter } from "@repo/blog/api"; - -export const appRouter = router({ - blog: blogRouter, -}); - -export type AppRouter = typeof appRouter; -``` - -- [ ] **Step 3: Install + verify** - -Run: `pnpm install` then `pnpm typecheck --filter @repo/core-api --filter @repo/blog` -Expected: both PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/core-api packages/blog pnpm-lock.yaml -git commit -m "feat(core-api): compose @repo/blog/api into appRouter under 'blog' namespace" -``` - ---- - -## Phase 6: UI helpers (deferred render — apps wire in Plan 5) - -### Task 2.14: ui/query.ts helper - -**Files:** -- Create: `packages/blog/src/ui/query.ts` - -- [ ] **Step 1: Implement** (no test — exercised when apps consume in Plan 5) - -```typescript -// packages/blog/src/ui/query.ts -// React Query option builders for blog feature procedures. -// Consumed by apps via the @repo/core-trpc client (wired in Plan 5). -// -// Example consumer (Plan 5): -// import { useQuery } from '@tanstack/react-query' -// import { trpc } from '@repo/core-trpc' -// import { articleBySlugQuery } from '@repo/blog/src/ui/query' -// const { data } = useQuery(articleBySlugQuery(trpc, slug)) -// -// Kept framework-agnostic here: takes the typed `trpc` client as an argument -// rather than importing it. This avoids importing @repo/core-trpc (which is -// a frontend platform package — feature shouldn't depend on it). - -type TrpcClient = { - blog: { - articleBySlug: { - queryOptions: (input: { slug: string }) => unknown; - }; - listArticles: { - queryOptions: (input?: { - status?: string; - authorId?: string; - limit?: number; - offset?: number; - }) => unknown; - }; - }; -}; - -export function articleBySlugQuery(client: TrpcClient, slug: string) { - return client.blog.articleBySlug.queryOptions({ slug }); -} - -export function listArticlesQuery( - client: TrpcClient, - options?: { status?: string; authorId?: string; limit?: number; offset?: number }, -) { - return client.blog.listArticles.queryOptions(options); -} -``` - -> Note: `TrpcClient` is locally typed structurally because `@repo/core-trpc` doesn't ship the React-aware client until Plan 5. The structural type is sufficient: when Plan 5 lands and apps call `articleBySlugQuery(trpc, slug)` with the real typed client, structural compatibility holds. Plan 5 may revise this to import from `@repo/core-trpc` once that's available. - -- [ ] **Step 2: Verify it compiles** - -Run: `cd packages/blog && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add packages/blog/src/ui -git commit -m "feat(blog): add ui/query.ts (framework-agnostic React Query option builders)" -``` - ---- - -## Phase 7: Wiring + barrel + feature test - -### Task 2.15: Wire blog package barrel - -**Files:** -- Modify: `packages/blog/src/index.ts` - -- [ ] **Step 1: Replace empty index.ts** - -```typescript -// packages/blog/src/index.ts -export type { Article, ArticleStatus } from "./entities/article"; -export { ArticleNotFoundError, InputParseError } from "./entities/errors"; -export { articleBySlugQuery, listArticlesQuery } from "./ui/query"; -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `cd packages/blog && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add packages/blog/src/index.ts -git commit -m "feat(blog): wire root barrel (entity types + ui query helpers)" -``` - ---- - -### Task 2.16: Feature-level test (cross-layer) - -**Files:** -- Create: `packages/blog/tests/articles.feature.test.ts` - -- [ ] **Step 1: Write the test** - -```typescript -// packages/blog/tests/articles.feature.test.ts -// -// Feature-level test: exercises the full slice -// tRPC procedure -> controller -> use-case -> mock repo -// without going through a network or the actual Payload Local API. -// Verifies that the layers are correctly wired through the per-feature DI container. - -import { beforeEach, describe, expect, it } from "vitest"; -import { blogContainer } from "@/di/container"; -import { BLOG_SYMBOLS } from "@/di/symbols"; -import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; -import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; -import { blogRouter } from "@/integrations/api/router"; - -describe("blog feature: article-by-slug end-to-end", () => { - let repo: MockArticlesRepository; - - beforeEach(() => { - if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { - blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); - } - repo = new MockArticlesRepository(); - blogContainer - .bind(BLOG_SYMBOLS.IArticlesRepository) - .toConstantValue(repo); - }); - - it("creates an article via tRPC, then fetches it back by slug", async () => { - const caller = blogRouter.createCaller({}); - - const created = await caller.createArticle({ - title: "The Vertical Refactor", - content: { type: "doc", children: [] }, - authorId: "u1", - slug: "vertical-refactor", - }); - expect(created.id).toBeTruthy(); - expect(created.slug).toBe("vertical-refactor"); - - const fetched = await caller.articleBySlug({ slug: "vertical-refactor" }); - expect(fetched?.id).toBe(created.id); - expect(fetched?.title).toBe("The Vertical Refactor"); - }); - - it("listArticles filters by status", async () => { - const caller = blogRouter.createCaller({}); - await caller.createArticle({ - title: "Draft One", - content: null, - authorId: "u1", - }); - const draftOnly = await caller.listArticles({ status: "draft" }); - expect(draftOnly).toHaveLength(1); - - const publishedOnly = await caller.listArticles({ status: "published" }); - expect(publishedOnly).toHaveLength(0); - }); -}); -``` - -- [ ] **Step 2: Verify the path alias works for tests/ folder** - -The test uses `@/` paths. Confirm `packages/blog/tsconfig.json` includes `"tests/**/*"` (it does per Task 2.1). - -- [ ] **Step 3: Run** - -Run: `cd packages/blog && pnpm vitest run tests/` -Expected: PASS — 2 tests. - -- [ ] **Step 4: Commit** - -```bash -git add packages/blog/tests -git commit -m "test(blog): add feature-level test exercising router → controller → use-case → repo" -``` - ---- - -### Task 2.17: Final verification + apps/cms boot smoke test - -- [ ] **Step 1: Run all blog tests** - -Run: `pnpm test --filter @repo/blog` -Expected: PASS — exact count: entities (7) + di container (2) + use-cases (2 + 2) + payload-articles repo (2) + controller (6) + router (3) + feature (2) = 26 tests. - -- [ ] **Step 2: Run repo-wide typecheck** - -Run: `pnpm typecheck` -Expected: PASS for all the packages we touched. Pre-existing failures in `@repo/api` and `@repo/ui` remain (will be addressed in Plans 5/6). - -- [ ] **Step 3: Boot apps/cms to verify articles collection registers** - -Ensure Postgres is running (`docker compose up -d postgres`). - -Run: `pnpm dev --filter @repo/cms` (in background; wait ~10s for "Ready"). - -Run: `curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin` -Expected: `200`. - -Run: `curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/collections/articles` -Expected: `200`. - -Stop the dev server. - -> Note: When the dev server first connects, Payload may again offer to push schema changes (drop the OLD `users` and `media` columns since core-cms now only knows about `articles` — those collections come back in Plan 3). Answer N to the prompt or accept — for the smoke test we only care that admin responds 200, so the dev server can stay paused on the prompt. - -- [ ] **Step 4: Regenerate types one more time and confirm it includes Articles** - -Run: `cd apps/cms && pnpm generate:types` - -Run: `grep -c "Article" packages/core-cms/src/generated-types.ts` -Expected: at least 5 occurrences (Articles interface, references in Config, etc.). - -- [ ] **Step 5: Commit regenerated types** - -```bash -git add packages/core-cms/src/generated-types.ts -git commit -m "feat(core-cms): regenerate types — now includes Articles" -``` - ---- - -## Plan 2 Done Criteria - -- [ ] All 26 tests in `@repo/blog` pass -- [ ] `@repo/blog`, `@repo/core-cms`, `@repo/core-api` all typecheck -- [ ] `apps/cms` admin UI serves `/admin` and `/admin/collections/articles` with 200 -- [ ] `pnpm generate:types` in `apps/cms` produces a `generated-types.ts` containing `Articles` -- [ ] `core-cms/src/payload.config.ts` references `@repo/blog/cms` (not the old `cms-core`'s articles) -- [ ] `core-api/src/root.ts` references `@repo/blog/api` and exposes `blog.*` procedures -- [ ] `tsconfig.base.json` includes the three `@repo/blog*` aliases -- [ ] No deletions yet — `packages/core/src/entities/models/article.ts` and friends still exist (drained in Plan 6) - -**Next plan:** Plan 3 — Auth + Media features. Migrates the Users collection (auth feature) and Media collection (media feature). Restores `articles.author` to a `relationship → users` and adds back `featuredImage: upload → media`. Demonstrates that the per-feature DI pattern scales beyond one feature. diff --git a/docs/superpowers/plans/2026-05-04-plan-3-auth-media.md b/docs/superpowers/plans/2026-05-04-plan-3-auth-media.md deleted file mode 100644 index 6b82073..0000000 --- a/docs/superpowers/plans/2026-05-04-plan-3-auth-media.md +++ /dev/null @@ -1,1881 +0,0 @@ -# Vertical Refactor — Plan 3: Auth + Media + Restore Blog Cross-Feature Relations - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Migrate the existing `auth` domain (Users collection, sign-in/up/out flows) into a new `@repo/auth` feature package, migrate the Media collection into a new `@repo/media` feature package, and restore the cross-feature relationships in `@repo/blog/integrations/cms/collections/articles.ts` (`author` → relationship to users, `featuredImage` → upload to media). Demonstrates that the per-feature DI pattern + composition exception (`core-cms` aggregating multiple feature `/cms` exports) scales beyond one feature. - -**Architecture:** Two new feature packages following the canonical pattern proven in Plan 2. Auth has multiple use-cases, two services (auth, dropping the unused telemetry), and depends on InversifyJS constructor injection (auth service takes users repo). Media is minimal — collection + barrel only (no use-cases or DI yet; spec addendum v5 "no empty folders"). - -**Tech Stack:** Same as Plan 2. - -**Plan position:** Plan 3 of 6. -- Plan 1 ✅ Foundation -- Plan 2 ✅ Blog feature -- **Plan 3 (this doc):** Auth + Media + restore blog relations -- Plan 4: Marketing-pages + Navigation features -- Plan 5: App + UI integration (core-trpc client/providers, route handlers, example pages) -- Plan 6: Cleanup + boundary enforcement + Playwright + docs rewrite - -**Spec reference:** `docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md` - -**Lessons from Plan 2 (apply throughout):** -1. **Vitest config**: each new feature's `vitest.config.ts` MUST include `resolve.alias: { "@": path.resolve(__dirname, "./src") }` — vitest doesn't read tsconfig paths. -2. **No `rootDir` in tsconfig**: removed in blog because `tests/**/*` is outside `src/`. Same for new features. -3. **Source files use relative imports** (NOT `@/`). `@/` reserved for test files within the feature's own context. Reason: when a downstream package typechecks and follows imports into the feature, it can't resolve the feature's `@/` alias. -4. **Payload-backed repos take `SanitizedConfig` via constructor**, NOT `import config from '@repo/core-cms'`. Avoids workspace cycle. Feature `package.json` does NOT depend on `@repo/core-cms`. App boot (Plan 5) supplies the config when binding the prod repo. -5. **`z.unknown()` in input schemas should be `.optional()`** — Zod treats `unknown` as accepting `undefined` anyway; explicit is more honest. - ---- - -## Decisions taken in this plan - -- **Drop `ITelemetryService`** — currently unused in any auth use-case. Spec addendum v5 says don't carry dead code. If telemetry is needed later, add it back via `core-shared/src/telemetry/` (cross-cutting concern, not auth-specific). -- **Auth domain entity stays `User { id, username, passwordHash }`** — the existing model. The Payload `Users` collection (with `email`, `displayName`, `role`) is a SEPARATE, parallel concept used for Payload admin login. The two are deliberately not wired in this plan; reconciling them is future work. The `author` relationship in blog points at the Payload Users collection (i.e., admin-side users), which is consistent with the existing template. -- **Media is a "skeleton-only" feature** — collection + barrel only. No entities/use-cases/repos yet. Add when something needs them. -- **Blog's `author` field becomes `relationship → users`** with `relationTo: "users"` (the Payload collection slug). Blog package doesn't import auth — relationship is by slug, satisfied at Payload runtime when both collections are registered in `core-cms`. - ---- - -## File Structure - -**Create — new `@repo/auth` package:** -- `packages/auth/{package.json,tsconfig.json,turbo.json,vitest.config.ts}` -- `packages/auth/src/index.ts` -- `packages/auth/src/config.ts` (SESSION_COOKIE constant) -- `packages/auth/src/entities/user.ts` + `user.test.ts` -- `packages/auth/src/entities/cookie.ts` -- `packages/auth/src/entities/session.ts` + `session.test.ts` -- `packages/auth/src/entities/errors.ts` -- `packages/auth/src/application/repositories/users-repository.interface.ts` -- `packages/auth/src/application/services/authentication-service.interface.ts` -- `packages/auth/src/application/use-cases/sign-in.use-case.ts` + `.test.ts` -- `packages/auth/src/application/use-cases/sign-up.use-case.ts` + `.test.ts` -- `packages/auth/src/application/use-cases/sign-out.use-case.ts` + `.test.ts` -- `packages/auth/src/infrastructure/repositories/mock-users.repository.ts` -- `packages/auth/src/infrastructure/services/mock-authentication.service.ts` -- `packages/auth/src/di/symbols.ts` -- `packages/auth/src/di/module.ts` -- `packages/auth/src/di/container.ts` + `container.test.ts` -- `packages/auth/src/interface-adapters/controllers/sign-in.controller.ts` + `.test.ts` -- `packages/auth/src/interface-adapters/controllers/sign-up.controller.ts` + `.test.ts` -- `packages/auth/src/interface-adapters/controllers/sign-out.controller.ts` + `.test.ts` -- `packages/auth/src/integrations/cms/collections/users.ts` -- `packages/auth/src/integrations/cms/index.ts` -- `packages/auth/src/integrations/api/router.ts` + `router.test.ts` -- `packages/auth/src/ui/query.ts` -- `packages/auth/tests/sign-in-flow.feature.test.ts` - -**Create — new `@repo/media` package:** -- `packages/media/{package.json,tsconfig.json,turbo.json}` -- `packages/media/src/index.ts` -- `packages/media/src/integrations/cms/collections/media.ts` -- `packages/media/src/integrations/cms/index.ts` - -**Modify:** -- `packages/blog/src/integrations/cms/collections/articles.ts` — restore `author: relationship → users`, add `featuredImage: upload → media` -- `packages/core-cms/src/payload.config.ts` — register `users`, `media` collections; depend on `@repo/auth` and `@repo/media` -- `packages/core-cms/package.json` — add `@repo/auth`, `@repo/media` -- `packages/core-api/src/root.ts` — add `auth: authRouter` to appRouter -- `packages/core-api/package.json` — add `@repo/auth` -- `tsconfig.base.json` — add `@repo/auth*`, `@repo/media*` aliases -- `apps/cms/package.json` — add `@repo/auth`, `@repo/media` (for schema visibility) - ---- - -## Phase A: Auth feature - -### Task 3.1: Scaffold @repo/auth package - -**Files:** `packages/auth/{package.json,tsconfig.json,turbo.json,vitest.config.ts,src/index.ts}` + path aliases. - -- [ ] **Step 1: Create `packages/auth/package.json`** - -```json -{ - "name": "@repo/auth", - "private": true, - "version": "0.0.0", - "type": "module", - "exports": { - ".": "./src/index.ts", - "./cms": "./src/integrations/cms/index.ts", - "./api": "./src/integrations/api/router.ts" - }, - "scripts": { - "build": "tsc --noEmit", - "lint": "eslint .", - "test": "vitest run --passWithNoTests", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@repo/core-shared": "workspace:*", - "@trpc/server": "^11.0.0", - "inversify": "^6.2.0", - "payload": "^3.14.0", - "reflect-metadata": "^0.2.2", - "zod": "^3.24.0" - }, - "devDependencies": { - "@repo/core-eslint": "workspace:*", - "@repo/core-typescript": "workspace:*", - "@types/node": "^22.0.0", - "vitest": "^3.1.0" - } -} -``` - -> Note: NO `@repo/core-cms` dep (cycle avoidance per Plan 2 lesson #4). - -- [ ] **Step 2: Create `packages/auth/tsconfig.json`** - -```json -{ - "extends": "@repo/core-typescript/base.json", - "compilerOptions": { - "outDir": "dist", - "lib": ["ES2022", "DOM"], - "jsx": "preserve", - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["src/**/*", "tests/**/*"], - "exclude": ["node_modules", "dist"] -} -``` - -> Note: NO `rootDir` (Plan 2 lesson #2). Path alias `@/` declared but only used in test files (Plan 2 lesson #3). - -- [ ] **Step 3: Create `packages/auth/turbo.json`** - -```json -{ - "extends": ["//"], - "tags": ["feature"] -} -``` - -- [ ] **Step 4: Create `packages/auth/vitest.config.ts`** - -```typescript -import path from "node:path"; -import { baseVitestConfig } from "@repo/core-typescript/vitest.base"; - -export default { - ...baseVitestConfig, - resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), - }, - }, -}; -``` - -- [ ] **Step 5: Create `packages/auth/src/index.ts`** - -```typescript -export {}; -``` - -- [ ] **Step 6: Add path aliases to `tsconfig.base.json`** - -Add three lines inside `compilerOptions.paths`: - -```json -"@repo/auth": ["packages/auth/src/index.ts"], -"@repo/auth/cms": ["packages/auth/src/integrations/cms/index.ts"], -"@repo/auth/api": ["packages/auth/src/integrations/api/router.ts"] -``` - -- [ ] **Step 7: Install + verify** - -Run: `pnpm install` -Verify: `pnpm list --recursive --depth=-1 | grep "@repo/auth"` - -- [ ] **Step 8: Commit** - -```bash -git add packages/auth tsconfig.base.json pnpm-lock.yaml -git commit -m "feat(auth): scaffold empty package with feature tag + path aliases" -``` - ---- - -### Task 3.2: Auth entities (User, Cookie, Session, errors) + tests - -**Files:** `packages/auth/src/entities/{user.ts,cookie.ts,session.ts,errors.ts}` + tests, `packages/auth/src/config.ts`. - -- [ ] **Step 1: Write `packages/auth/src/entities/user.test.ts`** - -```typescript -import { describe, expect, it } from "vitest"; -import { userSchema } from "./user"; - -describe("userSchema", () => { - it("accepts a valid user", () => { - const result = userSchema.parse({ - id: "1", - username: "alice", - passwordHash: "hashed_password_1", - }); - expect(result.username).toBe("alice"); - }); - - it("rejects username shorter than 3 chars", () => { - expect(() => - userSchema.parse({ - id: "1", - username: "ab", - passwordHash: "hashed_password_1", - }), - ).toThrow(); - }); - - it("rejects passwordHash shorter than 6 chars", () => { - expect(() => - userSchema.parse({ - id: "1", - username: "alice", - passwordHash: "abc", - }), - ).toThrow(); - }); -}); -``` - -- [ ] **Step 2: Run — expect failure** - -Run: `cd packages/auth && pnpm vitest run src/entities/user.test.ts` -Expected: FAIL — Cannot find module './user'. - -- [ ] **Step 3: Implement `packages/auth/src/entities/user.ts`** - -```typescript -import { z } from "zod"; - -export const userSchema = z.object({ - id: z.string(), - username: z.string().min(3).max(31), - passwordHash: z.string().min(6).max(255), -}); - -export type User = z.infer; -``` - -- [ ] **Step 4: Implement `packages/auth/src/entities/cookie.ts`** - -```typescript -type CookieAttributes = { - secure?: boolean; - path?: string; - domain?: string; - sameSite?: "lax" | "strict" | "none"; - httpOnly?: boolean; - maxAge?: number; - expires?: Date; -}; - -export type Cookie = { - name: string; - value: string; - attributes: CookieAttributes; -}; -``` - -- [ ] **Step 5: Write `packages/auth/src/entities/session.test.ts`** - -```typescript -import { describe, expect, it } from "vitest"; -import { sessionSchema } from "./session"; - -describe("sessionSchema", () => { - it("accepts a valid session", () => { - const result = sessionSchema.parse({ - id: "session_1", - userId: "1", - expiresAt: new Date(), - }); - expect(result.userId).toBe("1"); - }); - - it("rejects non-Date expiresAt", () => { - expect(() => - sessionSchema.parse({ - id: "session_1", - userId: "1", - expiresAt: "2026-05-04", - }), - ).toThrow(); - }); -}); -``` - -- [ ] **Step 6: Implement `packages/auth/src/entities/session.ts`** - -```typescript -import { z } from "zod"; - -export const sessionSchema = z.object({ - id: z.string(), - userId: z.string(), - expiresAt: z.date(), -}); - -export type Session = z.infer; -``` - -- [ ] **Step 7: Implement `packages/auth/src/entities/errors.ts`** - -```typescript -export class AuthenticationError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - } -} - -export class UnauthenticatedError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - } -} - -export class UnauthorizedError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - } -} - -export class InputParseError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - } -} -``` - -- [ ] **Step 8: Implement `packages/auth/src/config.ts`** - -```typescript -export const SESSION_COOKIE = "session"; -``` - -- [ ] **Step 9: Run all entity tests — expect 5 PASS** - -Run: `cd packages/auth && pnpm vitest run src/entities` -Expected: 5 tests pass (user 3 + session 2). - -- [ ] **Step 10: Commit** - -```bash -git add packages/auth/src/entities packages/auth/src/config.ts -git commit -m "feat(auth): add User, Cookie, Session entities + errors + config" -``` - ---- - -### Task 3.3: Auth application interfaces (repository + service) - -- [ ] **Step 1: Create `packages/auth/src/application/repositories/users-repository.interface.ts`** - -```typescript -import type { User } from "../../entities/user"; - -export interface IUsersRepository { - getUser(id: string): Promise; - getUserByUsername(username: string): Promise; - createUser(input: User): Promise; -} -``` - -- [ ] **Step 2: Create `packages/auth/src/application/services/authentication-service.interface.ts`** - -```typescript -import type { Cookie } from "../../entities/cookie"; -import type { Session } from "../../entities/session"; -import type { User } from "../../entities/user"; - -export interface IAuthenticationService { - generateUserId(): string; - hashPassword(password: string): Promise; - verifyPassword(hash: string, password: string): Promise; - validateSession( - sessionId: string, - ): Promise<{ user: User; session: Session }>; - createSession(user: User): Promise<{ session: Session; cookie: Cookie }>; - invalidateSession(sessionId: string): Promise<{ blankCookie: Cookie }>; -} -``` - -- [ ] **Step 3: Verify compiles** - -Run: `cd packages/auth && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/auth/src/application -git commit -m "feat(auth): add IUsersRepository + IAuthenticationService interfaces" -``` - ---- - -### Task 3.4: Auth use-cases (sign-in, sign-up, sign-out) + tests - -**Files:** 3 use-case files + 3 test files. Tests will be RED until DI container lands in Task 3.7. - -- [ ] **Step 1: Create test `packages/auth/src/application/use-cases/sign-in.use-case.test.ts`** - -```typescript -import { beforeEach, describe, expect, it } from "vitest"; -import { authContainer } from "@/di/container"; -import { AUTH_SYMBOLS } from "@/di/symbols"; -import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository"; -import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service"; -import type { IUsersRepository } from "@/application/repositories/users-repository.interface"; -import type { IAuthenticationService } from "@/application/services/authentication-service.interface"; -import { AuthenticationError } from "@/entities/errors"; -import { signInUseCase } from "./sign-in.use-case"; - -describe("signInUseCase", () => { - let usersRepo: MockUsersRepository; - let authService: MockAuthenticationService; - - beforeEach(() => { - if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { - authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); - } - if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) { - authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService); - } - usersRepo = new MockUsersRepository(); - authService = new MockAuthenticationService(usersRepo); - authContainer - .bind(AUTH_SYMBOLS.IUsersRepository) - .toConstantValue(usersRepo); - authContainer - .bind(AUTH_SYMBOLS.IAuthenticationService) - .toConstantValue(authService); - }); - - it("returns a session + cookie on valid credentials", async () => { - const result = await signInUseCase({ - username: "alice", - password: "password_alice", - }); - expect(result.session.userId).toBe("1"); - expect(result.cookie.name).toBe("session"); - }); - - it("throws AuthenticationError when user does not exist", async () => { - await expect( - signInUseCase({ username: "ghost", password: "anything" }), - ).rejects.toBeInstanceOf(AuthenticationError); - }); - - it("throws AuthenticationError on wrong password", async () => { - await expect( - signInUseCase({ username: "alice", password: "wrong" }), - ).rejects.toBeInstanceOf(AuthenticationError); - }); -}); -``` - -- [ ] **Step 2: Implement `packages/auth/src/application/use-cases/sign-in.use-case.ts`** - -```typescript -import { AuthenticationError } from "../../entities/errors"; -import type { Cookie } from "../../entities/cookie"; -import type { Session } from "../../entities/session"; -import { authContainer } from "../../di/container"; -import { AUTH_SYMBOLS } from "../../di/symbols"; -import type { IUsersRepository } from "../repositories/users-repository.interface"; -import type { IAuthenticationService } from "../services/authentication-service.interface"; - -export async function signInUseCase(input: { - username: string; - password: string; -}): Promise<{ session: Session; cookie: Cookie }> { - const usersRepository = authContainer.get( - AUTH_SYMBOLS.IUsersRepository, - ); - const authService = authContainer.get( - AUTH_SYMBOLS.IAuthenticationService, - ); - - const existingUser = await usersRepository.getUserByUsername(input.username); - if (!existingUser) { - throw new AuthenticationError("User does not exist"); - } - - const validPassword = await authService.verifyPassword( - existingUser.passwordHash, - input.password, - ); - if (!validPassword) { - throw new AuthenticationError("Incorrect username or password"); - } - - return await authService.createSession(existingUser); -} -``` - -- [ ] **Step 3: Commit (test RED until 3.7)** - -```bash -git add packages/auth/src/application/use-cases/sign-in.use-case.ts packages/auth/src/application/use-cases/sign-in.use-case.test.ts -git commit -m "feat(auth): add signInUseCase (test red until DI lands)" -``` - -- [ ] **Step 4: Create test `packages/auth/src/application/use-cases/sign-up.use-case.test.ts`** - -```typescript -import { beforeEach, describe, expect, it } from "vitest"; -import { authContainer } from "@/di/container"; -import { AUTH_SYMBOLS } from "@/di/symbols"; -import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository"; -import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service"; -import type { IUsersRepository } from "@/application/repositories/users-repository.interface"; -import type { IAuthenticationService } from "@/application/services/authentication-service.interface"; -import { AuthenticationError } from "@/entities/errors"; -import { signUpUseCase } from "./sign-up.use-case"; - -describe("signUpUseCase", () => { - let usersRepo: MockUsersRepository; - let authService: MockAuthenticationService; - - beforeEach(() => { - if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { - authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); - } - if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) { - authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService); - } - usersRepo = new MockUsersRepository(); - authService = new MockAuthenticationService(usersRepo); - authContainer - .bind(AUTH_SYMBOLS.IUsersRepository) - .toConstantValue(usersRepo); - authContainer - .bind(AUTH_SYMBOLS.IAuthenticationService) - .toConstantValue(authService); - }); - - it("creates a new user and returns session + cookie + user", async () => { - const result = await signUpUseCase({ - username: "carol", - password: "secret_password", - }); - expect(result.user.username).toBe("carol"); - expect(result.session.userId).toBe(result.user.id); - expect(result.cookie.name).toBe("session"); - }); - - it("throws AuthenticationError when username taken", async () => { - await expect( - signUpUseCase({ username: "alice", password: "secret_password" }), - ).rejects.toBeInstanceOf(AuthenticationError); - }); -}); -``` - -- [ ] **Step 5: Implement `packages/auth/src/application/use-cases/sign-up.use-case.ts`** - -```typescript -import { AuthenticationError } from "../../entities/errors"; -import type { Cookie } from "../../entities/cookie"; -import type { Session } from "../../entities/session"; -import type { User } from "../../entities/user"; -import { authContainer } from "../../di/container"; -import { AUTH_SYMBOLS } from "../../di/symbols"; -import type { IUsersRepository } from "../repositories/users-repository.interface"; -import type { IAuthenticationService } from "../services/authentication-service.interface"; - -export async function signUpUseCase(input: { - username: string; - password: string; -}): Promise<{ - session: Session; - cookie: Cookie; - user: Pick; -}> { - const usersRepository = authContainer.get( - AUTH_SYMBOLS.IUsersRepository, - ); - const authService = authContainer.get( - AUTH_SYMBOLS.IAuthenticationService, - ); - - const existingUser = await usersRepository.getUserByUsername(input.username); - if (existingUser) { - throw new AuthenticationError("Username taken"); - } - - const passwordHash = await authService.hashPassword(input.password); - const userId = authService.generateUserId(); - - const newUser = await usersRepository.createUser({ - id: userId, - username: input.username, - passwordHash, - }); - - const { cookie, session } = await authService.createSession(newUser); - - return { - cookie, - session, - user: { id: newUser.id, username: newUser.username }, - }; -} -``` - -- [ ] **Step 6: Commit (test RED)** - -```bash -git add packages/auth/src/application/use-cases/sign-up.use-case.ts packages/auth/src/application/use-cases/sign-up.use-case.test.ts -git commit -m "feat(auth): add signUpUseCase (test red until DI lands)" -``` - -- [ ] **Step 7: Create test `packages/auth/src/application/use-cases/sign-out.use-case.test.ts`** - -```typescript -import { beforeEach, describe, expect, it } from "vitest"; -import { authContainer } from "@/di/container"; -import { AUTH_SYMBOLS } from "@/di/symbols"; -import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository"; -import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service"; -import type { IUsersRepository } from "@/application/repositories/users-repository.interface"; -import type { IAuthenticationService } from "@/application/services/authentication-service.interface"; -import { signOutUseCase } from "./sign-out.use-case"; - -describe("signOutUseCase", () => { - let usersRepo: MockUsersRepository; - let authService: MockAuthenticationService; - - beforeEach(() => { - if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { - authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); - } - if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) { - authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService); - } - usersRepo = new MockUsersRepository(); - authService = new MockAuthenticationService(usersRepo); - authContainer - .bind(AUTH_SYMBOLS.IUsersRepository) - .toConstantValue(usersRepo); - authContainer - .bind(AUTH_SYMBOLS.IAuthenticationService) - .toConstantValue(authService); - }); - - it("returns a blank cookie", async () => { - const result = await signOutUseCase("session_1"); - expect(result.blankCookie.name).toBe("session"); - expect(result.blankCookie.value).toBe(""); - }); -}); -``` - -- [ ] **Step 8: Implement `packages/auth/src/application/use-cases/sign-out.use-case.ts`** - -```typescript -import type { Cookie } from "../../entities/cookie"; -import { authContainer } from "../../di/container"; -import { AUTH_SYMBOLS } from "../../di/symbols"; -import type { IAuthenticationService } from "../services/authentication-service.interface"; - -export async function signOutUseCase( - sessionId: string, -): Promise<{ blankCookie: Cookie }> { - const authService = authContainer.get( - AUTH_SYMBOLS.IAuthenticationService, - ); - return await authService.invalidateSession(sessionId); -} -``` - -- [ ] **Step 9: Commit** - -```bash -git add packages/auth/src/application/use-cases/sign-out.use-case.ts packages/auth/src/application/use-cases/sign-out.use-case.test.ts -git commit -m "feat(auth): add signOutUseCase (test red until DI lands)" -``` - ---- - -### Task 3.5: Mock users repository - -- [ ] **Step 1: Create `packages/auth/src/infrastructure/repositories/mock-users.repository.ts`** - -```typescript -import "reflect-metadata"; -import { injectable } from "inversify"; - -import type { IUsersRepository } from "../../application/repositories/users-repository.interface"; -import type { User } from "../../entities/user"; - -@injectable() -export class MockUsersRepository implements IUsersRepository { - private _users: User[] = [ - { id: "1", username: "alice", passwordHash: "hashed_password_alice" }, - { id: "2", username: "bob", passwordHash: "hashed_password_bob" }, - ]; - - async getUser(id: string): Promise { - return this._users.find((u) => u.id === id); - } - - async getUserByUsername(username: string): Promise { - return this._users.find((u) => u.username === username); - } - - async createUser(input: User): Promise { - this._users.push(input); - return input; - } -} -``` - -- [ ] **Step 2: Verify compiles** - -Run: `cd packages/auth && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add packages/auth/src/infrastructure/repositories -git commit -m "feat(auth): add MockUsersRepository with seed users" -``` - ---- - -### Task 3.6: Mock authentication service - -- [ ] **Step 1: Create `packages/auth/src/infrastructure/services/mock-authentication.service.ts`** - -```typescript -import "reflect-metadata"; -import { inject, injectable } from "inversify"; - -import type { IAuthenticationService } from "../../application/services/authentication-service.interface"; -import type { IUsersRepository } from "../../application/repositories/users-repository.interface"; -import { UnauthenticatedError } from "../../entities/errors"; -import { sessionSchema, type Session } from "../../entities/session"; -import type { Cookie } from "../../entities/cookie"; -import type { User } from "../../entities/user"; -import { AUTH_SYMBOLS } from "../../di/symbols"; -import { SESSION_COOKIE } from "../../config"; - -@injectable() -export class MockAuthenticationService implements IAuthenticationService { - private _sessions: Record = {}; - - constructor( - @inject(AUTH_SYMBOLS.IUsersRepository) - private _usersRepository: IUsersRepository, - ) {} - - generateUserId(): string { - return (Math.random() + 1).toString(36).substring(7); - } - - async hashPassword(password: string): Promise { - return `hashed_${password}`; - } - - async verifyPassword(hash: string, password: string): Promise { - return hash === `hashed_${password}`; - } - - async validateSession( - sessionId: string, - ): Promise<{ user: User; session: Session }> { - const result = this._sessions[sessionId]; - if (!result) { - throw new UnauthenticatedError("Unauthenticated"); - } - const user = await this._usersRepository.getUser(result.user.id); - if (!user) { - throw new UnauthenticatedError("Unauthenticated"); - } - return { user, session: result.session }; - } - - async createSession( - user: User, - ): Promise<{ session: Session; cookie: Cookie }> { - const session = sessionSchema.parse({ - id: "session_" + user.id, - userId: user.id, - expiresAt: new Date(Date.now() + 86400000 * 7), - }); - const cookie: Cookie = { - name: SESSION_COOKIE, - value: session.id, - attributes: {}, - }; - this._sessions[session.id] = { session, user }; - return { session, cookie }; - } - - async invalidateSession( - sessionId: string, - ): Promise<{ blankCookie: Cookie }> { - delete this._sessions[sessionId]; - return { - blankCookie: { name: SESSION_COOKIE, value: "", attributes: {} }, - }; - } -} -``` - -- [ ] **Step 2: Verify compiles** - -Run: `cd packages/auth && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add packages/auth/src/infrastructure/services -git commit -m "feat(auth): add MockAuthenticationService with constructor-injected users repo" -``` - ---- - -### Task 3.7: Per-feature DI container (symbols, module, container) + test - -- [ ] **Step 1: Write `packages/auth/src/di/container.test.ts`** - -```typescript -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { authContainer } from "./container"; -import { AUTH_SYMBOLS } from "./symbols"; -import { AuthModule } from "./module"; -import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository"; -import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service"; -import type { IUsersRepository } from "@/application/repositories/users-repository.interface"; -import type { IAuthenticationService } from "@/application/services/authentication-service.interface"; - -describe("authContainer", () => { - beforeEach(() => { - authContainer.unbindAll(); - authContainer.load(AuthModule); - }); - - afterEach(() => { - authContainer.unbindAll(); - }); - - it("resolves IUsersRepository to MockUsersRepository by default", () => { - const repo = authContainer.get( - AUTH_SYMBOLS.IUsersRepository, - ); - expect(repo).toBeInstanceOf(MockUsersRepository); - }); - - it("resolves IAuthenticationService to MockAuthenticationService by default", () => { - const service = authContainer.get( - AUTH_SYMBOLS.IAuthenticationService, - ); - expect(service).toBeInstanceOf(MockAuthenticationService); - }); - - it("authentication service receives users repository via constructor injection", async () => { - const service = authContainer.get( - AUTH_SYMBOLS.IAuthenticationService, - ); - // The service should be able to validate against the seeded users - const { session, cookie } = await service.createSession({ - id: "1", - username: "alice", - passwordHash: "hashed_password_alice", - }); - expect(session.userId).toBe("1"); - expect(cookie.value).toBe(session.id); - - // After session creation, validateSession should resolve user via the repo - const validated = await service.validateSession(session.id); - expect(validated.user.username).toBe("alice"); - }); -}); -``` - -- [ ] **Step 2: Implement `packages/auth/src/di/symbols.ts`** - -```typescript -export const AUTH_SYMBOLS = { - IUsersRepository: Symbol.for("auth:IUsersRepository"), - IAuthenticationService: Symbol.for("auth:IAuthenticationService"), -} as const; -``` - -- [ ] **Step 3: Implement `packages/auth/src/di/module.ts`** - -```typescript -import { ContainerModule, type interfaces } from "inversify"; - -import type { IUsersRepository } from "../application/repositories/users-repository.interface"; -import type { IAuthenticationService } from "../application/services/authentication-service.interface"; -import { MockUsersRepository } from "../infrastructure/repositories/mock-users.repository"; -import { MockAuthenticationService } from "../infrastructure/services/mock-authentication.service"; -import { AUTH_SYMBOLS } from "./symbols"; - -export const AuthModule = new ContainerModule((bind: interfaces.Bind) => { - bind(AUTH_SYMBOLS.IUsersRepository).to(MockUsersRepository); - bind(AUTH_SYMBOLS.IAuthenticationService).to( - MockAuthenticationService, - ); -}); -``` - -- [ ] **Step 4: Implement `packages/auth/src/di/container.ts`** - -```typescript -import "reflect-metadata"; -import { Container } from "inversify"; -import { AuthModule } from "./module"; - -export const authContainer = new Container({ defaultScope: "Singleton" }); -authContainer.load(AuthModule); -``` - -- [ ] **Step 5: Run container test — expect 3 PASS** - -Run: `cd packages/auth && pnpm vitest run src/di/container.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 6: Run previously-RED use-case tests — expect 6 PASS** - -Run: `cd packages/auth && pnpm vitest run src/application/use-cases` -Expected: PASS — 6 tests (sign-in 3 + sign-up 2 + sign-out 1). - -- [ ] **Step 7: Commit** - -```bash -git add packages/auth/src/di -git commit -m "feat(auth): add per-feature InversifyJS container with constructor-injected service" -``` - ---- - -### Task 3.8: Auth controllers + tests - -- [ ] **Step 1: Write test `packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts`** - -```typescript -import { beforeEach, describe, expect, it } from "vitest"; -import { authContainer } from "@/di/container"; -import { AUTH_SYMBOLS } from "@/di/symbols"; -import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository"; -import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service"; -import type { IUsersRepository } from "@/application/repositories/users-repository.interface"; -import type { IAuthenticationService } from "@/application/services/authentication-service.interface"; -import { InputParseError } from "@/entities/errors"; -import { signInController } from "./sign-in.controller"; - -describe("signInController", () => { - let usersRepo: MockUsersRepository; - let authService: MockAuthenticationService; - - beforeEach(() => { - if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { - authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); - } - if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) { - authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService); - } - usersRepo = new MockUsersRepository(); - authService = new MockAuthenticationService(usersRepo); - authContainer - .bind(AUTH_SYMBOLS.IUsersRepository) - .toConstantValue(usersRepo); - authContainer - .bind(AUTH_SYMBOLS.IAuthenticationService) - .toConstantValue(authService); - }); - - it("returns a cookie on valid credentials", async () => { - const cookie = await signInController({ - username: "alice", - password: "password_alice", - }); - expect(cookie.name).toBe("session"); - }); - - it("throws InputParseError on missing username", async () => { - await expect( - signInController({ password: "anything" }), - ).rejects.toBeInstanceOf(InputParseError); - }); - - it("throws InputParseError on too-short password", async () => { - await expect( - signInController({ username: "alice", password: "abc" }), - ).rejects.toBeInstanceOf(InputParseError); - }); -}); -``` - -- [ ] **Step 2: Implement `packages/auth/src/interface-adapters/controllers/sign-in.controller.ts`** - -```typescript -import { z } from "zod"; - -import { InputParseError } from "../../entities/errors"; -import type { Cookie } from "../../entities/cookie"; -import { signInUseCase } from "../../application/use-cases/sign-in.use-case"; - -const inputSchema = z.object({ - username: z.string().min(3).max(31), - password: z.string().min(6).max(255), -}); - -export async function signInController( - input: Partial>, -): Promise { - const parsed = inputSchema.safeParse(input); - if (!parsed.success) { - throw new InputParseError("Invalid sign-in input", { cause: parsed.error }); - } - const { cookie } = await signInUseCase(parsed.data); - return cookie; -} -``` - -- [ ] **Step 3: Run — expect 3 PASS** - -Run: `cd packages/auth && pnpm vitest run src/interface-adapters/controllers/sign-in.controller.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 4: Write test `packages/auth/src/interface-adapters/controllers/sign-up.controller.test.ts`** - -```typescript -import { beforeEach, describe, expect, it } from "vitest"; -import { authContainer } from "@/di/container"; -import { AUTH_SYMBOLS } from "@/di/symbols"; -import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository"; -import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service"; -import type { IUsersRepository } from "@/application/repositories/users-repository.interface"; -import type { IAuthenticationService } from "@/application/services/authentication-service.interface"; -import { InputParseError } from "@/entities/errors"; -import { signUpController } from "./sign-up.controller"; - -describe("signUpController", () => { - let usersRepo: MockUsersRepository; - let authService: MockAuthenticationService; - - beforeEach(() => { - if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { - authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); - } - if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) { - authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService); - } - usersRepo = new MockUsersRepository(); - authService = new MockAuthenticationService(usersRepo); - authContainer - .bind(AUTH_SYMBOLS.IUsersRepository) - .toConstantValue(usersRepo); - authContainer - .bind(AUTH_SYMBOLS.IAuthenticationService) - .toConstantValue(authService); - }); - - it("creates a new user when passwords match", async () => { - const result = await signUpController({ - username: "carol", - password: "secret_password", - confirmPassword: "secret_password", - }); - expect(result.user.username).toBe("carol"); - }); - - it("throws InputParseError when passwords do not match", async () => { - await expect( - signUpController({ - username: "dave", - password: "secret_password", - confirmPassword: "different_password", - }), - ).rejects.toBeInstanceOf(InputParseError); - }); -}); -``` - -- [ ] **Step 5: Implement `packages/auth/src/interface-adapters/controllers/sign-up.controller.ts`** - -```typescript -import { z } from "zod"; - -import { InputParseError } from "../../entities/errors"; -import { signUpUseCase } from "../../application/use-cases/sign-up.use-case"; - -const inputSchema = z - .object({ - username: z.string().min(3).max(31), - password: z.string().min(6).max(255), - confirmPassword: z.string().min(6).max(255), - }) - .superRefine(({ password, confirmPassword }, ctx) => { - if (confirmPassword !== password) { - ctx.addIssue({ - code: "custom", - message: "The passwords did not match", - path: ["password"], - }); - ctx.addIssue({ - code: "custom", - message: "The passwords did not match", - path: ["confirmPassword"], - }); - } - }); - -export async function signUpController( - input: Partial>, -): Promise> { - const parsed = inputSchema.safeParse(input); - if (!parsed.success) { - throw new InputParseError("Invalid sign-up input", { cause: parsed.error }); - } - return await signUpUseCase(parsed.data); -} -``` - -- [ ] **Step 6: Run — expect 2 PASS** - -Run: `cd packages/auth && pnpm vitest run src/interface-adapters/controllers/sign-up.controller.test.ts` -Expected: PASS — 2 tests. - -- [ ] **Step 7: Write test `packages/auth/src/interface-adapters/controllers/sign-out.controller.test.ts`** - -```typescript -import { beforeEach, describe, expect, it } from "vitest"; -import { authContainer } from "@/di/container"; -import { AUTH_SYMBOLS } from "@/di/symbols"; -import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository"; -import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service"; -import type { IUsersRepository } from "@/application/repositories/users-repository.interface"; -import type { IAuthenticationService } from "@/application/services/authentication-service.interface"; -import { InputParseError } from "@/entities/errors"; -import { signOutController } from "./sign-out.controller"; - -describe("signOutController", () => { - beforeEach(() => { - if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { - authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); - } - if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) { - authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService); - } - const usersRepo = new MockUsersRepository(); - const authService = new MockAuthenticationService(usersRepo); - authContainer - .bind(AUTH_SYMBOLS.IUsersRepository) - .toConstantValue(usersRepo); - authContainer - .bind(AUTH_SYMBOLS.IAuthenticationService) - .toConstantValue(authService); - }); - - it("returns a blank cookie", async () => { - const cookie = await signOutController("session_anything"); - expect(cookie.name).toBe("session"); - expect(cookie.value).toBe(""); - }); - - it("throws InputParseError when sessionId is missing", async () => { - await expect(signOutController(undefined)).rejects.toBeInstanceOf( - InputParseError, - ); - }); -}); -``` - -- [ ] **Step 8: Implement `packages/auth/src/interface-adapters/controllers/sign-out.controller.ts`** - -```typescript -import { InputParseError } from "../../entities/errors"; -import type { Cookie } from "../../entities/cookie"; -import { signOutUseCase } from "../../application/use-cases/sign-out.use-case"; - -export async function signOutController( - sessionId: string | undefined, -): Promise { - if (!sessionId) { - throw new InputParseError("Must provide a session ID"); - } - const { blankCookie } = await signOutUseCase(sessionId); - return blankCookie; -} -``` - -- [ ] **Step 9: Run — expect 2 PASS** - -Run: `cd packages/auth && pnpm vitest run src/interface-adapters/controllers/sign-out.controller.test.ts` -Expected: PASS — 2 tests. - -- [ ] **Step 10: Single commit for all 3 controllers** - -```bash -git add packages/auth/src/interface-adapters -git commit -m "feat(auth): add sign-in/sign-up/sign-out controllers with Zod validation" -``` - ---- - -### Task 3.9: Auth CMS integration (Users collection) - -- [ ] **Step 1: Create `packages/auth/src/integrations/cms/collections/users.ts`** - -```typescript -import type { CollectionConfig } from "payload"; - -export const users: CollectionConfig = { - slug: "users", - auth: true, - admin: { - useAsTitle: "email", - }, - fields: [ - { - name: "displayName", - type: "text", - }, - { - name: "role", - type: "select", - options: [ - { label: "Admin", value: "admin" }, - { label: "Editor", value: "editor" }, - { label: "Author", value: "author" }, - ], - defaultValue: "author", - required: true, - }, - ], -}; -``` - -> Note: This is the Payload-managed admin auth collection. It is intentionally separate from the domain `User` entity (id/username/passwordHash) used by the auth feature's use-cases. Reconciling the two is future work; for now they coexist. - -- [ ] **Step 2: Create `packages/auth/src/integrations/cms/index.ts`** - -```typescript -export { users } from "./collections/users"; -``` - -- [ ] **Step 3: Verify compiles** - -Run: `cd packages/auth && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/auth/src/integrations/cms -git commit -m "feat(auth): add users collection with role + displayName fields" -``` - ---- - -### Task 3.10: Auth API integration (tRPC router) + tests - -- [ ] **Step 1: Write test `packages/auth/src/integrations/api/router.test.ts`** - -```typescript -import { beforeEach, describe, expect, it } from "vitest"; -import { authContainer } from "@/di/container"; -import { AUTH_SYMBOLS } from "@/di/symbols"; -import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository"; -import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service"; -import type { IUsersRepository } from "@/application/repositories/users-repository.interface"; -import type { IAuthenticationService } from "@/application/services/authentication-service.interface"; -import { authRouter } from "./router"; - -describe("authRouter", () => { - beforeEach(() => { - if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { - authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); - } - if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) { - authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService); - } - const usersRepo = new MockUsersRepository(); - const authService = new MockAuthenticationService(usersRepo); - authContainer - .bind(AUTH_SYMBOLS.IUsersRepository) - .toConstantValue(usersRepo); - authContainer - .bind(AUTH_SYMBOLS.IAuthenticationService) - .toConstantValue(authService); - }); - - it("exposes signIn, signUp, signOut procedures", () => { - const names = Object.keys(authRouter._def.procedures); - expect(names).toContain("signIn"); - expect(names).toContain("signUp"); - expect(names).toContain("signOut"); - }); - - it("signIn returns a cookie", async () => { - const caller = authRouter.createCaller({}); - const result = await caller.signIn({ - username: "alice", - password: "password_alice", - }); - expect(result.name).toBe("session"); - }); -}); -``` - -- [ ] **Step 2: Implement `packages/auth/src/integrations/api/router.ts`** - -```typescript -import { z } from "zod"; -import { router, publicProcedure } from "@repo/core-shared/trpc/init"; -import { signInController } from "../../interface-adapters/controllers/sign-in.controller"; -import { signUpController } from "../../interface-adapters/controllers/sign-up.controller"; -import { signOutController } from "../../interface-adapters/controllers/sign-out.controller"; - -export const authRouter = router({ - signIn: publicProcedure - .input( - z.object({ - username: z.string().min(3).max(31), - password: z.string().min(6).max(255), - }), - ) - .mutation(({ input }) => signInController(input)), - - signUp: publicProcedure - .input( - z.object({ - username: z.string().min(3).max(31), - password: z.string().min(6).max(255), - confirmPassword: z.string().min(6).max(255), - }), - ) - .mutation(({ input }) => signUpController(input)), - - signOut: publicProcedure - .input(z.object({ sessionId: z.string() })) - .mutation(({ input }) => signOutController(input.sessionId)), -}); - -export type AuthRouter = typeof authRouter; -``` - -- [ ] **Step 3: Run — expect 2 PASS** - -Run: `cd packages/auth && pnpm vitest run src/integrations/api/router.test.ts` -Expected: PASS — 2 tests. - -- [ ] **Step 4: Commit** - -```bash -git add packages/auth/src/integrations/api -git commit -m "feat(auth): add tRPC router with signIn + signUp + signOut" -``` - ---- - -### Task 3.11: ui/query.ts (placeholder for Plan 5 consumers) + barrel - -- [ ] **Step 1: Create `packages/auth/src/ui/query.ts`** - -```typescript -// React Query option builders for auth feature procedures. -// Sign-in/up/out are mutations — no query options needed. -// This file is intentionally minimal; expand if read procedures get added. - -export {}; -``` - -- [ ] **Step 2: Replace `packages/auth/src/index.ts` with public surface** - -```typescript -export type { User } from "./entities/user"; -export type { Session } from "./entities/session"; -export type { Cookie } from "./entities/cookie"; -export { - AuthenticationError, - UnauthenticatedError, - UnauthorizedError, - InputParseError, -} from "./entities/errors"; -export { SESSION_COOKIE } from "./config"; -``` - -- [ ] **Step 3: Verify compiles** - -Run: `cd packages/auth && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/auth/src/ui packages/auth/src/index.ts -git commit -m "feat(auth): wire root barrel + ui/query stub" -``` - ---- - -### Task 3.12: Auth feature-level test - -- [ ] **Step 1: Create `packages/auth/tests/sign-in-flow.feature.test.ts`** - -```typescript -// Feature-level test: sign-up, then sign-in with the new credentials, then sign-out. - -import { beforeEach, describe, expect, it } from "vitest"; -import { authContainer } from "../src/di/container"; -import { AUTH_SYMBOLS } from "../src/di/symbols"; -import { MockUsersRepository } from "../src/infrastructure/repositories/mock-users.repository"; -import { MockAuthenticationService } from "../src/infrastructure/services/mock-authentication.service"; -import type { IUsersRepository } from "../src/application/repositories/users-repository.interface"; -import type { IAuthenticationService } from "../src/application/services/authentication-service.interface"; -import { authRouter } from "../src/integrations/api/router"; - -describe("auth feature: sign-up → sign-in → sign-out", () => { - beforeEach(() => { - if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { - authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); - } - if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) { - authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService); - } - const usersRepo = new MockUsersRepository(); - const authService = new MockAuthenticationService(usersRepo); - authContainer - .bind(AUTH_SYMBOLS.IUsersRepository) - .toConstantValue(usersRepo); - authContainer - .bind(AUTH_SYMBOLS.IAuthenticationService) - .toConstantValue(authService); - }); - - it("a new user can sign up, then sign in, then sign out", async () => { - const caller = authRouter.createCaller({}); - - const signUpResult = await caller.signUp({ - username: "newperson", - password: "verysecret", - confirmPassword: "verysecret", - }); - expect(signUpResult.user.username).toBe("newperson"); - const userId = signUpResult.user.id; - - const signInCookie = await caller.signIn({ - username: "newperson", - password: "verysecret", - }); - expect(signInCookie.value).toBe("session_" + userId); - - const signOutResult = await caller.signOut({ - sessionId: signInCookie.value, - }); - expect(signOutResult.value).toBe(""); - }); -}); -``` - -- [ ] **Step 2: Run** - -Run: `cd packages/auth && pnpm vitest run tests/` -Expected: PASS — 1 test. - -- [ ] **Step 3: Commit** - -```bash -git add packages/auth/tests -git commit -m "test(auth): add feature test for sign-up → sign-in → sign-out flow" -``` - ---- - -## Phase B: Media feature (skeleton-only) - -### Task 3.13: Scaffold @repo/media package - -- [ ] **Step 1: Create `packages/media/package.json`** - -```json -{ - "name": "@repo/media", - "private": true, - "version": "0.0.0", - "type": "module", - "exports": { - ".": "./src/index.ts", - "./cms": "./src/integrations/cms/index.ts" - }, - "scripts": { - "build": "tsc --noEmit", - "lint": "eslint .", - "test": "vitest run --passWithNoTests", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "payload": "^3.14.0" - }, - "devDependencies": { - "@repo/core-eslint": "workspace:*", - "@repo/core-typescript": "workspace:*", - "vitest": "^3.1.0" - } -} -``` - -> Note: NO `./api` export — media has no tRPC procedures yet (no use-cases). Add when needed. - -- [ ] **Step 2: Create `packages/media/tsconfig.json`** - -```json -{ - "extends": "@repo/core-typescript/base.json", - "compilerOptions": { - "outDir": "dist", - "lib": ["ES2022", "DOM"] - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} -``` - -- [ ] **Step 3: Create `packages/media/turbo.json`** - -```json -{ - "extends": ["//"], - "tags": ["feature"] -} -``` - -- [ ] **Step 4: Create `packages/media/src/integrations/cms/collections/media.ts`** - -```typescript -import type { CollectionConfig } from "payload"; - -export const media: CollectionConfig = { - slug: "media", - upload: { - mimeTypes: ["image/*", "application/pdf"], - }, - admin: { - useAsTitle: "filename", - }, - fields: [ - { - name: "alt", - type: "text", - required: true, - }, - ], -}; -``` - -- [ ] **Step 5: Create `packages/media/src/integrations/cms/index.ts`** - -```typescript -export { media } from "./collections/media"; -``` - -- [ ] **Step 6: Create `packages/media/src/index.ts`** - -```typescript -export {}; -``` - -- [ ] **Step 7: Add path aliases to `tsconfig.base.json`** - -Add to `compilerOptions.paths`: - -```json -"@repo/media": ["packages/media/src/index.ts"], -"@repo/media/cms": ["packages/media/src/integrations/cms/index.ts"] -``` - -- [ ] **Step 8: Install + verify** - -Run: `pnpm install` then `pnpm typecheck --filter @repo/media` -Expected: PASS. - -- [ ] **Step 9: Commit** - -```bash -git add packages/media tsconfig.base.json pnpm-lock.yaml -git commit -m "feat(media): scaffold feature package with media collection only" -``` - ---- - -## Phase C: Compose into core-cms + core-api, restore blog cross-feature relations - -### Task 3.14: Wire users + media into core-cms - -- [ ] **Step 1: Add `@repo/auth` and `@repo/media` to `packages/core-cms/package.json`** - -Edit `dependencies` block — add both lines: - -```json -"@repo/auth": "workspace:*", -"@repo/media": "workspace:*", -``` - -- [ ] **Step 2: Replace `packages/core-cms/src/payload.config.ts`** - -```typescript -import { buildConfig } from "payload"; -import { postgresAdapter } from "@payloadcms/db-postgres"; -import { lexicalEditor } from "@payloadcms/richtext-lexical"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { users } from "@repo/auth/cms"; -import { articles } from "@repo/blog/cms"; -import { media } from "@repo/media/cms"; - -const filename = fileURLToPath(import.meta.url); -const dirname = path.dirname(filename); - -export default buildConfig({ - editor: lexicalEditor(), - collections: [users, articles, media], - globals: [], - secret: process.env.PAYLOAD_SECRET || "default-secret-change-me", - db: postgresAdapter({ - pool: { - connectionString: - process.env.DATABASE_URL || - "postgresql://postgres:postgres@localhost:5432/template", - }, - }), - typescript: { - outputFile: path.resolve(dirname, "generated-types.ts"), - }, -}); -``` - -- [ ] **Step 3: Install + typecheck** - -Run: `pnpm install` then `pnpm typecheck --filter @repo/core-cms` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/core-cms pnpm-lock.yaml -git commit -m "feat(core-cms): compose users + media into payload config alongside articles" -``` - ---- - -### Task 3.15: Restore blog's author + featuredImage relations - -- [ ] **Step 1: Replace `packages/blog/src/integrations/cms/collections/articles.ts`** - -```typescript -import type { CollectionConfig } from "payload"; -import { slugifyIfMissing } from "@repo/core-shared/payload"; - -export const articles: CollectionConfig = { - slug: "articles", - admin: { - useAsTitle: "title", - defaultColumns: ["title", "status", "author", "updatedAt"], - }, - hooks: { - beforeChange: [slugifyIfMissing], - }, - versions: { - drafts: true, - }, - fields: [ - { - name: "title", - type: "text", - required: true, - maxLength: 255, - }, - { - name: "slug", - type: "text", - unique: true, - admin: { - position: "sidebar", - description: "Auto-generated from title if left empty", - }, - }, - { - name: "content", - type: "richText", - }, - { - name: "status", - type: "select", - options: [ - { label: "Draft", value: "draft" }, - { label: "Published", value: "published" }, - ], - defaultValue: "draft", - required: true, - admin: { - position: "sidebar", - }, - }, - { - name: "author", - type: "relationship", - relationTo: "users", - required: true, - admin: { - position: "sidebar", - }, - }, - { - name: "featuredImage", - type: "upload", - relationTo: "media", - }, - { - name: "publishedAt", - type: "date", - admin: { - position: "sidebar", - date: { - pickerAppearance: "dayAndTime", - }, - }, - }, - ], -}; -``` - -> Note: `relationTo: "users"` and `relationTo: "media"` are slug references — they don't require importing `@repo/auth` or `@repo/media` from blog. Payload resolves them at runtime by looking up the collection slug in the assembled config. - -- [ ] **Step 2: Verify blog still compiles + tests pass** - -Run: `cd packages/blog && pnpm typecheck && pnpm test` -Expected: PASS — 26 tests. - -- [ ] **Step 3: Commit** - -```bash -git add packages/blog/src/integrations/cms/collections/articles.ts -git commit -m "feat(blog): restore author relationship → users + featuredImage upload → media" -``` - ---- - -### Task 3.16: Wire authRouter into core-api - -- [ ] **Step 1: Add `@repo/auth` to `packages/core-api/package.json`** - -Add inside `dependencies`: - -```json -"@repo/auth": "workspace:*", -``` - -- [ ] **Step 2: Replace `packages/core-api/src/root.ts`** - -```typescript -import { router } from "@repo/core-shared/trpc/init"; -import { authRouter } from "@repo/auth/api"; -import { blogRouter } from "@repo/blog/api"; - -export const appRouter = router({ - auth: authRouter, - blog: blogRouter, -}); - -export type AppRouter = typeof appRouter; -``` - -- [ ] **Step 3: Install + typecheck** - -Run: `pnpm install` then `pnpm typecheck --filter @repo/core-api --filter @repo/auth` -Expected: BOTH PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/core-api pnpm-lock.yaml -git commit -m "feat(core-api): compose @repo/auth/api into appRouter under 'auth' namespace" -``` - ---- - -### Task 3.17: Final verification + apps/cms boot smoke - -- [ ] **Step 1: Run all tests** - -Run: `pnpm test --filter @repo/auth --filter @repo/blog --filter @repo/core-shared` -Expected: PASS. Auth: ~14 tests (entities 5 + DI container 3 + use-cases 6 + controllers 7 + router 2 + feature 1 = 24 — check actual count). Blog: 26. Core-shared: 26. - -- [ ] **Step 2: Repo-wide typecheck** - -Run: `pnpm typecheck` -Expected: PASS for blog, auth, media, core-shared, core-cms, core-api, core-trpc, core-ui, cms, cms-core. PRE-EXISTING failures remain in `@repo/api` and `@repo/ui`. - -- [ ] **Step 3: Boot apps/cms admin smoke test** - -Postgres: `docker ps | grep postgres` — should be running. - -Start cms in background: -```bash -pnpm dev --filter @repo/cms & -DEV_PID=$! -sleep 15 -``` - -Test endpoints: -```bash -curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin -curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/collections/articles -curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/collections/users -curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/collections/media -``` -Expected: ALL return `200`. - -> Note: Payload may again offer schema push (drop article fields not in current config or add new user/media tables). Smoke test only cares about 200 responses; kill dev server after. - -Kill: `kill $DEV_PID 2>/dev/null; pkill -f "next.*3001" 2>/dev/null` - -- [ ] **Step 4: Regenerate types** - -Run: `cd apps/cms && pnpm generate:types` - -Verify both Articles + Users + Media types are present: -```bash -cd /Users/danijel/Documents/Projects/template-vertical/.worktrees/vertical-refactor -grep -c "Article\|User\|Media" packages/core-cms/src/generated-types.ts -``` -Expected: count >= 10 (multiple references each). - -- [ ] **Step 5: Commit regenerated types if changed** - -```bash -git add packages/core-cms/src/generated-types.ts -git diff --cached --quiet || git commit -m "feat(core-cms): regenerate types — now includes Articles, Users, Media" -``` - ---- - -## Plan 3 Done Criteria - -- [ ] All auth tests pass (~24 tests in `@repo/auth`) -- [ ] All blog tests pass (26) -- [ ] Media compiles (no tests yet — skeleton only) -- [ ] `apps/cms` admin serves all four endpoints (`/admin`, `/admin/collections/articles`, `/users`, `/media`) with 200 -- [ ] `pnpm generate:types` produces a `generated-types.ts` containing Articles + Users + Media -- [ ] `core-cms/src/payload.config.ts` registers `users`, `articles`, `media` collections -- [ ] `core-api/src/root.ts` exposes both `auth.*` and `blog.*` namespaces -- [ ] Blog's `articles.ts` has `author: relationship → users` and `featuredImage: upload → media` -- [ ] No deletions yet (old `packages/core/src/entities/models/user.ts` etc. still exist — drained in Plan 6) - -**Next plan:** Plan 4 — Marketing-pages + Navigation features. Adds the `pages` collection (with `featuredImage` relation to media), the `header` global, and the `siteSettings` global. No new cross-feature dependencies; should be quicker than Plan 3 since the patterns are well-established. diff --git a/docs/superpowers/plans/2026-05-04-plan-4-marketing-pages-navigation.md b/docs/superpowers/plans/2026-05-04-plan-4-marketing-pages-navigation.md deleted file mode 100644 index 0c8a8b0..0000000 --- a/docs/superpowers/plans/2026-05-04-plan-4-marketing-pages-navigation.md +++ /dev/null @@ -1,1875 +0,0 @@ -# Vertical Refactor — Plan 4: Marketing-pages + Navigation - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add `@repo/marketing-pages` (Pages collection + SiteSettings global + read use-case + tRPC router) and `@repo/navigation` (Header global + read use-case + tRPC router). Compose both into `core-cms` and `core-api`. Demonstrates that the canonical pattern scales smoothly to features that own globals (not just collections) and to features with read-only surfaces. - -**Architecture:** Both features follow the proven canonical shape from Plans 2-3. Marketing-pages is comparable in size to blog. Navigation is smaller — globals are simpler than collections (no per-record CRUD, just one document). Both use the per-feature InversifyJS container + payload-backed repository (constructor-injected with `SanitizedConfig`) pattern. - -**Tech Stack:** Same as Plans 2-3. - -**Plan position:** Plan 4 of 6. -- Plan 1 ✅ Foundation -- Plan 2 ✅ Blog feature -- Plan 3 ✅ Auth + Media + restore blog relations -- **Plan 4 (this doc):** Marketing-pages + Navigation -- Plan 5: App + UI integration -- Plan 6: Cleanup + boundary enforcement + Playwright + docs rewrite - -**Spec reference:** `docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md` - -**All Plan 2/3 lessons apply:** -1. vitest.config.ts has `resolve.alias` for `@/` -2. tsconfig.json has NO `rootDir` -3. Source files use relative imports; tests use `@/` -4. Payload-backed repos take `SanitizedConfig` via constructor (no `@repo/core-cms` dep) -5. Feature `package.json` has NO `@repo/core-cms` dep -6. `z.unknown()` schemas should be `.optional()` - ---- - -## Schema design (this plan introduces) - -### Marketing-pages - -`pages` collection — generic CMS page (about, contact, etc.): -- `title: text required` -- `slug: text unique` (sidebar, auto-generated via `slugifyIfMissing`) -- `hero: group { heading: text, subheading: textarea, image: upload→media (optional) }` -- `layout: blocks [cta]` (uses generic `cta` block from `@repo/core-shared/payload`) -- `status: select draft|published, default draft, required, sidebar` -- `publishedAt: date sidebar` -- `seo: group { title: text required, description: textarea }` (uses `seoFields` from `core-shared`) - -`siteSettings` global: -- Port from `cms-core/src/globals/site-settings.ts` as-is -- Slug: `site-settings` -- Fields: `siteName: text required defaultValue "My App"`, `siteDescription: textarea` - -### Navigation - -`header` global: -- `logo: upload → media (optional)` -- `items: array { label: text required, href: text required, external: checkbox default false }` - ---- - -## File Structure - -**Create — `@repo/marketing-pages` package:** -- `packages/marketing-pages/{package.json,tsconfig.json,turbo.json,vitest.config.ts}` -- `packages/marketing-pages/src/index.ts` -- `packages/marketing-pages/src/entities/page.ts` + `.test.ts` -- `packages/marketing-pages/src/entities/site-settings.ts` -- `packages/marketing-pages/src/entities/errors.ts` -- `packages/marketing-pages/src/application/repositories/pages-repository.interface.ts` -- `packages/marketing-pages/src/application/repositories/site-settings-repository.interface.ts` -- `packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts` + `.test.ts` -- `packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.ts` + `.test.ts` -- `packages/marketing-pages/src/infrastructure/repositories/mock-pages.repository.ts` -- `packages/marketing-pages/src/infrastructure/repositories/payload-pages.repository.ts` -- `packages/marketing-pages/src/infrastructure/repositories/mock-site-settings.repository.ts` -- `packages/marketing-pages/src/infrastructure/repositories/payload-site-settings.repository.ts` -- `packages/marketing-pages/src/di/{symbols.ts,module.ts,container.ts,container.test.ts}` -- `packages/marketing-pages/src/interface-adapters/controllers/pages.controller.ts` + `.test.ts` -- `packages/marketing-pages/src/integrations/cms/collections/pages.ts` -- `packages/marketing-pages/src/integrations/cms/globals/site-settings.ts` -- `packages/marketing-pages/src/integrations/cms/index.ts` -- `packages/marketing-pages/src/integrations/api/router.ts` + `.test.ts` -- `packages/marketing-pages/src/ui/query.ts` -- `packages/marketing-pages/tests/page-by-slug.feature.test.ts` - -**Create — `@repo/navigation` package:** -- `packages/navigation/{package.json,tsconfig.json,turbo.json,vitest.config.ts}` -- `packages/navigation/src/index.ts` -- `packages/navigation/src/entities/header.ts` -- `packages/navigation/src/application/repositories/header-repository.interface.ts` -- `packages/navigation/src/application/use-cases/get-header.use-case.ts` + `.test.ts` -- `packages/navigation/src/infrastructure/repositories/mock-header.repository.ts` -- `packages/navigation/src/infrastructure/repositories/payload-header.repository.ts` -- `packages/navigation/src/di/{symbols.ts,module.ts,container.ts,container.test.ts}` -- `packages/navigation/src/interface-adapters/controllers/header.controller.ts` -- `packages/navigation/src/integrations/cms/globals/header.ts` -- `packages/navigation/src/integrations/cms/index.ts` -- `packages/navigation/src/integrations/api/router.ts` + `.test.ts` -- `packages/navigation/src/ui/query.ts` - -**Modify:** -- `packages/core-cms/src/payload.config.ts` — register `pages` collection + `siteSettings` and `header` globals; add `@repo/marketing-pages` and `@repo/navigation` deps -- `packages/core-cms/package.json` — add deps -- `packages/core-api/src/root.ts` — compose `marketingPages` and `navigation` routers -- `packages/core-api/package.json` — add deps -- `tsconfig.base.json` — add aliases for both new packages - ---- - -## Phase A: Marketing-pages feature - -### Task 4.1: Scaffold @repo/marketing-pages package - -**Files:** `packages/marketing-pages/{package.json,tsconfig.json,turbo.json,vitest.config.ts,src/index.ts}` + path aliases. - -- [ ] **Step 1: Create `packages/marketing-pages/package.json`** - -```json -{ - "name": "@repo/marketing-pages", - "private": true, - "version": "0.0.0", - "type": "module", - "exports": { - ".": "./src/index.ts", - "./cms": "./src/integrations/cms/index.ts", - "./api": "./src/integrations/api/router.ts" - }, - "scripts": { - "build": "tsc --noEmit", - "lint": "eslint .", - "test": "vitest run --passWithNoTests", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@repo/core-shared": "workspace:*", - "@trpc/server": "^11.0.0", - "inversify": "^6.2.0", - "payload": "^3.14.0", - "reflect-metadata": "^0.2.2", - "zod": "^3.24.0" - }, - "devDependencies": { - "@repo/core-eslint": "workspace:*", - "@repo/core-typescript": "workspace:*", - "@types/node": "^22.0.0", - "vitest": "^3.1.0" - } -} -``` - -- [ ] **Step 2: Create `packages/marketing-pages/tsconfig.json`** - -```json -{ - "extends": "@repo/core-typescript/base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": ".", - "lib": ["ES2022", "DOM"], - "jsx": "preserve", - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["src/**/*", "tests/**/*"], - "exclude": ["node_modules", "dist"] -} -``` - -- [ ] **Step 3: Create `packages/marketing-pages/turbo.json`** - -```json -{ - "extends": ["//"], - "tags": ["feature"] -} -``` - -- [ ] **Step 4: Create `packages/marketing-pages/vitest.config.ts`** - -```typescript -import path from "node:path"; -import { baseVitestConfig } from "@repo/core-typescript/vitest.base"; - -export default { - ...baseVitestConfig, - resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), - }, - }, -}; -``` - -- [ ] **Step 5: Create `packages/marketing-pages/src/index.ts`** - -```typescript -export {}; -``` - -- [ ] **Step 6: Add path aliases to `tsconfig.base.json`** - -Add to `compilerOptions.paths`: - -```json -"@repo/marketing-pages": ["packages/marketing-pages/src/index.ts"], -"@repo/marketing-pages/cms": ["packages/marketing-pages/src/integrations/cms/index.ts"], -"@repo/marketing-pages/api": ["packages/marketing-pages/src/integrations/api/router.ts"] -``` - -- [ ] **Step 7: Install + verify** - -Run: `pnpm install` -Verify: `pnpm list --recursive --depth=-1 | grep marketing-pages` - -- [ ] **Step 8: Commit** - -```bash -git add packages/marketing-pages tsconfig.base.json pnpm-lock.yaml -git commit -m "feat(marketing-pages): scaffold empty package with feature tag + path aliases" -``` - ---- - -### Task 4.2: Page + SiteSettings entities + tests - -- [ ] **Step 1: Write `packages/marketing-pages/src/entities/page.test.ts`** - -```typescript -import { describe, expect, it } from "vitest"; -import { pageSchema, pageStatusSchema } from "./page"; - -describe("pageSchema", () => { - it("accepts a minimal valid page", () => { - const result = pageSchema.parse({ - id: "p1", - title: "About", - slug: "about", - hero: { heading: "About us" }, - layout: [], - seo: { title: "About — My App" }, - createdAt: new Date(), - updatedAt: new Date(), - }); - expect(result.status).toBe("draft"); - expect(result.publishedAt).toBeNull(); - }); - - it("accepts a published page with publishedAt", () => { - const result = pageSchema.parse({ - id: "p1", - title: "About", - slug: "about", - hero: { heading: "About us" }, - layout: [], - status: "published", - publishedAt: new Date(), - seo: { title: "About" }, - createdAt: new Date(), - updatedAt: new Date(), - }); - expect(result.status).toBe("published"); - expect(result.publishedAt).toBeInstanceOf(Date); - }); - - it("rejects empty title", () => { - expect(() => - pageSchema.parse({ - id: "p1", - title: "", - slug: "about", - hero: { heading: "h" }, - layout: [], - seo: { title: "x" }, - createdAt: new Date(), - updatedAt: new Date(), - }), - ).toThrow(); - }); -}); - -describe("pageStatusSchema", () => { - it("accepts draft and published", () => { - expect(pageStatusSchema.parse("draft")).toBe("draft"); - expect(pageStatusSchema.parse("published")).toBe("published"); - }); -}); -``` - -- [ ] **Step 2: Run — expect failure** - -Run: `cd packages/marketing-pages && pnpm vitest run src/entities/page.test.ts` -Expected: FAIL — "Cannot find module './page'". - -- [ ] **Step 3: Implement `packages/marketing-pages/src/entities/page.ts`** - -```typescript -import { z } from "zod"; - -export const pageStatusSchema = z.enum(["draft", "published"]); - -export const heroSchema = z.object({ - heading: z.string().min(1).max(255), - subheading: z.string().optional(), - imageId: z.string().optional(), -}); - -export const pageSchema = z.object({ - id: z.string(), - title: z.string().min(1).max(255), - slug: z.string().min(1).max(255), - hero: heroSchema, - layout: z.array(z.unknown()), - status: pageStatusSchema.default("draft"), - publishedAt: z.date().nullable().default(null), - seo: z.object({ - title: z.string().min(1), - description: z.string().optional(), - }), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type Page = z.infer; -export type PageStatus = z.infer; -export type Hero = z.infer; -``` - -- [ ] **Step 4: Implement `packages/marketing-pages/src/entities/site-settings.ts`** - -```typescript -import { z } from "zod"; - -export const siteSettingsSchema = z.object({ - siteName: z.string().min(1), - siteDescription: z.string().optional(), -}); - -export type SiteSettings = z.infer; -``` - -- [ ] **Step 5: Implement `packages/marketing-pages/src/entities/errors.ts`** - -```typescript -export class PageNotFoundError extends Error { - constructor(message = "Page not found", options?: ErrorOptions) { - super(message, options); - } -} - -export class InputParseError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - } -} -``` - -- [ ] **Step 6: Run — expect 4 tests pass** - -Run: `cd packages/marketing-pages && pnpm vitest run src/entities` -Expected: PASS — 4 tests. - -- [ ] **Step 7: Commit** - -```bash -git add packages/marketing-pages/src/entities -git commit -m "feat(marketing-pages): add Page + SiteSettings entities + errors" -``` - ---- - -### Task 4.3: Repository interfaces (pages + site-settings) - -- [ ] **Step 1: Create `packages/marketing-pages/src/application/repositories/pages-repository.interface.ts`** - -```typescript -import type { Page } from "../../entities/page"; - -export interface IPagesRepository { - getPageBySlug(slug: string): Promise; - getPages(options?: { - status?: string; - limit?: number; - offset?: number; - }): Promise; -} -``` - -- [ ] **Step 2: Create `packages/marketing-pages/src/application/repositories/site-settings-repository.interface.ts`** - -```typescript -import type { SiteSettings } from "../../entities/site-settings"; - -export interface ISiteSettingsRepository { - getSiteSettings(): Promise; -} -``` - -- [ ] **Step 3: Verify compiles** - -Run: `cd packages/marketing-pages && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/marketing-pages/src/application -git commit -m "feat(marketing-pages): add IPagesRepository + ISiteSettingsRepository interfaces" -``` - ---- - -### Task 4.4: Use-cases + tests (RED until DI lands) - -- [ ] **Step 1: Write test `packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.test.ts`** - -```typescript -import { beforeEach, describe, expect, it } from "vitest"; -import { marketingPagesContainer } from "@/di/container"; -import { MARKETING_PAGES_SYMBOLS } from "@/di/symbols"; -import { MockPagesRepository } from "@/infrastructure/repositories/mock-pages.repository"; -import type { IPagesRepository } from "@/application/repositories/pages-repository.interface"; -import { getPageBySlugUseCase } from "./get-page-by-slug.use-case"; - -describe("getPageBySlugUseCase", () => { - let repo: MockPagesRepository; - - beforeEach(() => { - if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository)) { - marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository); - } - repo = new MockPagesRepository(); - marketingPagesContainer - .bind(MARKETING_PAGES_SYMBOLS.IPagesRepository) - .toConstantValue(repo); - }); - - it("returns the page when found", async () => { - const result = await getPageBySlugUseCase("about"); - expect(result?.slug).toBe("about"); - }); - - it("returns undefined when not found", async () => { - const result = await getPageBySlugUseCase("missing-page"); - expect(result).toBeUndefined(); - }); -}); -``` - -- [ ] **Step 2: Implement `packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts`** - -```typescript -import type { Page } from "../../entities/page"; -import { marketingPagesContainer } from "../../di/container"; -import { MARKETING_PAGES_SYMBOLS } from "../../di/symbols"; -import type { IPagesRepository } from "../repositories/pages-repository.interface"; - -export async function getPageBySlugUseCase( - slug: string, -): Promise { - const repo = marketingPagesContainer.get( - MARKETING_PAGES_SYMBOLS.IPagesRepository, - ); - return repo.getPageBySlug(slug); -} -``` - -- [ ] **Step 3: Commit (test RED)** - -```bash -git add packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.test.ts -git commit -m "feat(marketing-pages): add getPageBySlugUseCase (test red until DI lands)" -``` - -- [ ] **Step 4: Write test `packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.test.ts`** - -```typescript -import { beforeEach, describe, expect, it } from "vitest"; -import { marketingPagesContainer } from "@/di/container"; -import { MARKETING_PAGES_SYMBOLS } from "@/di/symbols"; -import { MockSiteSettingsRepository } from "@/infrastructure/repositories/mock-site-settings.repository"; -import type { ISiteSettingsRepository } from "@/application/repositories/site-settings-repository.interface"; -import { getSiteSettingsUseCase } from "./get-site-settings.use-case"; - -describe("getSiteSettingsUseCase", () => { - let repo: MockSiteSettingsRepository; - - beforeEach(() => { - if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)) { - marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository); - } - repo = new MockSiteSettingsRepository(); - marketingPagesContainer - .bind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository) - .toConstantValue(repo); - }); - - it("returns the seeded site settings", async () => { - const result = await getSiteSettingsUseCase(); - expect(result.siteName).toBe("My App"); - }); -}); -``` - -- [ ] **Step 5: Implement `packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.ts`** - -```typescript -import type { SiteSettings } from "../../entities/site-settings"; -import { marketingPagesContainer } from "../../di/container"; -import { MARKETING_PAGES_SYMBOLS } from "../../di/symbols"; -import type { ISiteSettingsRepository } from "../repositories/site-settings-repository.interface"; - -export async function getSiteSettingsUseCase(): Promise { - const repo = marketingPagesContainer.get( - MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository, - ); - return repo.getSiteSettings(); -} -``` - -- [ ] **Step 6: Commit (test RED)** - -```bash -git add packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.ts packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.test.ts -git commit -m "feat(marketing-pages): add getSiteSettingsUseCase (test red until DI lands)" -``` - ---- - -### Task 4.5: Mock repositories (pages + site-settings) - -- [ ] **Step 1: Create `packages/marketing-pages/src/infrastructure/repositories/mock-pages.repository.ts`** - -```typescript -import "reflect-metadata"; -import { injectable } from "inversify"; - -import type { IPagesRepository } from "../../application/repositories/pages-repository.interface"; -import type { Page } from "../../entities/page"; - -const SEED_DATE = new Date("2026-01-01T00:00:00.000Z"); - -@injectable() -export class MockPagesRepository implements IPagesRepository { - private _pages: Page[] = [ - { - id: "p1", - title: "About", - slug: "about", - hero: { heading: "About us" }, - layout: [], - status: "published", - publishedAt: SEED_DATE, - seo: { title: "About — My App" }, - createdAt: SEED_DATE, - updatedAt: SEED_DATE, - }, - ]; - - async getPageBySlug(slug: string): Promise { - return this._pages.find((p) => p.slug === slug); - } - - async getPages(options?: { - status?: string; - limit?: number; - offset?: number; - }): Promise { - let result = [...this._pages]; - if (options?.status) { - result = result.filter((p) => p.status === options.status); - } - const offset = options?.offset ?? 0; - const limit = options?.limit ?? 50; - return result.slice(offset, offset + limit); - } -} -``` - -- [ ] **Step 2: Create `packages/marketing-pages/src/infrastructure/repositories/mock-site-settings.repository.ts`** - -```typescript -import "reflect-metadata"; -import { injectable } from "inversify"; - -import type { ISiteSettingsRepository } from "../../application/repositories/site-settings-repository.interface"; -import type { SiteSettings } from "../../entities/site-settings"; - -@injectable() -export class MockSiteSettingsRepository implements ISiteSettingsRepository { - async getSiteSettings(): Promise { - return { - siteName: "My App", - siteDescription: "A vertical-feature monorepo template", - }; - } -} -``` - -- [ ] **Step 3: Verify compiles** - -Run: `cd packages/marketing-pages && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/marketing-pages/src/infrastructure/repositories -git commit -m "feat(marketing-pages): add Mock pages + site-settings repositories" -``` - ---- - -### Task 4.6: Per-feature DI container + tests - -- [ ] **Step 1: Write `packages/marketing-pages/src/di/container.test.ts`** - -```typescript -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { marketingPagesContainer } from "./container"; -import { MARKETING_PAGES_SYMBOLS } from "./symbols"; -import { MarketingPagesModule } from "./module"; -import { MockPagesRepository } from "@/infrastructure/repositories/mock-pages.repository"; -import { MockSiteSettingsRepository } from "@/infrastructure/repositories/mock-site-settings.repository"; -import type { IPagesRepository } from "@/application/repositories/pages-repository.interface"; -import type { ISiteSettingsRepository } from "@/application/repositories/site-settings-repository.interface"; - -describe("marketingPagesContainer", () => { - beforeEach(() => { - marketingPagesContainer.unbindAll(); - marketingPagesContainer.load(MarketingPagesModule); - }); - - afterEach(() => { - marketingPagesContainer.unbindAll(); - }); - - it("resolves IPagesRepository to MockPagesRepository", () => { - const repo = marketingPagesContainer.get( - MARKETING_PAGES_SYMBOLS.IPagesRepository, - ); - expect(repo).toBeInstanceOf(MockPagesRepository); - }); - - it("resolves ISiteSettingsRepository to MockSiteSettingsRepository", () => { - const repo = marketingPagesContainer.get( - MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository, - ); - expect(repo).toBeInstanceOf(MockSiteSettingsRepository); - }); -}); -``` - -- [ ] **Step 2: Implement `packages/marketing-pages/src/di/symbols.ts`** - -```typescript -export const MARKETING_PAGES_SYMBOLS = { - IPagesRepository: Symbol.for("marketing-pages:IPagesRepository"), - ISiteSettingsRepository: Symbol.for("marketing-pages:ISiteSettingsRepository"), -} as const; -``` - -- [ ] **Step 3: Implement `packages/marketing-pages/src/di/module.ts`** - -```typescript -import { ContainerModule, type interfaces } from "inversify"; - -import type { IPagesRepository } from "../application/repositories/pages-repository.interface"; -import type { ISiteSettingsRepository } from "../application/repositories/site-settings-repository.interface"; -import { MockPagesRepository } from "../infrastructure/repositories/mock-pages.repository"; -import { MockSiteSettingsRepository } from "../infrastructure/repositories/mock-site-settings.repository"; -import { MARKETING_PAGES_SYMBOLS } from "./symbols"; - -export const MarketingPagesModule = new ContainerModule( - (bind: interfaces.Bind) => { - bind(MARKETING_PAGES_SYMBOLS.IPagesRepository).to( - MockPagesRepository, - ); - bind( - MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository, - ).to(MockSiteSettingsRepository); - }, -); -``` - -- [ ] **Step 4: Implement `packages/marketing-pages/src/di/container.ts`** - -```typescript -import "reflect-metadata"; -import { Container } from "inversify"; -import { MarketingPagesModule } from "./module"; - -export const marketingPagesContainer = new Container({ - defaultScope: "Singleton", -}); -marketingPagesContainer.load(MarketingPagesModule); -``` - -- [ ] **Step 5: Run container test — expect 2 PASS** - -Run: `cd packages/marketing-pages && pnpm vitest run src/di/container.test.ts` -Expected: PASS — 2 tests. - -- [ ] **Step 6: Run previously-RED use-case tests** - -Run: `cd packages/marketing-pages && pnpm vitest run src/application/use-cases` -Expected: PASS — 3 tests (page-by-slug 2 + site-settings 1). - -- [ ] **Step 7: Commit** - -```bash -git add packages/marketing-pages/src/di -git commit -m "feat(marketing-pages): add per-feature InversifyJS container" -``` - ---- - -### Task 4.7: Payload-backed repositories - -Both repositories take `SanitizedConfig` via constructor (per Plan 2 lesson #4). - -- [ ] **Step 1: Implement `packages/marketing-pages/src/infrastructure/repositories/payload-pages.repository.ts`** - -```typescript -import "reflect-metadata"; -import { injectable } from "inversify"; -import { getPayload } from "payload"; -import type { SanitizedConfig } from "payload"; - -import type { IPagesRepository } from "../../application/repositories/pages-repository.interface"; -import type { Page } from "../../entities/page"; - -type PayloadPageDoc = { - id: string | number; - title?: string | null; - slug?: string | null; - hero?: - | { - heading?: string | null; - subheading?: string | null; - image?: string | number | { id: string | number } | null; - } - | null; - layout?: unknown[] | null; - status?: string | null; - publishedAt?: string | null; - seo?: { title?: string | null; description?: string | null } | null; - createdAt?: string | null; - updatedAt?: string | null; -}; - -function mapDoc(doc: PayloadPageDoc): Page { - const imageId = - doc.hero && typeof doc.hero.image === "object" && doc.hero.image !== null - ? String(doc.hero.image.id) - : doc.hero?.image != null - ? String(doc.hero.image) - : undefined; - - return { - id: String(doc.id), - title: doc.title ?? "", - slug: doc.slug ?? "", - hero: { - heading: doc.hero?.heading ?? "", - subheading: doc.hero?.subheading ?? undefined, - imageId, - }, - layout: doc.layout ?? [], - status: doc.status === "published" ? "published" : "draft", - publishedAt: doc.publishedAt ? new Date(doc.publishedAt) : null, - seo: { - title: doc.seo?.title ?? "", - description: doc.seo?.description ?? undefined, - }, - createdAt: doc.createdAt ? new Date(doc.createdAt) : new Date(0), - updatedAt: doc.updatedAt ? new Date(doc.updatedAt) : new Date(0), - }; -} - -@injectable() -export class PayloadPagesRepository implements IPagesRepository { - private config: SanitizedConfig; - - constructor(config: SanitizedConfig) { - this.config = config; - } - - async getPageBySlug(slug: string): Promise { - const payload = await getPayload({ config: this.config }); - const result = await payload.find({ - collection: "pages", - where: { slug: { equals: slug } }, - limit: 1, - overrideAccess: false, - }); - const doc = result.docs[0] as PayloadPageDoc | undefined; - return doc ? mapDoc(doc) : undefined; - } - - async getPages(options?: { - status?: string; - limit?: number; - offset?: number; - }): Promise { - const payload = await getPayload({ config: this.config }); - const where: Record = {}; - if (options?.status) where.status = { equals: options.status }; - const result = await payload.find({ - collection: "pages", - where: where as never, - limit: options?.limit ?? 50, - page: options?.offset - ? Math.floor(options.offset / (options.limit ?? 50)) + 1 - : 1, - overrideAccess: false, - }); - return result.docs.map((d) => mapDoc(d as PayloadPageDoc)); - } -} -``` - -- [ ] **Step 2: Implement `packages/marketing-pages/src/infrastructure/repositories/payload-site-settings.repository.ts`** - -```typescript -import "reflect-metadata"; -import { injectable } from "inversify"; -import { getPayload } from "payload"; -import type { SanitizedConfig } from "payload"; - -import type { ISiteSettingsRepository } from "../../application/repositories/site-settings-repository.interface"; -import type { SiteSettings } from "../../entities/site-settings"; - -type PayloadSiteSettings = { - siteName?: string | null; - siteDescription?: string | null; -}; - -@injectable() -export class PayloadSiteSettingsRepository implements ISiteSettingsRepository { - private config: SanitizedConfig; - - constructor(config: SanitizedConfig) { - this.config = config; - } - - async getSiteSettings(): Promise { - const payload = await getPayload({ config: this.config }); - const doc = (await payload.findGlobal({ - slug: "site-settings", - overrideAccess: false, - })) as PayloadSiteSettings; - return { - siteName: doc.siteName ?? "My App", - siteDescription: doc.siteDescription ?? undefined, - }; - } -} -``` - -- [ ] **Step 3: Verify compiles** - -Run: `cd packages/marketing-pages && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/marketing-pages/src/infrastructure/repositories -git commit -m "feat(marketing-pages): add Payload-backed pages + site-settings repos (constructor-injected config)" -``` - ---- - -### Task 4.8: Pages controller + tests - -- [ ] **Step 1: Write test `packages/marketing-pages/src/interface-adapters/controllers/pages.controller.test.ts`** - -```typescript -import { beforeEach, describe, expect, it } from "vitest"; -import { marketingPagesContainer } from "@/di/container"; -import { MARKETING_PAGES_SYMBOLS } from "@/di/symbols"; -import { MockPagesRepository } from "@/infrastructure/repositories/mock-pages.repository"; -import { MockSiteSettingsRepository } from "@/infrastructure/repositories/mock-site-settings.repository"; -import type { IPagesRepository } from "@/application/repositories/pages-repository.interface"; -import type { ISiteSettingsRepository } from "@/application/repositories/site-settings-repository.interface"; -import { InputParseError } from "@/entities/errors"; -import { - getPageBySlugController, - getSiteSettingsController, -} from "./pages.controller"; - -describe("pages controller", () => { - beforeEach(() => { - if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository)) { - marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository); - } - if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)) { - marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository); - } - marketingPagesContainer - .bind(MARKETING_PAGES_SYMBOLS.IPagesRepository) - .toConstantValue(new MockPagesRepository()); - marketingPagesContainer - .bind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository) - .toConstantValue(new MockSiteSettingsRepository()); - }); - - describe("getPageBySlugController", () => { - it("returns the page when found", async () => { - const result = await getPageBySlugController({ slug: "about" }); - expect(result?.slug).toBe("about"); - }); - - it("throws InputParseError on missing slug", async () => { - await expect( - getPageBySlugController({} as { slug: string }), - ).rejects.toBeInstanceOf(InputParseError); - }); - }); - - describe("getSiteSettingsController", () => { - it("returns site settings", async () => { - const result = await getSiteSettingsController(); - expect(result.siteName).toBe("My App"); - }); - }); -}); -``` - -- [ ] **Step 2: Implement `packages/marketing-pages/src/interface-adapters/controllers/pages.controller.ts`** - -```typescript -import { z } from "zod"; - -import { InputParseError } from "../../entities/errors"; -import type { Page } from "../../entities/page"; -import type { SiteSettings } from "../../entities/site-settings"; -import { getPageBySlugUseCase } from "../../application/use-cases/get-page-by-slug.use-case"; -import { getSiteSettingsUseCase } from "../../application/use-cases/get-site-settings.use-case"; - -const getBySlugInputSchema = z.object({ - slug: z.string().min(1), -}); - -export async function getPageBySlugController(input: { - slug: string; -}): Promise { - const parsed = getBySlugInputSchema.safeParse(input); - if (!parsed.success) { - throw new InputParseError("Invalid get-page-by-slug input", { - cause: parsed.error, - }); - } - return getPageBySlugUseCase(parsed.data.slug); -} - -export async function getSiteSettingsController(): Promise { - return getSiteSettingsUseCase(); -} -``` - -- [ ] **Step 3: Run — expect 3 PASS** - -Run: `cd packages/marketing-pages && pnpm vitest run src/interface-adapters` -Expected: PASS — 3 tests. - -- [ ] **Step 4: Commit** - -```bash -git add packages/marketing-pages/src/interface-adapters -git commit -m "feat(marketing-pages): add pages controller (getBySlug + getSiteSettings)" -``` - ---- - -### Task 4.9: Pages collection + SiteSettings global - -- [ ] **Step 1: Create `packages/marketing-pages/src/integrations/cms/collections/pages.ts`** - -```typescript -import type { CollectionConfig } from "payload"; -import { - cta, - seoFields, - slugifyIfMissing, -} from "@repo/core-shared/payload"; - -export const pages: CollectionConfig = { - slug: "pages", - admin: { - useAsTitle: "title", - defaultColumns: ["title", "status", "updatedAt"], - }, - hooks: { - beforeChange: [slugifyIfMissing], - }, - versions: { - drafts: true, - }, - fields: [ - { - name: "title", - type: "text", - required: true, - maxLength: 255, - }, - { - name: "slug", - type: "text", - unique: true, - admin: { - position: "sidebar", - description: "Auto-generated from title if left empty", - }, - }, - { - name: "hero", - type: "group", - fields: [ - { name: "heading", type: "text", required: true }, - { name: "subheading", type: "textarea" }, - { - name: "image", - type: "upload", - relationTo: "media", - }, - ], - }, - { - name: "layout", - type: "blocks", - blocks: [cta], - }, - { - name: "status", - type: "select", - options: [ - { label: "Draft", value: "draft" }, - { label: "Published", value: "published" }, - ], - defaultValue: "draft", - required: true, - admin: { position: "sidebar" }, - }, - { - name: "publishedAt", - type: "date", - admin: { - position: "sidebar", - date: { pickerAppearance: "dayAndTime" }, - }, - }, - seoFields, - ], -}; -``` - -- [ ] **Step 2: Create `packages/marketing-pages/src/integrations/cms/globals/site-settings.ts`** - -```typescript -import type { GlobalConfig } from "payload"; - -export const siteSettings: GlobalConfig = { - slug: "site-settings", - admin: { - group: "Settings", - }, - fields: [ - { - name: "siteName", - type: "text", - required: true, - defaultValue: "My App", - }, - { - name: "siteDescription", - type: "textarea", - }, - ], -}; -``` - -- [ ] **Step 3: Create `packages/marketing-pages/src/integrations/cms/index.ts`** - -```typescript -export { pages } from "./collections/pages"; -export { siteSettings } from "./globals/site-settings"; -``` - -- [ ] **Step 4: Verify compiles** - -Run: `cd packages/marketing-pages && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add packages/marketing-pages/src/integrations/cms -git commit -m "feat(marketing-pages): add pages collection + siteSettings global" -``` - ---- - -### Task 4.10: tRPC router + tests - -- [ ] **Step 1: Write test `packages/marketing-pages/src/integrations/api/router.test.ts`** - -```typescript -import { beforeEach, describe, expect, it } from "vitest"; -import { marketingPagesContainer } from "@/di/container"; -import { MARKETING_PAGES_SYMBOLS } from "@/di/symbols"; -import { MockPagesRepository } from "@/infrastructure/repositories/mock-pages.repository"; -import { MockSiteSettingsRepository } from "@/infrastructure/repositories/mock-site-settings.repository"; -import type { IPagesRepository } from "@/application/repositories/pages-repository.interface"; -import type { ISiteSettingsRepository } from "@/application/repositories/site-settings-repository.interface"; -import { marketingPagesRouter } from "./router"; - -describe("marketingPagesRouter", () => { - beforeEach(() => { - if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository)) { - marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository); - } - if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)) { - marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository); - } - marketingPagesContainer - .bind(MARKETING_PAGES_SYMBOLS.IPagesRepository) - .toConstantValue(new MockPagesRepository()); - marketingPagesContainer - .bind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository) - .toConstantValue(new MockSiteSettingsRepository()); - }); - - it("exposes pageBySlug + siteSettings procedures", () => { - const names = Object.keys(marketingPagesRouter._def.procedures); - expect(names).toContain("pageBySlug"); - expect(names).toContain("siteSettings"); - }); - - it("pageBySlug returns the seeded About page", async () => { - const caller = marketingPagesRouter.createCaller({}); - const result = await caller.pageBySlug({ slug: "about" }); - expect(result?.title).toBe("About"); - }); - - it("siteSettings returns site name", async () => { - const caller = marketingPagesRouter.createCaller({}); - const result = await caller.siteSettings(); - expect(result.siteName).toBe("My App"); - }); -}); -``` - -- [ ] **Step 2: Implement `packages/marketing-pages/src/integrations/api/router.ts`** - -```typescript -import { z } from "zod"; -import { router, publicProcedure } from "@repo/core-shared/trpc/init"; -import { - getPageBySlugController, - getSiteSettingsController, -} from "../../interface-adapters/controllers/pages.controller"; - -export const marketingPagesRouter = router({ - pageBySlug: publicProcedure - .input(z.object({ slug: z.string().min(1) })) - .query(({ input }) => getPageBySlugController(input)), - - siteSettings: publicProcedure.query(() => getSiteSettingsController()), -}); - -export type MarketingPagesRouter = typeof marketingPagesRouter; -``` - -- [ ] **Step 3: Run — expect 3 PASS** - -Run: `cd packages/marketing-pages && pnpm vitest run src/integrations/api/router.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 4: Commit** - -```bash -git add packages/marketing-pages/src/integrations/api -git commit -m "feat(marketing-pages): add tRPC router with pageBySlug + siteSettings" -``` - ---- - -### Task 4.11: ui/query + barrel + feature test - -- [ ] **Step 1: Create `packages/marketing-pages/src/ui/query.ts`** - -```typescript -type TrpcClient = { - marketingPages: { - pageBySlug: { queryOptions: (input: { slug: string }) => unknown }; - siteSettings: { queryOptions: () => unknown }; - }; -}; - -export function pageBySlugQuery(client: TrpcClient, slug: string) { - return client.marketingPages.pageBySlug.queryOptions({ slug }); -} - -export function siteSettingsQuery(client: TrpcClient) { - return client.marketingPages.siteSettings.queryOptions(); -} -``` - -- [ ] **Step 2: Replace `packages/marketing-pages/src/index.ts`** - -```typescript -export type { Page, PageStatus, Hero } from "./entities/page"; -export type { SiteSettings } from "./entities/site-settings"; -export { PageNotFoundError, InputParseError } from "./entities/errors"; -export { pageBySlugQuery, siteSettingsQuery } from "./ui/query"; -``` - -- [ ] **Step 3: Create feature test `packages/marketing-pages/tests/page-by-slug.feature.test.ts`** - -```typescript -import { beforeEach, describe, expect, it } from "vitest"; -import { marketingPagesContainer } from "../src/di/container"; -import { MARKETING_PAGES_SYMBOLS } from "../src/di/symbols"; -import { MockPagesRepository } from "../src/infrastructure/repositories/mock-pages.repository"; -import { MockSiteSettingsRepository } from "../src/infrastructure/repositories/mock-site-settings.repository"; -import type { IPagesRepository } from "../src/application/repositories/pages-repository.interface"; -import type { ISiteSettingsRepository } from "../src/application/repositories/site-settings-repository.interface"; -import { marketingPagesRouter } from "../src/integrations/api/router"; - -describe("marketing-pages feature: page-by-slug end-to-end", () => { - beforeEach(() => { - if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository)) { - marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository); - } - if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)) { - marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository); - } - marketingPagesContainer - .bind(MARKETING_PAGES_SYMBOLS.IPagesRepository) - .toConstantValue(new MockPagesRepository()); - marketingPagesContainer - .bind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository) - .toConstantValue(new MockSiteSettingsRepository()); - }); - - it("fetches a page by slug via tRPC and returns the domain entity", async () => { - const caller = marketingPagesRouter.createCaller({}); - const page = await caller.pageBySlug({ slug: "about" }); - expect(page?.title).toBe("About"); - expect(page?.hero.heading).toBe("About us"); - expect(page?.status).toBe("published"); - }); -}); -``` - -- [ ] **Step 4: Run all marketing-pages tests** - -Run: `cd packages/marketing-pages && pnpm test` -Expected: PASS — entities (4) + di (2) + use-cases (3) + controller (3) + router (3) + feature (1) = 16 tests. - -- [ ] **Step 5: Commit** - -```bash -git add packages/marketing-pages/src/ui packages/marketing-pages/src/index.ts packages/marketing-pages/tests -git commit -m "feat(marketing-pages): add ui/query + barrel + feature test" -``` - ---- - -## Phase B: Navigation feature - -### Task 4.12: Scaffold @repo/navigation package - -- [ ] **Step 1: Create `packages/navigation/package.json`** - -```json -{ - "name": "@repo/navigation", - "private": true, - "version": "0.0.0", - "type": "module", - "exports": { - ".": "./src/index.ts", - "./cms": "./src/integrations/cms/index.ts", - "./api": "./src/integrations/api/router.ts" - }, - "scripts": { - "build": "tsc --noEmit", - "lint": "eslint .", - "test": "vitest run --passWithNoTests", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@repo/core-shared": "workspace:*", - "@trpc/server": "^11.0.0", - "inversify": "^6.2.0", - "payload": "^3.14.0", - "reflect-metadata": "^0.2.2", - "zod": "^3.24.0" - }, - "devDependencies": { - "@repo/core-eslint": "workspace:*", - "@repo/core-typescript": "workspace:*", - "@types/node": "^22.0.0", - "vitest": "^3.1.0" - } -} -``` - -- [ ] **Step 2: Create `packages/navigation/tsconfig.json`** (same shape as marketing-pages tsconfig from Task 4.1 step 2) - -- [ ] **Step 3: Create `packages/navigation/turbo.json`** (same as Task 4.1 step 3) - -- [ ] **Step 4: Create `packages/navigation/vitest.config.ts`** (same as Task 4.1 step 4) - -- [ ] **Step 5: Create `packages/navigation/src/index.ts`** (`export {};`) - -- [ ] **Step 6: Add path aliases to `tsconfig.base.json`** - -```json -"@repo/navigation": ["packages/navigation/src/index.ts"], -"@repo/navigation/cms": ["packages/navigation/src/integrations/cms/index.ts"], -"@repo/navigation/api": ["packages/navigation/src/integrations/api/router.ts"] -``` - -- [ ] **Step 7: Install + verify** - -Run: `pnpm install` -Verify: `pnpm list --recursive --depth=-1 | grep navigation` - -- [ ] **Step 8: Commit** - -```bash -git add packages/navigation tsconfig.base.json pnpm-lock.yaml -git commit -m "feat(navigation): scaffold empty package with feature tag + path aliases" -``` - ---- - -### Task 4.13: Header entity, repo interface, use-case + tests, mock repo, payload repo, DI - -This task batches all the application + infrastructure + DI for navigation since it's small. - -- [ ] **Step 1: Create `packages/navigation/src/entities/header.ts`** - -```typescript -import { z } from "zod"; - -export const headerItemSchema = z.object({ - label: z.string().min(1).max(64), - href: z.string().min(1), - external: z.boolean().default(false), -}); - -export const headerSchema = z.object({ - logoId: z.string().optional(), - items: z.array(headerItemSchema), -}); - -export type Header = z.infer; -export type HeaderItem = z.infer; -``` - -- [ ] **Step 2: Create `packages/navigation/src/application/repositories/header-repository.interface.ts`** - -```typescript -import type { Header } from "../../entities/header"; - -export interface IHeaderRepository { - getHeader(): Promise
; -} -``` - -- [ ] **Step 3: Write test `packages/navigation/src/application/use-cases/get-header.use-case.test.ts`** - -```typescript -import { beforeEach, describe, expect, it } from "vitest"; -import { navigationContainer } from "@/di/container"; -import { NAVIGATION_SYMBOLS } from "@/di/symbols"; -import { MockHeaderRepository } from "@/infrastructure/repositories/mock-header.repository"; -import type { IHeaderRepository } from "@/application/repositories/header-repository.interface"; -import { getHeaderUseCase } from "./get-header.use-case"; - -describe("getHeaderUseCase", () => { - beforeEach(() => { - if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) { - navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository); - } - navigationContainer - .bind(NAVIGATION_SYMBOLS.IHeaderRepository) - .toConstantValue(new MockHeaderRepository()); - }); - - it("returns the seeded header items", async () => { - const result = await getHeaderUseCase(); - expect(result.items.length).toBeGreaterThan(0); - expect(result.items[0]?.label).toBe("Home"); - }); -}); -``` - -- [ ] **Step 4: Implement `packages/navigation/src/application/use-cases/get-header.use-case.ts`** - -```typescript -import type { Header } from "../../entities/header"; -import { navigationContainer } from "../../di/container"; -import { NAVIGATION_SYMBOLS } from "../../di/symbols"; -import type { IHeaderRepository } from "../repositories/header-repository.interface"; - -export async function getHeaderUseCase(): Promise
{ - const repo = navigationContainer.get( - NAVIGATION_SYMBOLS.IHeaderRepository, - ); - return repo.getHeader(); -} -``` - -- [ ] **Step 5: Implement `packages/navigation/src/infrastructure/repositories/mock-header.repository.ts`** - -```typescript -import "reflect-metadata"; -import { injectable } from "inversify"; - -import type { IHeaderRepository } from "../../application/repositories/header-repository.interface"; -import type { Header } from "../../entities/header"; - -@injectable() -export class MockHeaderRepository implements IHeaderRepository { - async getHeader(): Promise
{ - return { - items: [ - { label: "Home", href: "/", external: false }, - { label: "Blog", href: "/blog", external: false }, - { label: "About", href: "/about", external: false }, - ], - }; - } -} -``` - -- [ ] **Step 6: Implement `packages/navigation/src/infrastructure/repositories/payload-header.repository.ts`** - -```typescript -import "reflect-metadata"; -import { injectable } from "inversify"; -import { getPayload } from "payload"; -import type { SanitizedConfig } from "payload"; - -import type { IHeaderRepository } from "../../application/repositories/header-repository.interface"; -import type { Header, HeaderItem } from "../../entities/header"; - -type PayloadHeaderGlobal = { - logo?: string | number | { id: string | number } | null; - items?: Array<{ - label?: string | null; - href?: string | null; - external?: boolean | null; - }> | null; -}; - -@injectable() -export class PayloadHeaderRepository implements IHeaderRepository { - private config: SanitizedConfig; - - constructor(config: SanitizedConfig) { - this.config = config; - } - - async getHeader(): Promise
{ - const payload = await getPayload({ config: this.config }); - const doc = (await payload.findGlobal({ - slug: "header", - overrideAccess: false, - })) as PayloadHeaderGlobal; - - const logoId = - typeof doc.logo === "object" && doc.logo !== null - ? String(doc.logo.id) - : doc.logo != null - ? String(doc.logo) - : undefined; - - const items: HeaderItem[] = (doc.items ?? []).map((item) => ({ - label: item.label ?? "", - href: item.href ?? "", - external: item.external ?? false, - })); - - return { logoId, items }; - } -} -``` - -- [ ] **Step 7: Implement `packages/navigation/src/di/symbols.ts`** - -```typescript -export const NAVIGATION_SYMBOLS = { - IHeaderRepository: Symbol.for("navigation:IHeaderRepository"), -} as const; -``` - -- [ ] **Step 8: Implement `packages/navigation/src/di/module.ts`** - -```typescript -import { ContainerModule, type interfaces } from "inversify"; - -import type { IHeaderRepository } from "../application/repositories/header-repository.interface"; -import { MockHeaderRepository } from "../infrastructure/repositories/mock-header.repository"; -import { NAVIGATION_SYMBOLS } from "./symbols"; - -export const NavigationModule = new ContainerModule((bind: interfaces.Bind) => { - bind(NAVIGATION_SYMBOLS.IHeaderRepository).to( - MockHeaderRepository, - ); -}); -``` - -- [ ] **Step 9: Implement `packages/navigation/src/di/container.ts`** - -```typescript -import "reflect-metadata"; -import { Container } from "inversify"; -import { NavigationModule } from "./module"; - -export const navigationContainer = new Container({ defaultScope: "Singleton" }); -navigationContainer.load(NavigationModule); -``` - -- [ ] **Step 10: Write `packages/navigation/src/di/container.test.ts`** - -```typescript -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { navigationContainer } from "./container"; -import { NAVIGATION_SYMBOLS } from "./symbols"; -import { NavigationModule } from "./module"; -import { MockHeaderRepository } from "@/infrastructure/repositories/mock-header.repository"; -import type { IHeaderRepository } from "@/application/repositories/header-repository.interface"; - -describe("navigationContainer", () => { - beforeEach(() => { - navigationContainer.unbindAll(); - navigationContainer.load(NavigationModule); - }); - - afterEach(() => { - navigationContainer.unbindAll(); - }); - - it("resolves IHeaderRepository to MockHeaderRepository", () => { - const repo = navigationContainer.get( - NAVIGATION_SYMBOLS.IHeaderRepository, - ); - expect(repo).toBeInstanceOf(MockHeaderRepository); - }); -}); -``` - -- [ ] **Step 11: Run all tests** - -Run: `cd packages/navigation && pnpm test` -Expected: PASS — 2 tests (use-case 1 + container 1). - -- [ ] **Step 12: Commit** - -```bash -git add packages/navigation/src -git commit -m "feat(navigation): add Header entity + use-case + mock/payload repos + DI container" -``` - ---- - -### Task 4.14: Header controller + tRPC router + tests + CMS global + barrel - -- [ ] **Step 1: Create `packages/navigation/src/interface-adapters/controllers/header.controller.ts`** - -```typescript -import type { Header } from "../../entities/header"; -import { getHeaderUseCase } from "../../application/use-cases/get-header.use-case"; - -export async function getHeaderController(): Promise
{ - return getHeaderUseCase(); -} -``` - -> Note: No Zod input — `getHeader` takes no input. No InputParseError needed. - -- [ ] **Step 2: Create `packages/navigation/src/integrations/cms/globals/header.ts`** - -```typescript -import type { GlobalConfig } from "payload"; - -export const header: GlobalConfig = { - slug: "header", - admin: { - group: "Navigation", - }, - fields: [ - { - name: "logo", - type: "upload", - relationTo: "media", - }, - { - name: "items", - type: "array", - fields: [ - { name: "label", type: "text", required: true }, - { name: "href", type: "text", required: true }, - { name: "external", type: "checkbox", defaultValue: false }, - ], - }, - ], -}; -``` - -- [ ] **Step 3: Create `packages/navigation/src/integrations/cms/index.ts`** - -```typescript -export { header } from "./globals/header"; -``` - -- [ ] **Step 4: Write test `packages/navigation/src/integrations/api/router.test.ts`** - -```typescript -import { beforeEach, describe, expect, it } from "vitest"; -import { navigationContainer } from "@/di/container"; -import { NAVIGATION_SYMBOLS } from "@/di/symbols"; -import { MockHeaderRepository } from "@/infrastructure/repositories/mock-header.repository"; -import type { IHeaderRepository } from "@/application/repositories/header-repository.interface"; -import { navigationRouter } from "./router"; - -describe("navigationRouter", () => { - beforeEach(() => { - if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) { - navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository); - } - navigationContainer - .bind(NAVIGATION_SYMBOLS.IHeaderRepository) - .toConstantValue(new MockHeaderRepository()); - }); - - it("exposes header procedure", () => { - const names = Object.keys(navigationRouter._def.procedures); - expect(names).toContain("header"); - }); - - it("header returns 3 items", async () => { - const caller = navigationRouter.createCaller({}); - const result = await caller.header(); - expect(result.items).toHaveLength(3); - }); -}); -``` - -- [ ] **Step 5: Implement `packages/navigation/src/integrations/api/router.ts`** - -```typescript -import { router, publicProcedure } from "@repo/core-shared/trpc/init"; -import { getHeaderController } from "../../interface-adapters/controllers/header.controller"; - -export const navigationRouter = router({ - header: publicProcedure.query(() => getHeaderController()), -}); - -export type NavigationRouter = typeof navigationRouter; -``` - -- [ ] **Step 6: Create `packages/navigation/src/ui/query.ts`** - -```typescript -type TrpcClient = { - navigation: { - header: { queryOptions: () => unknown }; - }; -}; - -export function headerQuery(client: TrpcClient) { - return client.navigation.header.queryOptions(); -} -``` - -- [ ] **Step 7: Replace `packages/navigation/src/index.ts`** - -```typescript -export type { Header, HeaderItem } from "./entities/header"; -export { headerQuery } from "./ui/query"; -``` - -- [ ] **Step 8: Run all navigation tests** - -Run: `cd packages/navigation && pnpm test` -Expected: PASS — 4 tests (use-case 1 + container 1 + router 2). - -- [ ] **Step 9: Commit** - -```bash -git add packages/navigation/src -git commit -m "feat(navigation): add controller + tRPC router + header global + barrel" -``` - ---- - -## Phase C: Compose into core-cms + core-api - -### Task 4.15: Wire pages + globals into core-cms - -- [ ] **Step 1: Add `@repo/marketing-pages` and `@repo/navigation` to `packages/core-cms/package.json`** - -Add to `dependencies`: - -```json -"@repo/marketing-pages": "workspace:*", -"@repo/navigation": "workspace:*", -``` - -- [ ] **Step 2: Replace `packages/core-cms/src/payload.config.ts`** - -```typescript -import { buildConfig } from "payload"; -import { postgresAdapter } from "@payloadcms/db-postgres"; -import { lexicalEditor } from "@payloadcms/richtext-lexical"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { users } from "@repo/auth/cms"; -import { articles } from "@repo/blog/cms"; -import { media } from "@repo/media/cms"; -import { pages, siteSettings } from "@repo/marketing-pages/cms"; -import { header } from "@repo/navigation/cms"; - -const filename = fileURLToPath(import.meta.url); -const dirname = path.dirname(filename); - -export default buildConfig({ - editor: lexicalEditor(), - collections: [users, articles, pages, media], - globals: [siteSettings, header], - secret: process.env.PAYLOAD_SECRET || "default-secret-change-me", - db: postgresAdapter({ - pool: { - connectionString: - process.env.DATABASE_URL || - "postgresql://postgres:postgres@localhost:5432/template", - }, - }), - typescript: { - outputFile: path.resolve(dirname, "generated-types.ts"), - }, -}); -``` - -- [ ] **Step 3: Install + typecheck** - -Run: `pnpm install` then `pnpm typecheck --filter @repo/core-cms` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/core-cms pnpm-lock.yaml -git commit -m "feat(core-cms): compose pages + siteSettings + header into payload config" -``` - ---- - -### Task 4.16: Wire marketingPagesRouter + navigationRouter into core-api - -- [ ] **Step 1: Add to `packages/core-api/package.json` dependencies** - -```json -"@repo/marketing-pages": "workspace:*", -"@repo/navigation": "workspace:*", -``` - -- [ ] **Step 2: Replace `packages/core-api/src/root.ts`** - -```typescript -import { router } from "@repo/core-shared/trpc/init"; -import { authRouter } from "@repo/auth/api"; -import { blogRouter } from "@repo/blog/api"; -import { marketingPagesRouter } from "@repo/marketing-pages/api"; -import { navigationRouter } from "@repo/navigation/api"; - -export const appRouter = router({ - auth: authRouter, - blog: blogRouter, - marketingPages: marketingPagesRouter, - navigation: navigationRouter, -}); - -export type AppRouter = typeof appRouter; -``` - -- [ ] **Step 3: Install + typecheck** - -Run: `pnpm install` then `pnpm typecheck --filter @repo/core-api --filter @repo/marketing-pages --filter @repo/navigation` -Expected: ALL PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/core-api pnpm-lock.yaml -git commit -m "feat(core-api): compose marketingPages + navigation routers into appRouter" -``` - ---- - -### Task 4.17: Final verification + cms smoke test - -- [ ] **Step 1: Run all tests** - -Run: `pnpm test --filter @repo/marketing-pages --filter @repo/navigation --filter @repo/blog --filter @repo/auth --filter @repo/core-shared` -Expected: PASS. Marketing-pages: 16, Navigation: 4, Blog: 26, Auth: 24, Core-shared: 26 = 96 total. - -- [ ] **Step 2: Repo-wide typecheck** - -Run: `pnpm typecheck` -Expected: PASS for all packages except pre-existing `@repo/api` and `@repo/ui` failures. - -- [ ] **Step 3: CMS admin smoke test** - -Verify postgres: `docker ps | grep postgres`. If not running: `docker compose up -d postgres`. - -Start cms in background: -```bash -pnpm dev --filter @repo/cms & -DEV_PID=$! -sleep 15 -``` - -Test endpoints: -```bash -curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin -curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/collections/pages -curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/globals/site-settings -curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/globals/header -``` -Expected: ALL return 200. - -Kill: -```bash -kill $DEV_PID 2>/dev/null -pkill -f "next.*3001" 2>/dev/null -``` - -- [ ] **Step 4: Regenerate types** - -Run: `cd apps/cms && pnpm generate:types` - -Verify: -```bash -cd /Users/danijel/Documents/Projects/template-vertical/.worktrees/vertical-refactor -grep -E "Page|SiteSetting|Header" packages/core-cms/src/generated-types.ts | wc -l -``` -Expected: at least 5 occurrences. - -- [ ] **Step 5: Commit regenerated types if changed** - -```bash -git add packages/core-cms/src/generated-types.ts -git diff --cached --quiet || git commit -m "feat(core-cms): regenerate types — now includes Pages, SiteSettings, Header" -``` - ---- - -## Plan 4 Done Criteria - -- [ ] Marketing-pages tests pass (16) -- [ ] Navigation tests pass (4) -- [ ] All other features still pass (blog 26, auth 24) -- [ ] `apps/cms` admin serves `/admin/collections/pages`, `/admin/globals/site-settings`, `/admin/globals/header` — all 200 -- [ ] `pnpm generate:types` produces a `generated-types.ts` containing Pages, SiteSettings, Header -- [ ] `core-cms/src/payload.config.ts` registers `pages` collection + `siteSettings` and `header` globals -- [ ] `core-api/src/root.ts` exposes 4 namespaces: `auth`, `blog`, `marketingPages`, `navigation` - -**Next plan:** Plan 5 — App + UI integration. Wire `core-trpc` (client + per-framework providers), add tRPC route handlers in `apps/web-next` and `apps/web-tanstack`, build example pages (`/`, `/about`, `/blog/[slug]`) that consume the feature ui/query helpers. First plan that actually renders the features in a browser. diff --git a/docs/superpowers/plans/2026-05-04-plan-5-app-ui-integration.md b/docs/superpowers/plans/2026-05-04-plan-5-app-ui-integration.md deleted file mode 100644 index 4ae920d..0000000 --- a/docs/superpowers/plans/2026-05-04-plan-5-app-ui-integration.md +++ /dev/null @@ -1,1062 +0,0 @@ -# Vertical Refactor — Plan 5: App + UI Integration - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. - -**Goal:** Populate `@repo/core-trpc` with the React tRPC client + per-framework providers, expose tRPC route handlers in `apps/web-next` and `apps/web-tanstack`, and render real example pages that consume the migrated features end-to-end (home with navigation, `/about` marketing page, `/blog/[slug]` article detail). This is the first plan that produces browser-rendered output. Also adds the small but critical `bindProduction*(config)` helpers each feature exports so apps can swap mock repos for Payload-backed repos at boot. - -**Architecture:** -- `core-trpc` exposes a typed `trpc` React client (against `AppRouter` from `core-api`), a shared `getQueryClient`, and two provider components — one per app framework — that wire `` + ``. -- Each feature with a payload-backed repository exports a `bindProduction*(config)` helper from a new `./di/bind-production.ts` file. Apps call all of them once at server boot to swap mock implementations for real Payload-backed ones. Mock implementations remain the default for tests and for any environment without Payload. -- `apps/web-next/src/app/api/trpc/[trpc]/route.ts` imports `appRouter` from `core-api` and the bind-production helpers from features. Module-scoped initialization means rebindings happen exactly once per server process. RSC server components use the same per-feature DI containers, so the bound repos work for both SSR and client-side fetches via tRPC. -- `apps/web-tanstack` does the equivalent: a route module imports `core-trpc/tanstack` provider and points at the same `appRouter` URL (proxied to `web-next`'s tRPC handler in dev — both apps can serve their own handler in production). -- Example pages: `/` (renders nav header + site name + linked blog list), `/about` (renders the `about` marketing page), `/blog/[slug]` (renders an article). - -**Tech Stack:** Next.js 15.5 App Router (RSC), React 19, TanStack Start (TanStack Router 1.120), tRPC 11 React Query integration, `@trpc/tanstack-react-query`. - -**Plan position:** Plan 5 of 6. -- Plans 1-4 ✅ Foundation, Blog, Auth+Media, Marketing-pages+Navigation -- **Plan 5 (this doc):** App + UI integration -- Plan 6: Cleanup + boundary enforcement + Playwright + docs rewrite - -**Spec reference:** `docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md` - -**Lessons from earlier plans (apply throughout):** -1. vitest.config.ts has `resolve.alias` for `@/` -2. tsconfig.json has `"rootDir": "."` -3. Source files use relative imports; tests use `@/` -4. Payload-backed repos take `SanitizedConfig` via constructor -5. Apps use `transpilePackages` to allow Next.js to consume `.ts` source from workspace packages - ---- - -## Decisions taken in this plan - -- **`core-trpc` exports two providers**: `./next` for App Router (uses `'use client'`, `httpBatchLink` to `/api/trpc`) and `./tanstack` for TanStack Start (configurable URL since it points at `web-next`'s endpoint in dev). The framework-agnostic primitives (typed `trpc`, `getQueryClient`) live at the root export. -- **Production DI binding pattern: each feature exports `bindProduction*(config: SanitizedConfig)`** from `./di/bind-production.ts`. Pure function, idempotent, called once per server process. Apps build a `bindAllProduction(config)` thin wrapper that calls each. The wrapper lives in `apps/web-next/src/server/bind-production.ts` (server-only, never bundled into client code). -- **Example pages render via React Server Components (RSC)** for first paint, with one demo client component (`` placeholder kept simple — actual subscription wiring deferred). RSC fetches use `await caller.X()` directly; client components use `useQuery(trpc.X.queryOptions(...))`. -- **`apps/web-tanstack` deps move**: drop `@repo/api`/`@repo/api-client`/`@repo/ui`; add `@repo/core-api`/`@repo/core-trpc`/`@repo/core-ui` + the feature packages. (Same swap for `web-next`.) Old packages stay alive on disk (Plan 6 deletes them). -- **No real Payload integration test in this plan** — the smoke test is "GET /blog/[slug] returns 200 and HTML contains the seeded article title." Full e2e via Playwright lands in Plan 6. -- **Auth UI stays minimal** — no sign-in form yet; just expose `auth.signIn` etc. as tRPC procedures already done. Adding a real auth UI is a feature decision, not architecture work. - ---- - -## File Structure - -**Modify — `core-trpc/`** (currently empty): -- `packages/core-trpc/src/client.ts` — typed React tRPC client -- `packages/core-trpc/src/query-client.ts` — getQueryClient with SSR-safe singleton -- `packages/core-trpc/src/providers/next-provider.tsx` — `` -- `packages/core-trpc/src/providers/tanstack-provider.tsx` — `` -- `packages/core-trpc/src/index.ts` — barrel: `trpc`, `getQueryClient`, types -- `packages/core-trpc/package.json` — add `./next`, `./tanstack` exports -- `packages/core-trpc/tsconfig.json` — already correct from Plan 1 - -**Create — `bind-production.ts` in each payload-backed feature:** -- `packages/blog/src/di/bind-production.ts` -- `packages/auth/src/di/bind-production.ts` -- `packages/marketing-pages/src/di/bind-production.ts` -- `packages/navigation/src/di/bind-production.ts` -- (No media — media has no use-cases yet, just schema.) - -**Modify each feature's `package.json` exports** to add `./di/bind-production`: -- blog, auth, marketing-pages, navigation - -**Create — `apps/web-next/src/server/bind-production.ts`** — aggregates the per-feature binds. - -**Modify — `apps/web-next/`:** -- `package.json` — swap deps (`@repo/api`/`@repo/api-client`/`@repo/ui` → `@repo/core-*` + feature packages) -- `next.config.mjs` — update transpilePackages list -- `src/app/api/trpc/[trpc]/route.ts` — import appRouter from `@repo/core-api`, call `bindAllProduction(config)` once at module load -- `src/app/providers.tsx` — use `` from `@repo/core-trpc/next` -- `src/app/page.tsx` — render homepage with nav header + site name + blog list (RSC) -- `src/app/about/page.tsx` — render about marketing page (RSC) -- `src/app/blog/[slug]/page.tsx` — render article by slug (RSC + client component example) -- `src/app/layout.tsx` — small text — title from siteSettings (optional, kept simple) - -**Modify — `apps/web-tanstack/`:** -- `package.json` — swap deps -- `src/routes/__root.tsx` — use `` (URL pointing at web-next:3000/api/trpc in dev for shared backend; future per-app handler is a follow-up) -- `src/routes/index.tsx` — render homepage with nav (proves framework-agnostic features) -- `src/routes/blog/$slug.tsx` — article by slug - -**Do NOT touch in this plan:** -- `packages/api/`, `packages/api-client/`, `packages/ui/`, `packages/core/`, `packages/cms-core/`, `packages/cms-client/` — kept alive; deleted in Plan 6. -- `apps/cms/` — already wired to `core-cms` in Plan 1. -- Boundary enforcement / ESLint plugin — Plan 6. -- Playwright — Plan 6. - ---- - -## Phase A: Populate core-trpc - -### Task 5.1: core-trpc client + query-client + index barrel - -- [ ] **Step 1: Create `packages/core-trpc/src/client.ts`** - -```typescript -"use client"; - -import { createTRPCContext } from "@trpc/tanstack-react-query"; -import type { AppRouter } from "@repo/core-api"; - -export const { TRPCProvider, useTRPC } = createTRPCContext(); -``` - -> Note: `useTRPC` returns the typed proxy. Exported alongside `TRPCProvider` so consumers wire both in their app provider. - -- [ ] **Step 2: Create `packages/core-trpc/src/query-client.ts`** - -```typescript -import { QueryClient } from "@tanstack/react-query"; - -let clientQueryClient: QueryClient | undefined; - -const defaultOptions = { - queries: { - staleTime: 30 * 1000, - refetchOnWindowFocus: false, - }, -}; - -export function getQueryClient(): QueryClient { - if (typeof window === "undefined") { - // Server: always create a new instance per request - return new QueryClient({ defaultOptions }); - } - // Browser: singleton - if (!clientQueryClient) { - clientQueryClient = new QueryClient({ defaultOptions }); - } - return clientQueryClient; -} -``` - -- [ ] **Step 3: Replace `packages/core-trpc/src/index.ts`** - -```typescript -export { useTRPC, TRPCProvider } from "./client"; -export { getQueryClient } from "./query-client"; -export type { AppRouter } from "@repo/core-api"; -``` - -- [ ] **Step 4: Verify compiles** - -Run: `cd packages/core-trpc && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-trpc/src -git commit -m "feat(core-trpc): add typed React tRPC client + getQueryClient" -``` - ---- - -### Task 5.2: Per-framework providers + package.json exports - -- [ ] **Step 1: Create `packages/core-trpc/src/providers/next-provider.tsx`** - -```tsx -"use client"; - -import { useState } from "react"; -import { QueryClientProvider } from "@tanstack/react-query"; -import { createTRPCClient, httpBatchLink } from "@trpc/client"; -import superjson from "superjson"; -import type { AppRouter } from "@repo/core-api"; -import { TRPCProvider } from "../client"; -import { getQueryClient } from "../query-client"; - -export function NextTrpcProvider({ - children, - trpcUrl = "/api/trpc", -}: { - children: React.ReactNode; - trpcUrl?: string; -}) { - const [queryClient] = useState(() => getQueryClient()); - const [trpcClient] = useState(() => - createTRPCClient({ - links: [httpBatchLink({ url: trpcUrl, transformer: superjson })], - }), - ); - - return ( - - - {children} - - - ); -} -``` - -> Note: `transformer: superjson` is required to match the server's superjson transformer in `core-shared/src/trpc/init.ts`. Without it, complex types like `Date` won't deserialize correctly across the wire. - -- [ ] **Step 2: Create `packages/core-trpc/src/providers/tanstack-provider.tsx`** - -```tsx -"use client"; - -import { useState } from "react"; -import { QueryClientProvider } from "@tanstack/react-query"; -import { createTRPCClient, httpBatchLink } from "@trpc/client"; -import superjson from "superjson"; -import type { AppRouter } from "@repo/core-api"; -import { TRPCProvider } from "../client"; -import { getQueryClient } from "../query-client"; - -export function TanstackTrpcProvider({ - children, - trpcUrl, -}: { - children: React.ReactNode; - trpcUrl: string; -}) { - const [queryClient] = useState(() => getQueryClient()); - const [trpcClient] = useState(() => - createTRPCClient({ - links: [httpBatchLink({ url: trpcUrl, transformer: superjson })], - }), - ); - - return ( - - - {children} - - - ); -} -``` - -> Note: TanStack provider is essentially the same as Next provider — both wrap `` + ``. The two-file split is to keep the public exports clean (`@repo/core-trpc/next` vs `@repo/core-trpc/tanstack`) and to allow per-framework divergence later (e.g., Next-specific RSC integration). - -- [ ] **Step 3: Update `packages/core-trpc/package.json` exports** - -Replace the `exports` block: - -```json -"exports": { - ".": "./src/index.ts", - "./next": "./src/providers/next-provider.tsx", - "./tanstack": "./src/providers/tanstack-provider.tsx" -} -``` - -- [ ] **Step 4: Verify compiles** - -Run: `cd packages/core-trpc && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-trpc -git commit -m "feat(core-trpc): add Next.js + TanStack provider components" -``` - ---- - -## Phase B: Per-feature `bindProduction` helpers - -### Task 5.3: blog bindProduction helper - -- [ ] **Step 1: Create `packages/blog/src/di/bind-production.ts`** - -```typescript -import type { SanitizedConfig } from "payload"; -import { blogContainer } from "./container"; -import { BLOG_SYMBOLS } from "./symbols"; -import { PayloadArticlesRepository } from "../infrastructure/repositories/payload-articles.repository"; - -export function bindProductionBlog(config: SanitizedConfig): void { - if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { - blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); - } - blogContainer - .bind(BLOG_SYMBOLS.IArticlesRepository) - .toConstantValue(new PayloadArticlesRepository(config)); -} -``` - -- [ ] **Step 2: Add `./di/bind-production` to `packages/blog/package.json` exports** - -```json -"./di/bind-production": "./src/di/bind-production.ts" -``` - -(Add this line inside the existing `exports` block.) - -- [ ] **Step 3: Verify compiles** - -Run: `cd packages/blog && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/blog -git commit -m "feat(blog): add bindProductionBlog(config) DI helper for app boot" -``` - ---- - -### Task 5.4: auth bindProduction helper (NO production payload repo yet — keep mock) - -> Note: Auth uses `MockUsersRepository` and `MockAuthenticationService` even in production for now. There's no `PayloadUsersRepository` (Payload's auth collection works differently than the domain User entity per Plan 3 decisions). Auth's bind-production is therefore a NO-OP that just confirms the existing mock bindings are in place. This file exists for symmetry with other features and to give app boot a single bind interface. - -- [ ] **Step 1: Create `packages/auth/src/di/bind-production.ts`** - -```typescript -import type { SanitizedConfig as _SanitizedConfig } from "payload"; - -// Auth currently uses Mock repositories even in production: see Plan 3 -// decisions. This helper exists for API symmetry with other features and -// for forward-compatibility if a Payload-backed users repo is added later. -// -// Until then it's a no-op that intentionally accepts (and ignores) the -// SanitizedConfig argument so app-boot code can call it uniformly. -export function bindProductionAuth(_config: _SanitizedConfig): void { - // Default mock bindings from `module.ts` already loaded by container.ts; - // nothing to swap. -} -``` - -- [ ] **Step 2: Add export to `packages/auth/package.json`** - -```json -"./di/bind-production": "./src/di/bind-production.ts" -``` - -- [ ] **Step 3: Verify compiles** - -Run: `cd packages/auth && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/auth -git commit -m "feat(auth): add bindProductionAuth(config) helper (no-op pending payload-users repo)" -``` - ---- - -### Task 5.5: marketing-pages bindProduction helper - -- [ ] **Step 1: Create `packages/marketing-pages/src/di/bind-production.ts`** - -```typescript -import type { SanitizedConfig } from "payload"; -import { marketingPagesContainer } from "./container"; -import { MARKETING_PAGES_SYMBOLS } from "./symbols"; -import { PayloadPagesRepository } from "../infrastructure/repositories/payload-pages.repository"; -import { PayloadSiteSettingsRepository } from "../infrastructure/repositories/payload-site-settings.repository"; - -export function bindProductionMarketingPages(config: SanitizedConfig): void { - if ( - marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository) - ) { - marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository); - } - marketingPagesContainer - .bind(MARKETING_PAGES_SYMBOLS.IPagesRepository) - .toConstantValue(new PayloadPagesRepository(config)); - - if ( - marketingPagesContainer.isBound( - MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository, - ) - ) { - marketingPagesContainer.unbind( - MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository, - ); - } - marketingPagesContainer - .bind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository) - .toConstantValue(new PayloadSiteSettingsRepository(config)); -} -``` - -- [ ] **Step 2: Add to `packages/marketing-pages/package.json` exports** - -```json -"./di/bind-production": "./src/di/bind-production.ts" -``` - -- [ ] **Step 3: Verify compiles** - -Run: `cd packages/marketing-pages && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/marketing-pages -git commit -m "feat(marketing-pages): add bindProductionMarketingPages(config) DI helper" -``` - ---- - -### Task 5.6: navigation bindProduction helper - -- [ ] **Step 1: Create `packages/navigation/src/di/bind-production.ts`** - -```typescript -import type { SanitizedConfig } from "payload"; -import { navigationContainer } from "./container"; -import { NAVIGATION_SYMBOLS } from "./symbols"; -import { PayloadHeaderRepository } from "../infrastructure/repositories/payload-header.repository"; - -export function bindProductionNavigation(config: SanitizedConfig): void { - if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) { - navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository); - } - navigationContainer - .bind(NAVIGATION_SYMBOLS.IHeaderRepository) - .toConstantValue(new PayloadHeaderRepository(config)); -} -``` - -- [ ] **Step 2: Add to `packages/navigation/package.json` exports** - -```json -"./di/bind-production": "./src/di/bind-production.ts" -``` - -- [ ] **Step 3: Verify compiles** - -Run: `cd packages/navigation && pnpm typecheck` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/navigation -git commit -m "feat(navigation): add bindProductionNavigation(config) DI helper" -``` - ---- - -### Task 5.7: Run all feature tests to confirm bind-production additions don't break existing tests - -- [ ] **Step 1: Run all feature tests** - -Run: `pnpm test --filter @repo/blog --filter @repo/auth --filter @repo/marketing-pages --filter @repo/navigation --filter @repo/core-shared` -Expected: PASS — 96 total (blog 26, auth 24, marketing-pages 16, navigation 4, core-shared 26). - -No new commit needed (all bind-production files are additive; tests didn't import them). - ---- - -## Phase C: Wire apps/web-next - -### Task 5.8: Update apps/web-next package.json + next.config.mjs - -- [ ] **Step 1: Replace `apps/web-next/package.json`** - -```json -{ - "name": "@repo/web-next", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "build": "echo 'Next.js build requires full environment — use pnpm dev or docker'", - "dev": "next dev --port 3000", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@repo/auth": "workspace:*", - "@repo/blog": "workspace:*", - "@repo/core-api": "workspace:*", - "@repo/core-cms": "workspace:*", - "@repo/core-shared": "workspace:*", - "@repo/core-trpc": "workspace:*", - "@repo/core-ui": "workspace:*", - "@repo/marketing-pages": "workspace:*", - "@repo/media": "workspace:*", - "@repo/navigation": "workspace:*", - "@tanstack/react-query": "^5.66.0", - "next": "^15.3.0", - "payload": "^3.14.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "superjson": "^2.2.1" - }, - "devDependencies": { - "@repo/core-eslint": "workspace:*", - "@repo/core-typescript": "workspace:*", - "@types/node": "^22.0.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0" - } -} -``` - -> Note: `@repo/api`, `@repo/api-client`, `@repo/ui` removed. `@repo/core-cms` added because the route handler imports the assembled config to pass to bind-production helpers. `payload` added because the bind helpers reference `SanitizedConfig`. - -- [ ] **Step 2: Replace `apps/web-next/next.config.mjs`** - -```javascript -/** @type {import('next').NextConfig} */ -const nextConfig = { - transpilePackages: [ - "@repo/auth", - "@repo/blog", - "@repo/core-api", - "@repo/core-cms", - "@repo/core-shared", - "@repo/core-trpc", - "@repo/core-ui", - "@repo/marketing-pages", - "@repo/media", - "@repo/navigation", - ], -}; - -export default nextConfig; -``` - -- [ ] **Step 3: Install + verify** - -Run: `pnpm install` -Expected: completes without error; old `@repo/api` etc. still in workspace but no longer in web-next's node_modules. - -- [ ] **Step 4: Commit** - -```bash -git add apps/web-next/package.json apps/web-next/next.config.mjs pnpm-lock.yaml -git commit -m "build(web-next): swap deps to core-* + feature packages, transpile new workspaces" -``` - ---- - -### Task 5.9: web-next server-side bind-production aggregator - -- [ ] **Step 1: Create `apps/web-next/src/server/bind-production.ts`** - -```typescript -import "server-only"; -import config from "@repo/core-cms"; -import { bindProductionBlog } from "@repo/blog/di/bind-production"; -import { bindProductionAuth } from "@repo/auth/di/bind-production"; -import { bindProductionMarketingPages } from "@repo/marketing-pages/di/bind-production"; -import { bindProductionNavigation } from "@repo/navigation/di/bind-production"; - -let bound = false; - -export async function bindAllProduction(): Promise { - if (bound) return; - bound = true; - const resolvedConfig = await config; - bindProductionAuth(resolvedConfig); - bindProductionBlog(resolvedConfig); - bindProductionMarketingPages(resolvedConfig); - bindProductionNavigation(resolvedConfig); -} -``` - -> Notes: -> - `import 'server-only'` from Next.js fails the build if a client component imports this file. That's the safety net we want — Payload config can't be bundled into the browser. -> - The `config` default export from `@repo/core-cms` is a Promise (Payload's `buildConfig` returns one when there are async resolutions). We `await` it once. -> - `bound` flag makes `bindAllProduction()` idempotent. Multiple route handlers and RSC pages can call it; only the first invocation does work. - -- [ ] **Step 2: Add `server-only` to `apps/web-next/package.json` if not already present** - -Add to `dependencies`: -```json -"server-only": "^0.0.1" -``` - -(If `pnpm install` complains the version doesn't exist, omit it — `server-only` is a Next.js built-in marker often resolved via transitive deps. Try adding it explicitly first; remove if it causes lockfile issues.) - -- [ ] **Step 3: Install + verify** - -Run: `pnpm install` -Expected: completes. If `server-only` install fails, drop the import line and use a comment marker instead: -```typescript -// SERVER-ONLY: this module imports Payload config and must never be bundled into the browser. -``` - -- [ ] **Step 4: Verify typecheck** - -Run: `pnpm typecheck --filter @repo/web-next` -Expected: PASS (or report any errors — see "If anything fails" note in Report Format). - -- [ ] **Step 5: Commit** - -```bash -git add apps/web-next pnpm-lock.yaml -git commit -m "feat(web-next): add server bindAllProduction() aggregator with idempotent guard" -``` - ---- - -### Task 5.10: web-next tRPC route handler + providers - -- [ ] **Step 1: Replace `apps/web-next/src/app/api/trpc/[trpc]/route.ts`** - -```typescript -import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; -import { appRouter } from "@repo/core-api"; -import { bindAllProduction } from "../../../../server/bind-production"; - -const handler = async (req: Request) => { - await bindAllProduction(); - return fetchRequestHandler({ - endpoint: "/api/trpc", - req, - router: appRouter, - createContext: () => ({}), - }); -}; - -export { handler as GET, handler as POST }; -``` - -> Note: `bindAllProduction()` runs on every request but is internally idempotent — only the first call does work. This pattern works whether the Next.js dev server runs as a single long-lived process (typical) or as serverless lambdas (where each cold start re-binds, which is what we want). - -- [ ] **Step 2: Replace `apps/web-next/src/app/providers.tsx`** - -```tsx -"use client"; - -import { NextTrpcProvider } from "@repo/core-trpc/next"; - -export function Providers({ children }: { children: React.ReactNode }) { - return {children}; -} -``` - -- [ ] **Step 3: Verify typecheck** - -Run: `pnpm typecheck --filter @repo/web-next` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add apps/web-next/src/app -git commit -m "feat(web-next): wire tRPC route handler against core-api + new TrpcProvider" -``` - ---- - -### Task 5.11: web-next homepage (RSC) — nav + site name + blog list - -- [ ] **Step 1: Replace `apps/web-next/src/app/page.tsx`** - -```tsx -import Link from "next/link"; -import { appRouter } from "@repo/core-api"; -import { bindAllProduction } from "../server/bind-production"; - -export default async function Home() { - await bindAllProduction(); - const caller = appRouter.createCaller({}); - - const [siteSettings, header, articles] = await Promise.all([ - caller.marketingPages.siteSettings(), - caller.navigation.header(), - caller.blog.listArticles({ status: "published", limit: 20 }), - ]); - - return ( -
-
-

{siteSettings.siteName}

- {siteSettings.siteDescription ? ( -

{siteSettings.siteDescription}

- ) : null} - -
- -
-

Latest articles

- {articles.length === 0 ? ( -

No published articles yet.

- ) : ( -
    - {articles.map((a) => ( -
  • - {a.title} -
  • - ))} -
- )} -
-
- ); -} -``` - -> Note: This is a React Server Component. It calls `appRouter.createCaller({})` directly (bypassing HTTP) for SSR — the server has the same DI bindings, so use-cases resolve through Payload-backed repos. Could also use HTTP via `createTRPCClient` but the direct caller is faster and skips a network round-trip. - -- [ ] **Step 2: Verify typecheck** - -Run: `pnpm typecheck --filter @repo/web-next` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add apps/web-next/src/app/page.tsx -git commit -m "feat(web-next): render homepage with siteSettings + header + article list" -``` - ---- - -### Task 5.12: web-next /about page (RSC) - -- [ ] **Step 1: Create `apps/web-next/src/app/about/page.tsx`** - -```tsx -import { appRouter } from "@repo/core-api"; -import { bindAllProduction } from "../../server/bind-production"; - -export default async function AboutPage() { - await bindAllProduction(); - const caller = appRouter.createCaller({}); - const page = await caller.marketingPages.pageBySlug({ slug: "about" }); - - if (!page) { - return ( -
-

About

-

This page hasn’t been published yet.

-
- ); - } - - return ( -
-
-
-

{page.hero.heading}

- {page.hero.subheading ?

{page.hero.subheading}

: null} -
-
-          {JSON.stringify(page.layout, null, 2)}
-        
-
-
- ); -} -``` - -> Note: rich-text/blocks rendering is intentionally simplified to a JSON dump — building a proper Lexical/blocks renderer is feature work, not architecture work. Plan 6 won't add it either; that's future enhancement. - -- [ ] **Step 2: Verify typecheck** - -Run: `pnpm typecheck --filter @repo/web-next` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add apps/web-next/src/app/about -git commit -m "feat(web-next): render /about marketing page via marketingPages.pageBySlug" -``` - ---- - -### Task 5.13: web-next /blog/[slug] page with hybrid RSC + client component - -- [ ] **Step 1: Create `apps/web-next/src/app/blog/[slug]/page.tsx`** - -```tsx -import { notFound } from "next/navigation"; -import { appRouter } from "@repo/core-api"; -import { bindAllProduction } from "../../../server/bind-production"; - -type PageProps = { - params: Promise<{ slug: string }>; -}; - -export default async function BlogPostPage({ params }: PageProps) { - await bindAllProduction(); - const { slug } = await params; - const caller = appRouter.createCaller({}); - const article = await caller.blog.articleBySlug({ slug }); - - if (!article) notFound(); - - return ( -
-
-
-

{article.title}

- {article.publishedAt ? ( - - ) : null} -
-
-          {JSON.stringify(article.content, null, 2)}
-        
-
-
- ); -} -``` - -- [ ] **Step 2: Verify typecheck** - -Run: `pnpm typecheck --filter @repo/web-next` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add apps/web-next/src/app/blog -git commit -m "feat(web-next): render /blog/[slug] article detail via blog.articleBySlug" -``` - ---- - -### Task 5.14: web-next dev server smoke test - -- [ ] **Step 1: Ensure Postgres running** - -Run: `docker ps | grep postgres`. If empty: `docker compose up -d postgres`. - -- [ ] **Step 2: Ensure apps/web-next has an .env (or `.env.local`)** - -Check: `ls apps/web-next/.env*`. If none, copy: `cp /Users/danijel/Documents/Projects/template-vertical/apps/cms/.env apps/web-next/.env` (the same DATABASE_URL works since both connect to the shared Postgres). - -- [ ] **Step 3: Boot dev server in background** - -```bash -pnpm dev --filter @repo/web-next & -DEV_PID=$! -sleep 20 -``` - -- [ ] **Step 4: Smoke-test endpoints** - -```bash -curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3000/ -curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3000/about -``` - -Expected: both return 200. - -For `/blog/[slug]`, we don't have a seeded article in Payload yet — the page will return 404 (notFound). That's correct behavior. Verify: - -```bash -curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3000/blog/anything -``` - -Expected: 404 (not 500). That confirms the route renders without crashing. - -- [ ] **Step 5: Optional — verify body content** - -```bash -curl -sf http://localhost:3000/ | grep -o "My App" -``` - -Expected: prints "My App" (the seeded `siteName` from `MockSiteSettingsRepository` OR the actual Payload-stored value if one exists). - -- [ ] **Step 6: Stop dev server** - -```bash -kill $DEV_PID 2>/dev/null -pkill -f "next.*3000" 2>/dev/null -``` - -- [ ] **Step 7: No commit (smoke test only)** - ---- - -## Phase D: Wire apps/web-tanstack (parallel proof) - -### Task 5.15: Update apps/web-tanstack package.json + provider - -- [ ] **Step 1: Replace `apps/web-tanstack/package.json`** - -```json -{ - "name": "@repo/web-tanstack", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "build": "echo 'placeholder — TanStack Start build configured in later plan'", - "dev": "echo 'placeholder'", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@repo/blog": "workspace:*", - "@repo/core-api": "workspace:*", - "@repo/core-trpc": "workspace:*", - "@repo/core-ui": "workspace:*", - "@repo/marketing-pages": "workspace:*", - "@repo/navigation": "workspace:*", - "@tanstack/react-query": "^5.66.0", - "@tanstack/react-router": "^1.120.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" - }, - "devDependencies": { - "@repo/core-eslint": "workspace:*", - "@repo/core-typescript": "workspace:*", - "@types/node": "^22.0.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0" - } -} -``` - -> Note: TanStack-side does NOT include `@repo/core-cms` or `payload` — it doesn't bind production repos itself. It points its tRPC client at web-next's `/api/trpc` endpoint, which has already done the binding server-side. - -- [ ] **Step 2: Replace `apps/web-tanstack/src/routes/__root.tsx`** - -```tsx -import { Outlet, createRootRoute } from "@tanstack/react-router"; -import { TanstackTrpcProvider } from "@repo/core-trpc/tanstack"; - -export const Route = createRootRoute({ - component: () => ( - - - - ), -}); -``` - -- [ ] **Step 3: Install + typecheck** - -Run: `pnpm install` then `pnpm typecheck --filter @repo/web-tanstack` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add apps/web-tanstack pnpm-lock.yaml -git commit -m "build(web-tanstack): swap deps + use TanstackTrpcProvider against shared backend" -``` - ---- - -### Task 5.16: web-tanstack example route consuming features - -- [ ] **Step 1: Replace `apps/web-tanstack/src/routes/index.tsx`** - -```tsx -import { createFileRoute } from "@tanstack/react-router"; -import { useQuery } from "@tanstack/react-query"; -import { useTRPC } from "@repo/core-trpc"; - -export const Route = createFileRoute("/")({ - component: Home, -}); - -function Home() { - const trpc = useTRPC(); - const siteSettings = useQuery(trpc.marketingPages.siteSettings.queryOptions()); - const header = useQuery(trpc.navigation.header.queryOptions()); - - if (siteSettings.isPending || header.isPending) { - return
Loading…
; - } - if (siteSettings.error || header.error) { - return ( -
- Failed to load: {siteSettings.error?.message ?? header.error?.message} -
- ); - } - - return ( -
-
-

{siteSettings.data?.siteName} — TanStack edition

- -
-

This page is rendered by TanStack Router and consumes the same feature packages as the Next.js app.

-
- ); -} -``` - -- [ ] **Step 2: Verify typecheck** - -Run: `pnpm typecheck --filter @repo/web-tanstack` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add apps/web-tanstack/src/routes/index.tsx -git commit -m "feat(web-tanstack): consume marketingPages.siteSettings + navigation.header via tRPC client" -``` - ---- - -## Phase E: Final verification - -### Task 5.17: Repo-wide test + typecheck + smoke - -- [ ] **Step 1: Run all feature tests** - -Run: `pnpm test --filter @repo/blog --filter @repo/auth --filter @repo/marketing-pages --filter @repo/navigation --filter @repo/core-shared` -Expected: PASS — 96 tests total (no new tests in Plan 5 — apps don't have unit tests). - -- [ ] **Step 2: Repo-wide typecheck** - -Run: `pnpm typecheck` -Expected: PASS for all packages we touched (core-trpc, all 5 features, web-next, web-tanstack, cms, core-cms, etc.). Pre-existing failures remain in `@repo/api` and `@repo/ui`. - -- [ ] **Step 3: web-next dev smoke test (final)** - -Postgres should still be running. - -```bash -pnpm dev --filter @repo/web-next & -DEV_PID=$! -sleep 20 -curl -sf -o /dev/null -w "GET / -> %{http_code}\n" http://localhost:3000/ -curl -sf -o /dev/null -w "GET /about -> %{http_code}\n" http://localhost:3000/about -curl -sf -o /dev/null -w "GET /blog/anything -> %{http_code}\n" http://localhost:3000/blog/anything -kill $DEV_PID 2>/dev/null -pkill -f "next.*3000" 2>/dev/null -``` - -Expected: `/` and `/about` return 200; `/blog/anything` returns 404. - -- [ ] **Step 4: No commit (final verification)** - ---- - -## Plan 5 Done Criteria - -- [ ] All 96 feature tests still pass -- [ ] `core-trpc` has `useTRPC`, `getQueryClient`, `NextTrpcProvider`, `TanstackTrpcProvider` -- [ ] All 4 payload-backed features export `bindProduction*(config)` -- [ ] `apps/web-next` no longer depends on `@repo/api`/`@repo/api-client`/`@repo/ui`; uses `core-*` + features instead -- [ ] `apps/web-next` dev server boots; `/` returns 200 with rendered nav + site name + blog list; `/about` returns 200; `/blog/[slug]` returns 200 (with article) or 404 (without) -- [ ] `apps/web-tanstack` typechecks against new deps; renders `/` (running TanStack dev separately is deferred to Plan 6 since TanStack Start integration is more involved — for now we just typecheck) - -**Next plan:** Plan 6 — Cleanup + boundary enforcement + Playwright + docs rewrite. Deletes the old `@repo/core`, `@repo/api`, `@repo/api-client`, `@repo/cms-core`, `@repo/cms-client`, `@repo/ui` packages. Adds `eslint-plugin-boundaries` configuration enforcing the three-tag boundary model. Sets up Playwright in both apps with initial smoke specs. Rewrites root + per-package AGENTS.md, adds new ADRs, updates the adding-a-feature guide. diff --git a/docs/superpowers/plans/2026-05-04-plan-6-cleanup-enforcement-e2e-docs.md b/docs/superpowers/plans/2026-05-04-plan-6-cleanup-enforcement-e2e-docs.md deleted file mode 100644 index fc4cec1..0000000 --- a/docs/superpowers/plans/2026-05-04-plan-6-cleanup-enforcement-e2e-docs.md +++ /dev/null @@ -1,1095 +0,0 @@ -# Vertical Refactor — Plan 6: Cleanup + Enforcement + E2E + Docs - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. - -**Goal:** Land the vertical-refactor branch in a fully clean, enforced, tested, and documented state. Delete the six legacy packages; migrate `apps/storybook` from `@repo/ui` to `@repo/core-ui`; install `eslint-plugin-boundaries` with rules matching the spec's three-tag model; set up Playwright in both apps with smoke specs; rewrite the doc tree (AGENTS.md, ADRs, guides). End state: PR-ready branch where every package, app, lint pass, and test suite tells the same coherent vertical-feature story. - -**Architecture:** Five sequential phases. Each phase ends green so a reviewer can land Plan 6 in chunks if desired. Phase A removes legacy packages (drops their `apps/cms` and `apps/storybook` references first, then deletes). Phase B is `eslint-plugin-boundaries` configuration. Phase C is Playwright. Phase D is full doc rewrite. Phase E is final repo-wide green check. - -**Tech Stack:** `eslint-plugin-boundaries@^4`, `@playwright/test@^1.50`, all existing tooling. - -**Plan position:** Plan 6 of 6 — final. -- Plans 1-5 ✅ Foundation, Blog, Auth+Media, Marketing-pages+Navigation, App+UI integration -- **Plan 6 (this doc):** Cleanup + enforcement + e2e + docs - -**Spec reference:** `docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md` - ---- - -## Decisions taken in this plan - -- **Delete six legacy packages:** `packages/api`, `packages/api-client`, `packages/cms-client`, `packages/cms-core`, `packages/core`, `packages/ui`. Their callers (apps/cms, apps/storybook) get repointed first. -- **`eslint-plugin-boundaries` v4** chosen — the v5 series has breaking config-file changes; v4 is stable, ESLint-9 compatible, well-documented. Configured in `packages/eslint-config` so all packages inherit. -- **Playwright `chromium` only** in initial setup — Firefox/WebKit deferred. Each app gets its own `playwright.config.ts` and starts its own `webServer` (web-next on 3000, web-tanstack pointed at 3000 since it shares the backend in dev). -- **Storybook stays alive** but switches to `@repo/core-ui`. If `packages/ui/src/atoms/*` had stories in Plan 1's snapshot, they're already in `core-ui` after Plan 1's `core-ui` migration — but Plan 1 actually only scaffolded `core-ui` empty; the real `ui` content was never migrated. **This plan does that migration in Phase A** as a prerequisite for deleting `packages/ui`. -- **Doc rewrite scope:** rewrite root `AGENTS.md` + `CLAUDE.md`, all per-package `AGENTS.md`, `docs/architecture/{overview,dependency-flow}.md`, `docs/guides/{adding-a-feature,testing-strategy}.md`. Add 4 new ADRs and update/supersede 2 existing ones. Delete the six stale 2026-04-06 plan docs (they describe the old architecture). -- **No CLAUDE.md changes outside the worktree** — the user's main repo has its own .git/.claude state; we only touch the in-worktree CLAUDE.md (project-level instructions). - ---- - -## File Structure - -**Delete (Phase A):** -- `packages/api/` (entire directory) -- `packages/api-client/` -- `packages/cms-client/` -- `packages/cms-core/` -- `packages/core/` -- `packages/ui/` -- `docs/superpowers/plans/2026-04-06-plan-{1..6}-*.md` (6 stale plan files) -- All per-package `AGENTS.md` files inside the deleted packages (handled by directory deletion) - -**Modify (Phase A — pre-delete repointing):** -- `packages/core-ui/src/` — populate with content migrated from `packages/ui/src/` (atoms, molecules, lib/utils + stories) -- `packages/core-ui/package.json` — add storybook-related devDeps if any are needed -- `apps/storybook/package.json` — swap `@repo/ui` → `@repo/core-ui` -- `apps/storybook/.storybook/main.ts` (if it exists) — update component path -- `apps/cms/package.json` — drop `@repo/cms-core` dep (no longer used) - -**Create/modify (Phase B):** -- `packages/eslint-config/index.js` (or `index.mjs`) — add `eslint-plugin-boundaries` with rules -- `packages/eslint-config/package.json` — add `eslint-plugin-boundaries` dep -- Each package/app `eslint.config.js|mjs` — verify it inherits properly (most should already) - -**Create (Phase C):** -- `apps/web-next/playwright.config.ts` -- `apps/web-next/e2e/home.spec.ts` -- `apps/web-next/e2e/blog-post.spec.ts` -- `apps/web-next/e2e/marketing-page.spec.ts` -- `apps/web-next/package.json` — add `@playwright/test` devDep + `test:e2e` script -- `apps/web-tanstack/playwright.config.ts` -- `apps/web-tanstack/e2e/home.spec.ts` -- `apps/web-tanstack/package.json` — add `@playwright/test` devDep + `test:e2e` script -- Root `package.json` — add `test:e2e` script that runs Turbo task -- Root `turbo.json` — add `test:e2e` task - -**Create/rewrite (Phase D):** -- `docs/architecture/vertical-feature-spec.md` — copy of source spec -- `docs/architecture/overview.md` — full rewrite -- `docs/architecture/dependency-flow.md` — full rewrite -- `docs/guides/adding-a-feature.md` — full rewrite -- `docs/guides/testing-strategy.md` — full rewrite -- `docs/decisions/adr-002-di-framework.md` — append note about per-feature containers -- `docs/decisions/adr-003-cms-separation.md` — mark v1 superseded; write v2 (or replace inline) -- `docs/decisions/adr-004-dual-mode-client.md` — mark superseded -- `docs/decisions/adr-005-atomic-design.md` — append scope note (applies to core-ui only) -- `docs/decisions/adr-006-vertical-feature-packages.md` — NEW -- `docs/decisions/adr-007-drop-cms-client-wrapper.md` — NEW -- `docs/decisions/adr-008-per-feature-di-containers.md` — NEW -- `docs/decisions/adr-009-integrations-folder-naming.md` — NEW -- Root `AGENTS.md` — full rewrite -- Root `CLAUDE.md` — update Read First + add boundary note -- `apps/cms/AGENTS.md` — rewrite (replace cms-core references with core-cms) -- `apps/web-next/AGENTS.md` — NEW (or rewrite if exists) -- `apps/web-tanstack/AGENTS.md` — NEW -- `apps/storybook/AGENTS.md` — update (replace ui with core-ui) -- `packages//AGENTS.md` — one per remaining package (12 total: 5 core-* + 5 features + 2 tooling) - -**Delete:** -- `docs/superpowers/plans/2026-04-06-plan-{1,2,3,4,5,6}-*.md` (6 stale plans) - ---- - -## Phase A: Cleanup — populate core-ui, repoint apps, delete legacy packages - -### Task 6.1: Migrate `packages/ui/src/` content into `packages/core-ui/src/` - -**Files:** -- Read: `packages/ui/src/**/*` (currently has atoms/, molecules/, templates/, lib/utils.ts and stories) -- Write: copy into `packages/core-ui/src/` matching the same structure -- Modify: `packages/core-ui/package.json` — add any devDeps needed (likely none — `@types/react`, `react`, `clsx`, `tailwind-merge` already present) - -- [ ] **Step 1: List what's in packages/ui/src** - -Run: `find packages/ui/src -type f -name "*.ts" -o -name "*.tsx" | head -30` - -- [ ] **Step 2: Copy entire src tree from ui to core-ui** - -```bash -cp -R packages/ui/src/. packages/core-ui/src/ -``` - -This preserves the existing `packages/core-ui/src/index.ts` (will overwrite if there's a name collision; verify with the diff before committing). After copy, `packages/core-ui/src/` should contain `atoms/`, `molecules/`, `templates/` (if they exist in ui), `lib/utils.ts`, and the index.ts (overwritten with ui's barrel). - -- [ ] **Step 3: Verify the new core-ui index.ts barrel exports something meaningful** - -Run: `cat packages/core-ui/src/index.ts` - -If it's still the empty `export {};`, replace with the equivalent of `packages/ui/src/index.ts` (whatever that exports). The intent: `import { Button } from '@repo/core-ui'` should work from the storybook app. - -- [ ] **Step 4: Verify core-ui typechecks** - -Run: `pnpm typecheck --filter @repo/core-ui` -Expected: PASS. If not, the issue is likely missing devDeps like Storybook types — for components, that's fine (those types only matter in `*.stories.tsx`). If stories cause errors, see Step 5. - -- [ ] **Step 5: If `*.stories.tsx` cause typecheck errors due to missing `@storybook/react`** - -Storybook's React types come from the storybook app's deps, not the ui library's. To exclude stories from core-ui's typecheck, modify `packages/core-ui/tsconfig.json`: - -```json -{ - "extends": "@repo/core-typescript/base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": ".", - "lib": ["ES2022", "DOM"], - "jsx": "preserve" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.stories.tsx", "**/*.stories.ts"] -} -``` - -- [ ] **Step 6: Verify core-ui typechecks again** - -Run: `pnpm typecheck --filter @repo/core-ui` -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add packages/core-ui -git commit -m "feat(core-ui): migrate atoms/molecules/templates from packages/ui" -``` - ---- - -### Task 6.2: Repoint `apps/storybook` from `@repo/ui` to `@repo/core-ui` - -- [ ] **Step 1: Update `apps/storybook/package.json`** - -Change `"@repo/ui": "workspace:*"` to `"@repo/core-ui": "workspace:*"`. - -- [ ] **Step 2: Find and update import references in storybook config + stories** - -```bash -grep -rln "@repo/ui" apps/storybook -``` - -For each file: replace `@repo/ui` with `@repo/core-ui`. Common files: `apps/storybook/.storybook/main.ts` (story path globs), `apps/storybook/preview.ts` if exists. - -- [ ] **Step 3: Install + verify storybook can boot** - -Run: `pnpm install` -Run: `pnpm typecheck --filter @repo/storybook` -Expected: PASS. - -> Optional: try `pnpm dev --filter @repo/storybook` in background; verify port 6006 responds. Skip if storybook setup is fragile and would require additional config beyond the repoint. - -- [ ] **Step 4: Commit** - -```bash -git add apps/storybook pnpm-lock.yaml -git commit -m "build(storybook): migrate from @repo/ui to @repo/core-ui" -``` - ---- - -### Task 6.3: Drop `@repo/cms-core` dep from `apps/cms/package.json` - -- [ ] **Step 1: Edit `apps/cms/package.json`** - -Remove the line `"@repo/cms-core": "workspace:*",` from `dependencies`. Keep `@repo/core-cms` (added in Plan 1). - -- [ ] **Step 2: Install + verify** - -Run: `pnpm install` -Run: `pnpm typecheck --filter @repo/cms` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add apps/cms/package.json pnpm-lock.yaml -git commit -m "build(cms): drop legacy @repo/cms-core dep (now uses @repo/core-cms only)" -``` - ---- - -### Task 6.4: Delete the six legacy packages - -- [ ] **Step 1: Verify no remaining references** - -Run: `grep -rln "@repo/api\b\|@repo/api-client\|@repo/cms-client\|@repo/cms-core\|@repo/core\b\|@repo/ui\b" apps/ packages/ 2>/dev/null | grep -v "^node_modules" | grep -v "^.next" | grep -v "^.turbo" | grep -v "^dist"` - -Expected: empty output (no remaining imports). If anything appears, fix it before deleting. - -> Note: the regex `@repo/core\b` matches `@repo/core` but not `@repo/core-shared`. Same for `@repo/ui\b` vs `@repo/core-ui`. The `\b` word boundary is important. - -- [ ] **Step 2: Delete the six packages** - -```bash -rm -rf packages/api packages/api-client packages/cms-client packages/cms-core packages/core packages/ui -``` - -- [ ] **Step 3: Install — pnpm prunes the now-orphaned workspace entries** - -Run: `pnpm install` -Expected: lockfile updates, no errors. Old packages no longer in workspace. - -- [ ] **Step 4: Repo-wide typecheck + tests to confirm nothing broke** - -Run: `pnpm typecheck` -Expected: PASS for all packages we kept (core-shared, core-cms, core-api, core-trpc, core-ui, auth, blog, marketing-pages, media, navigation, eslint-config, typescript-config, web-next, web-tanstack, cms, storybook). NO pre-existing failures should remain — those were in `@repo/api` and `@repo/ui` which we just deleted. - -Run: `pnpm test` -Expected: PASS for the 96 feature tests. - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "chore: delete legacy packages (api, api-client, cms-client, cms-core, core, ui)" -``` - ---- - -## Phase B: ESLint boundary enforcement - -### Task 6.5: Install + configure `eslint-plugin-boundaries` - -- [ ] **Step 1: Add `eslint-plugin-boundaries` to `packages/eslint-config/package.json` deps** - -Read the current file, then add to `dependencies`: - -```json -"eslint-plugin-boundaries": "^4.2.2" -``` - -(If `dependencies` doesn't exist, add the block.) - -- [ ] **Step 2: Inspect current eslint config** - -Run: `cat packages/eslint-config/index.{js,mjs,ts} 2>/dev/null` - -There may be one or multiple files (e.g., a base flat config + framework variants). Identify the file that's used by all packages (typically named `index.js` or `base.js` and exports a flat config array). - -- [ ] **Step 3: Add `eslint-plugin-boundaries` rules to the base config** - -Append to the base config's exports (assuming it's a flat config array): - -```javascript -import boundaries from "eslint-plugin-boundaries"; - -export default [ - // ... existing config ... - { - plugins: { boundaries }, - settings: { - "boundaries/elements": [ - // Apps — top tier - { type: "app", pattern: "apps/*" }, - // Core foundation packages - { type: "core", pattern: "packages/core-*" }, - // Composition core packages — special: may import feature subpaths - { type: "core-composition", pattern: "packages/core-api" }, - { type: "core-composition", pattern: "packages/core-cms" }, - // Business feature packages - { type: "feature", pattern: "packages/!(core-*|eslint-config|typescript-config)" }, - // Tooling — untagged for boundary purposes - { type: "tooling", pattern: "packages/eslint-config" }, - { type: "tooling", pattern: "packages/typescript-config" }, - ], - }, - rules: { - "boundaries/element-types": [ - 2, - { - default: "disallow", - rules: [ - { from: "app", allow: ["app", "core", "core-composition", "feature", "tooling"] }, - { from: "feature", allow: ["core", "tooling"] }, - { from: "core", allow: ["core", "tooling"] }, - { from: "core-composition", allow: ["core", "feature", "tooling"] }, - { from: "tooling", allow: ["tooling"] }, - ], - }, - ], - "boundaries/no-private": [2, { allowUncles: false }], - "boundaries/external": [ - 2, - { - default: "allow", - rules: [], - }, - ], - }, - }, -]; -``` - -> Notes: -> - `core-composition` is the special tier for `core-api` + `core-cms` — they're allowed to import `feature/*` subpath exports. Plain `core` packages cannot. -> - The disallow-by-default policy means any package combination not explicitly allowed errors. `app → app` is permitted in case apps share helpers. -> - `boundaries/no-private` blocks deep-imports into another package's internal source paths (only public `exports` allowed). - -- [ ] **Step 4: Install + run lint across all packages** - -Run: `pnpm install` then `pnpm lint` - -Expected: PASS. If violations appear, they're real — usually from forgotten cross-feature imports or deep imports. Fix them or report DONE_WITH_CONCERNS with a list. - -> Common false positives: import path patterns the plugin doesn't understand. If genuine violations are zero but the plugin misclassifies, file an entry under `boundaries/element-types` ignore lists or refine the `pattern` regexes. - -- [ ] **Step 5: Commit** - -```bash -git add packages/eslint-config pnpm-lock.yaml -git commit -m "feat(eslint-config): add boundaries plugin enforcing app→feature→core graph" -``` - ---- - -## Phase C: Playwright - -### Task 6.6: Install Playwright in `apps/web-next` + initial config - -- [ ] **Step 1: Add to `apps/web-next/package.json`** - -Add to `devDependencies`: -```json -"@playwright/test": "^1.50.0" -``` - -Add to `scripts`: -```json -"test:e2e": "playwright test", -"test:e2e:install": "playwright install --with-deps chromium" -``` - -- [ ] **Step 2: Install + Playwright browser binaries** - -Run: `pnpm install` -Run: `cd apps/web-next && pnpm playwright install --with-deps chromium` - -(The `--with-deps` may require sudo on Linux to install OS-level browser deps. On macOS it should run without elevation.) - -- [ ] **Step 3: Create `apps/web-next/playwright.config.ts`** - -```typescript -import { defineConfig, devices } from "@playwright/test"; - -export default defineConfig({ - testDir: "./e2e", - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, - reporter: "list", - use: { - baseURL: "http://localhost:3000", - trace: "on-first-retry", - }, - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - ], - webServer: { - command: "pnpm dev", - url: "http://localhost:3000", - reuseExistingServer: !process.env.CI, - timeout: 60_000, - }, -}); -``` - -- [ ] **Step 4: Create three smoke specs** - -`apps/web-next/e2e/home.spec.ts`: -```typescript -import { test, expect } from "@playwright/test"; - -test("home page renders site name + nav + article list", async ({ page }) => { - await page.goto("/"); - await expect(page.locator("h1").first()).toBeVisible(); - // Site name from siteSettings (mock seed: "My App") - await expect(page.locator("body")).toContainText(/My App/i); - // At least one nav item (mock seed: Home/Blog/About) - await expect(page.locator("nav a").first()).toBeVisible(); -}); -``` - -`apps/web-next/e2e/marketing-page.spec.ts`: -```typescript -import { test, expect } from "@playwright/test"; - -test("/about renders the about marketing page", async ({ page }) => { - await page.goto("/about"); - // Either renders the seeded page (h1 = "About us") or "not yet published" message - // — both are HTTP 200, so the test only checks it doesn't 500. - const status = (await page.context().request.get("/about")).status(); - expect(status).toBe(200); - await expect(page.locator("body")).toBeVisible(); -}); -``` - -`apps/web-next/e2e/blog-post.spec.ts`: -```typescript -import { test, expect } from "@playwright/test"; - -test("/blog/[slug] returns 404 for non-existent slug", async ({ page }) => { - const response = await page.goto("/blog/this-slug-does-not-exist", { - waitUntil: "domcontentloaded", - }); - expect(response?.status()).toBe(404); -}); - -test("/blog/[slug] for a real slug renders the article", async ({ page }) => { - // The mock blog repository is empty by default — so this test currently - // expects 404. When seeded data exists in Payload, replace 404 with 200 - // and check for article.title in the page body. - test.skip( - true, - "Pending: seed a published article in Payload before enabling this test", - ); - await page.goto("/blog/example-slug"); - await expect(page.locator("h1").first()).toBeVisible(); -}); -``` - -- [ ] **Step 5: Run e2e** - -Run: `cd apps/web-next && pnpm test:e2e` -Expected: PASS — 3 tests run, 1 skipped, all green. The webServer config auto-starts `pnpm dev` so you don't need a separate dev server. - -> Note: Postgres must be running. If not: `docker compose up -d postgres` from repo root first. - -- [ ] **Step 6: Commit** - -```bash -git add apps/web-next pnpm-lock.yaml -git commit -m "test(web-next): add Playwright config + smoke specs (home, about, blog 404)" -``` - ---- - -### Task 6.7: Install Playwright in `apps/web-tanstack` + initial config - -> Note: `apps/web-tanstack` doesn't have a real dev server yet (its `dev` script is just `echo 'placeholder'`). For Playwright we need to either (a) skip web-tanstack e2e until TanStack Start runtime is wired, or (b) add a basic Vite dev server. Option (a) keeps Plan 6 focused. The e2e config is added but only one test runs against the shared web-next backend at port 3000 — proving the cross-framework consumption works at the data layer even without a real TanStack runtime. - -- [ ] **Step 1: Add to `apps/web-tanstack/package.json`** - -Add to `devDependencies`: -```json -"@playwright/test": "^1.50.0" -``` - -Add to `scripts`: -```json -"test:e2e": "playwright test" -``` - -- [ ] **Step 2: Install** - -Run: `pnpm install` -(Browsers installed already by web-next task 6.6 step 2 — they're shared system-wide.) - -- [ ] **Step 3: Create `apps/web-tanstack/playwright.config.ts`** - -```typescript -import { defineConfig, devices } from "@playwright/test"; - -export default defineConfig({ - testDir: "./e2e", - fullyParallel: true, - retries: process.env.CI ? 2 : 0, - reporter: "list", - use: { - baseURL: "http://localhost:3000", - trace: "on-first-retry", - }, - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - ], - // No webServer: web-tanstack tests run against the shared web-next backend - // (port 3000). When TanStack Start runtime is wired in a future plan, add - // a webServer block here pointing at port 3002. -}); -``` - -- [ ] **Step 4: Create `apps/web-tanstack/e2e/home.spec.ts`** - -```typescript -import { test, expect } from "@playwright/test"; - -test.skip( - "TanStack home renders site name + nav (pending TanStack Start runtime)", - async ({ page }) => { - // Pending: web-tanstack has no dev server yet. When the TanStack Start - // runtime is wired (future plan), update the playwright.config.ts - // webServer to start it on port 3002 and remove this skip. - await page.goto("http://localhost:3002"); - await expect(page.locator("h1").first()).toBeVisible(); - }, -); -``` - -- [ ] **Step 5: Verify config + skipped test pass** - -Run: `cd apps/web-tanstack && pnpm test:e2e` -Expected: 1 test, skipped. Exit 0. - -- [ ] **Step 6: Commit** - -```bash -git add apps/web-tanstack pnpm-lock.yaml -git commit -m "test(web-tanstack): add Playwright scaffold + skipped home spec" -``` - ---- - -### Task 6.8: Root `test:e2e` script + Turbo task - -- [ ] **Step 1: Add `test:e2e` script to root `package.json`** - -```json -"test:e2e": "turbo run test:e2e" -``` - -(Add inside the `scripts` block.) - -- [ ] **Step 2: Add `test:e2e` task to root `turbo.json`** - -```json -"test:e2e": { - "dependsOn": ["^build"], - "cache": false -} -``` - -(Add inside the `tasks` object alongside `test`, `lint`, etc.) - -- [ ] **Step 3: Run from root** - -Run: `pnpm test:e2e` -Expected: web-next runs 3 tests (1 skipped); web-tanstack runs 1 test (skipped). All green. - -- [ ] **Step 4: Commit** - -```bash -git add package.json turbo.json -git commit -m "build: add root test:e2e task aggregating per-app Playwright suites" -``` - ---- - -## Phase D: Docs rewrite - -### Task 6.9: Copy spec into `docs/architecture/vertical-feature-spec.md` - -- [ ] **Step 1: Find the source spec** - -The spec was provided as `monorepo-architecture-spec-detailed-v5.md` from `/Users/danijel/Downloads/`. The user's worktree may not have a copy. The `docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md` references it. For Plan 6, copy the design spec (not the source) into `docs/architecture/` since it's our authoritative interpretation. - -```bash -cp docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md docs/architecture/vertical-feature-spec.md -``` - -> Note: If the source spec from Downloads is required verbatim, that's a separate user-supplied step — for now we use our own design spec which already encodes our decisions. - -- [ ] **Step 2: Add a brief preamble to the copied file** - -Edit the top of `docs/architecture/vertical-feature-spec.md`: - -```markdown -# Vertical Feature Architecture Spec - -> **Source of truth.** Copied from `docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md` for in-tree reference. Edits here should be backported to the design spec. - -``` - -(Insert above the existing `# Vertical Feature Monorepo Refactor — Design Spec` heading; or replace the heading, your choice.) - -- [ ] **Step 3: Commit** - -```bash -git add docs/architecture/vertical-feature-spec.md -git commit -m "docs(architecture): copy refactor design spec into in-tree reference" -``` - ---- - -### Task 6.10: Rewrite `docs/architecture/overview.md` - -Replace the entire file with: - -```markdown -# Architecture Overview - -A vertical-feature monorepo. Business capabilities are top-level packages; non-business foundations are `core-*`. - -## Package map - -``` -packages/ - # Foundation (no business logic) - core-shared/ Generic primitives — Payload field/block helpers, tRPC init/context, lib utilities - core-cms/ Composition only: assembles feature CMS exports into one Payload config - core-api/ Composition only: aggregates feature tRPC routers into one appRouter - core-trpc/ Frontend tRPC client + per-framework providers (Next.js, TanStack) - core-ui/ Design-system primitives (atoms, molecules, generic organisms, templates) - - # Business capabilities - auth/ Users + sign-in/sign-up/sign-out + session/cookie domain - blog/ Articles collection + publishing flow - media/ Media upload collection (skeleton; expand with optimization, CDN, etc.) - marketing-pages/ Pages collection + SiteSettings global - navigation/ Header global + menu items - - # Tooling - eslint-config/ Shared ESLint flat config + boundary rules - typescript-config/ Shared tsconfig + vitest base -``` - -## Data flow - -``` -React component - ↓ useQuery(trpc.blog.articleBySlug.queryOptions(...)) ← ui/query.ts (typed tRPC client) -HTTP /api/trpc - ↓ -tRPC procedure ← integrations/api/router.ts - ↓ .input(zod).query(...) -Controller (Zod safeParse) ← interface-adapters/controllers/ - ↓ -Use case ← application/use-cases/ - ↓ container.get(SYMBOL) -Repository implementation ← infrastructure/repositories/ (@injectable) - ↓ getPayload({ config }) -Payload Local API → Postgres -``` - -## Three enforcement layers - -1. **`package.json` deps** — only declare allowed deps -2. **`exports` map** — each package exposes a small public surface (`.`, `./cms`, `./api`, `./di/bind-production`) -3. **ESLint `eslint-plugin-boundaries`** — three tags (`app`, `feature`, `core`); two composition exceptions (`core-api` may import `@repo//api`; `core-cms` may import `@repo//cms`) - -## Per-feature DI containers - -Each feature owns its own InversifyJS `Container` + symbol table. No shared symbols, no cross-feature DI coupling. Tests rebind per feature without touching others. Apps call `bindProduction*(config)` per feature at boot to swap the default mock implementations for Payload-backed ones. - -## Spec reference - -`docs/architecture/vertical-feature-spec.md` is the canonical design. -``` - -- [ ] **Step 1: Replace the file** - -(Use the Write tool with the content above.) - -- [ ] **Step 2: Commit** - -```bash -git add docs/architecture/overview.md -git commit -m "docs(architecture): rewrite overview for vertical feature architecture" -``` - ---- - -### Task 6.11: Rewrite `docs/architecture/dependency-flow.md` - -Replace with: - -```markdown -# Dependency Flow - -``` - +-------------+ +-----------------+ +-----------+ - | apps/web- | | apps/web- | | apps/cms | - | next | | tanstack | | | - +------+------+ +--------+--------+ +-----+-----+ - | | | - +------------------+--------------+ | | - | | | | | - +----v-----+ +-----v------+ +-----v----v---+ +-------v------+ - | core-api | | core-trpc | | feature | | core-cms | - | | | | | packages | | | - +-----+----+ +-----+------+ +------+-------+ +-------+------+ - | | | | - | | | | - +--+-------+------+---------------+----+ +-------------+ - | | | | - +----v---+ +-v---------+ +-------v---v---+ - | core- | | core-ui | | core-shared | - | shared | | | | | - +--------+ +-----------+ +----------------+ - - Boundary rules (enforced by eslint-plugin-boundaries): - app → app, core, feature, core-composition (any) - feature → core (any), but NOT other features, NOT app - core → core, but NOT feature, NOT app - core-composition → core, feature subpath exports only (`/cms`, `/api`) - core-api → @repo//api - core-cms → @repo//cms -``` - -## Concrete examples - -Allowed: -```ts -// in apps/web-next -import { appRouter } from "@repo/core-api"; -import { NextTrpcProvider } from "@repo/core-trpc/next"; -import { bindProductionBlog } from "@repo/blog/di/bind-production"; - -// in packages/blog -import { slugifyIfMissing } from "@repo/core-shared/payload"; - -// in packages/core-api -import { blogRouter } from "@repo/blog/api"; // composition exception -import { router } from "@repo/core-shared/trpc/init"; // core → core fine - -// in packages/core-cms -import { articles } from "@repo/blog/cms"; // composition exception -``` - -Disallowed: -```ts -// in packages/blog (cross-feature) -import { Article } from "@repo/marketing-pages"; // ❌ feature → feature - -// in packages/blog (deep import past public exports) -import { articles } from "@repo/blog/src/integrations/cms/collections/articles"; // ❌ no-private - -// in packages/core-shared -import { blogRouter } from "@repo/blog/api"; // ❌ core → feature - -// in packages/core-trpc -import { someBlogThing } from "@repo/blog"; // ❌ core → feature (only core-api/core-cms have exception) -``` - -## Three-layer enforcement - -ESLint catches accidental cross-package imports at lint time. The `package.json` `exports` map blocks deep imports at module-resolution time. Workspace `dependencies` declarations make the package graph itself the source of truth — if you didn't declare it, you can't import it. -``` - -- [ ] **Step 1: Replace the file** - -- [ ] **Step 2: Commit** - -```bash -git add docs/architecture/dependency-flow.md -git commit -m "docs(architecture): rewrite dependency-flow for vertical features + boundary rules" -``` - ---- - -### Task 6.12: Rewrite `docs/guides/adding-a-feature.md` - -Replace with a new walkthrough. The guide should cover: -1. Decide if the work is a new feature or extends an existing one -2. Scaffold a minimal feature (smallest viable shape — see addendum v5) -3. Add layers as needed (entities → application → infrastructure → di → integrations/cms → integrations/api → ui) -4. Add `bindProduction*(config)` if it has a payload-backed repo -5. Wire `/cms` into `core-cms`, `/api` into `core-api` -6. Add path aliases to `tsconfig.base.json` -7. Run `pnpm install`, typecheck, test - -A second walkthrough should cover modifying an existing feature (e.g., adding a procedure to blog). - -Use the existing `auth`, `blog`, `marketing-pages`, `navigation` packages as living examples. Keep the guide concise (~300-400 lines) and link to the spec for theoretical depth. - -- [ ] **Step 1: Write the new guide** - -Use the Write tool. Format the guide with clear `###` step headings, code blocks per file, and a "Done criteria" section at the end. - -> The exact prose is at the implementer's discretion (within the structure above) — Plan 6 doesn't ship the verbatim guide text. The implementer should look at the existing `packages/blog/src/` for ground truth on what a feature looks like. - -- [ ] **Step 2: Commit** - -```bash -git add docs/guides/adding-a-feature.md -git commit -m "docs(guides): rewrite adding-a-feature for vertical canonical pattern" -``` - ---- - -### Task 6.13: Rewrite `docs/guides/testing-strategy.md` - -Replace with a guide covering: -1. **Test placement table** (colocated `*.test.ts` next to source; feature-level `tests/*.feature.test.ts`; e2e `apps//e2e/*.spec.ts`) -2. **Per-feature DI in tests** — show the `beforeEach` pattern of unbinding + rebinding the feature's container -3. **Vitest setup** — each package has its own `vitest.config.ts` with `resolve.alias` for `@/` -4. **Playwright setup** — apps have `playwright.config.ts` with `webServer` block; smoke specs initially -5. **Mocking strategy for Payload** — feature-test level uses Mock repos via DI rebind; the `payload` module can be mocked at vitest level for infrastructure tests (see `payload-articles.repository.test.ts`) - -Keep concise (~150-200 lines). - -- [ ] **Step 1: Write** -- [ ] **Step 2: Commit** - -```bash -git add docs/guides/testing-strategy.md -git commit -m "docs(guides): rewrite testing-strategy for vertical features (per-feature DI, colocated tests, Playwright)" -``` - ---- - -### Task 6.14: Update existing ADRs (002, 003, 005), supersede ADR-004 - -For each: - -- [ ] **Step 1: ADR-002 (DI framework)** — append a section: - -```markdown - -## Update (2026-05-04) - -The vertical-feature refactor preserved InversifyJS but moved from a single shared container in `packages/core/src/di/` to **per-feature containers** in each feature package (`packages//src/di/container.ts`). See ADR-008. -``` - -- [ ] **Step 2: ADR-003 (CMS separation)** — mark v1 superseded, add v2 inline: - -```markdown - -## Status: Partially superseded by v2 (2026-05-04) - -v1 advocated `@repo/cms-core` as a single CMS package. v2 splits this into: -- `@repo/core-cms` — composition only (assembles feature CMS schemas) -- Each feature owns its own collections/globals under `packages//src/integrations/cms/` - -Rationale: vertical-feature ownership scales better; CMS schema lives with the business code that needs it. See ADR-006. -``` - -- [ ] **Step 3: ADR-004 (dual-mode client)** — supersede entirely: - -```markdown - -## Status: Superseded by ADR-007 (2026-05-04) - -The dual-mode client wrapper was deleted. Feature payload-backed repositories now call `getPayload({ config })` directly with the assembled config injected via constructor. See ADR-007 for rationale. -``` - -- [ ] **Step 4: ADR-005 (atomic design)** — append scope note: - -```markdown - -## Update (2026-05-04) - -Atomic Design now applies to `@repo/core-ui/` only — generic primitives (atoms, molecules, generic organisms, templates). Feature-specific components (e.g., `ArticleCard`, `HeaderNavMenu`) live in the owning feature's `ui/` folder per the vertical-feature architecture. See ADR-006. -``` - -- [ ] **Step 5: Commit** - -```bash -git add docs/decisions/adr-002 docs/decisions/adr-003 docs/decisions/adr-004 docs/decisions/adr-005 -git commit -m "docs(adr): update 002/003/005 with vertical-refactor notes; supersede 004" -``` - ---- - -### Task 6.15: Add four new ADRs (006-009) - -Create each as a short ADR (~50-100 lines): - -- [ ] **Step 1: `docs/decisions/adr-006-vertical-feature-packages.md`** - -Title: "Vertical feature packages over horizontal layers" -Context: original Clean Architecture used one `packages/core` for all domains -Decision: split by business capability; each feature owns the full vertical slice (entities → ui) -Consequences: features evolve independently; cross-feature coupling explicit at the package-graph level; per-feature DI containers - -- [ ] **Step 2: `docs/decisions/adr-007-drop-cms-client-wrapper.md`** - -Title: "Drop the dual-mode CMS client wrapper" -Context: ADR-004 introduced `@repo/cms-client` with local + HTTP modes; never used in production -Decision: delete the wrapper; payload-backed repositories use `getPayload({ config })` directly with config passed via constructor -Consequences: one fewer abstraction; package graph stays acyclic (feature ↛ core-cms) - -- [ ] **Step 3: `docs/decisions/adr-008-per-feature-di-containers.md`** - -Title: "Per-feature InversifyJS containers" -Context: original architecture had one shared container in `packages/core/src/di/` -Decision: each feature owns its own `Container` + symbol table; tests rebind per-feature without coordination -Consequences: zero cross-feature DI coupling; symbol collisions impossible; cross-feature shared services need explicit per-container binding (rare in practice) - -- [ ] **Step 4: `docs/decisions/adr-009-integrations-folder-naming.md`** - -Title: "Rename spec's `adapters/` to `integrations/`" -Context: source spec used `adapters/cms` + `adapters/api`; conflicts with Clean Architecture's `interface-adapters/` folder we kept -Decision: rename to `integrations/cms` + `integrations/api` to avoid the collision -Consequences: source-spec deviation bounded to one naming choice; semantically equivalent (both describe role-based plug points); leaves `adapters` unambiguously meaning Clean Architecture's interface-adapter layer - -- [ ] **Step 5: Commit** - -```bash -git add docs/decisions/adr-006 docs/decisions/adr-007 docs/decisions/adr-008 docs/decisions/adr-009 -git commit -m "docs(adr): add ADRs 006-009 for vertical refactor (vertical packages, drop wrapper, per-feature DI, integrations naming)" -``` - ---- - -### Task 6.16: Delete stale 2026-04-06 plan files - -- [ ] **Step 1: Delete the six files** - -```bash -rm docs/superpowers/plans/2026-04-06-plan-1-monorepo-foundation.md -rm docs/superpowers/plans/2026-04-06-plan-2-core-package.md -rm docs/superpowers/plans/2026-04-06-plan-3-payload-cms.md -rm docs/superpowers/plans/2026-04-06-plan-4-api-layer-app-shells.md -rm docs/superpowers/plans/2026-04-06-plan-5-ui-system.md -rm docs/superpowers/plans/2026-04-06-plan-6-documentation.md -``` - -- [ ] **Step 2: Optionally delete the stale superseded design spec** - -```bash -rm docs/superpowers/specs/2026-04-06-clean-architecture-monorepo-template-design.md -``` - -(Optional — keeping the old spec is acceptable as historical record. Default to deleting since the new spec supersedes it and the directory is for active specs.) - -- [ ] **Step 3: Commit** - -```bash -git add -A -git commit -m "docs(plans): delete six stale 2026-04-06 plan docs (superseded by 2026-05-04-plan-{1..6})" -``` - ---- - -### Task 6.17: Rewrite root `AGENTS.md` - -Full rewrite. Cover: -1. **What this repo is** — vertical-feature Turborepo + pnpm monorepo template -2. **Package map** — same as overview but in table form -3. **Boundary rules** — three tags, two composition exceptions, three enforcement layers -4. **Adding a feature** — link to `docs/guides/adding-a-feature.md` -5. **Key commands** — pnpm install, dev, typecheck, lint, test, test:e2e -6. **Per-package conventions** — relative imports in src, `@/` in tests, vitest config has resolve.alias, payload repos take SanitizedConfig via constructor - -Aim for ~150 lines. Reference the spec + guides for depth. - -- [ ] **Step 1: Replace `AGENTS.md` at repo root** -- [ ] **Step 2: Commit** - -```bash -git add AGENTS.md -git commit -m "docs(agents): rewrite root AGENTS.md for vertical feature architecture" -``` - ---- - -### Task 6.18: Update root `CLAUDE.md` - -Surgical edit, don't full rewrite — preserve user's existing setup notes and ports table. Update: -- **Read First section** to point at the new `docs/architecture/overview.md`, `docs/architecture/vertical-feature-spec.md`, `docs/guides/adding-a-feature.md` -- **Add a "Key conventions" section** with the bullet points from Plans 2-5 lessons (relative imports in src, no rootDir warning fixed, vitest alias, payload repos via constructor, `bindProduction*` for app boot) -- **MCP Servers section** — Storybook still on :6006 — keep as-is - -- [ ] **Step 1: Edit `CLAUDE.md`** -- [ ] **Step 2: Commit** - -```bash -git add CLAUDE.md -git commit -m "docs(claude): update Read First + add vertical feature conventions" -``` - ---- - -### Task 6.19: Per-package + per-app `AGENTS.md` - -12 packages + 4 apps = 16 AGENTS.md files. Most can be 30-60 lines each. Cover for each package: -- **Purpose** (one paragraph) -- **What it owns** (bulleted) -- **What it must NOT import** (boundary rules) -- **Public exports** (the entries from `package.json` `exports`) -- **Test conventions** (where tests live, how to run them) - -For apps: -- **What it does** + how to run dev -- **What it imports** (top-level deps) -- **Pages/routes** (if applicable) -- **e2e** location + how to run - -Group commits by area to keep history readable: - -- [ ] **Step 1: Write AGENTS.md for all 5 core packages** (core-shared, core-cms, core-api, core-trpc, core-ui). Commit: - -```bash -git add packages/core-*/AGENTS.md -git commit -m "docs(agents): add per-package AGENTS.md for all core-* packages" -``` - -- [ ] **Step 2: Write AGENTS.md for all 5 feature packages** (auth, blog, media, marketing-pages, navigation). Commit: - -```bash -git add packages/{auth,blog,media,marketing-pages,navigation}/AGENTS.md -git commit -m "docs(agents): add per-package AGENTS.md for all feature packages" -``` - -- [ ] **Step 3: Write AGENTS.md for tooling** (eslint-config, typescript-config — short, mostly "shared config"). Commit: - -```bash -git add packages/{eslint-config,typescript-config}/AGENTS.md -git commit -m "docs(agents): add per-package AGENTS.md for eslint-config + typescript-config" -``` - -- [ ] **Step 4: Write/rewrite app AGENTS.md** (cms, web-next, web-tanstack, storybook). Commit: - -```bash -git add apps/*/AGENTS.md -git commit -m "docs(agents): write per-app AGENTS.md for cms, web-next, web-tanstack, storybook" -``` - -> Note: `apps/cms/AGENTS.md` already exists and references the deleted `@repo/cms-core`. The rewrite replaces all `@repo/cms-core` mentions with `@repo/core-cms` and removes the "thin shell" text since core-cms now actually composes feature schemas. - ---- - -## Phase E: Final repo-wide green check - -### Task 6.20: All-green verification - -- [ ] **Step 1: Run all checks in sequence** - -```bash -pnpm install -pnpm typecheck -pnpm lint -pnpm test -pnpm test:e2e -``` - -Each must exit 0. - -- [ ] **Step 2: Verify package counts** - -Run: `ls packages/ | wc -l` -Expected: 12 (5 core + 5 feature + 2 tooling). - -Run: `ls apps/ | wc -l` -Expected: 4 (cms, storybook, web-next, web-tanstack). - -- [ ] **Step 3: Verify no remaining references to deleted packages** - -Run: `grep -rln "@repo/api\b\|@repo/api-client\|@repo/cms-client\|@repo/cms-core\|@repo/core\b\|@repo/ui\b" apps/ packages/ docs/ 2>/dev/null | grep -v node_modules | grep -v ".turbo" | grep -v ".next" | grep -v "dist/"` - -Expected: empty (any matches in `docs/` are likely in superseded ADRs and acceptable — verify each). - -- [ ] **Step 4: No commit needed if everything is green** (any small fixes uncovered should be committed individually with descriptive messages) - ---- - -## Plan 6 Done Criteria - -- [ ] 6 legacy packages deleted (api, api-client, cms-client, cms-core, core, ui); workspace has exactly 12 packages -- [ ] `apps/cms` no longer depends on `@repo/cms-core` -- [ ] `apps/storybook` migrated to `@repo/core-ui` and typechecks -- [ ] `eslint-plugin-boundaries` configured and `pnpm lint` passes with zero violations -- [ ] Playwright installed in both apps; `pnpm test:e2e` from repo root passes (3 specs run + 2 skipped) -- [ ] Root + per-package + per-app AGENTS.md all rewritten for vertical features -- [ ] 4 new ADRs added (006-009); 4 existing ADRs updated/superseded -- [ ] `docs/architecture/{overview,dependency-flow,vertical-feature-spec}.md` rewritten/copied -- [ ] `docs/guides/{adding-a-feature,testing-strategy}.md` rewritten -- [ ] 6 stale 2026-04-06 plan docs deleted -- [ ] `pnpm install && pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e` all green -- [ ] Branch `refactor/vertical-features` ready to merge - -**After this plan:** the branch is ready for PR + merge. Apps render features in browsers. Tests cover unit/feature/integration/e2e levels. Architecture is enforced by ESLint, package exports, and the workspace dependency graph. Documentation matches reality. diff --git a/docs/superpowers/plans/2026-05-05-plan-7-tdd-foundation.md b/docs/superpowers/plans/2026-05-05-plan-7-tdd-foundation.md deleted file mode 100644 index 89dcb50..0000000 --- a/docs/superpowers/plans/2026-05-05-plan-7-tdd-foundation.md +++ /dev/null @@ -1,2289 +0,0 @@ -# Plan 7 — TDD Foundation Implementation Plan - -> **Note (2026-05-05, post-Plan-8):** All file paths in this plan reference the **pre-Plan-8 layout** (`mock-articles.repository.ts`, `entities/article.ts`, multi-method `articles.controller.ts`, etc.). The Lazar conformance refactor (Plan 8) renamed/reshaped these files. See `docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md` for the full mapping; ADR-012 documents the conformance decision. - -> **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 full TDD frictionless in this monorepo by closing the ten gaps catalogued in `docs/superpowers/specs/2026-05-05-tdd-foundation-design.md`. - -**Architecture:** Add a new `@repo/core-testing` package providing factories, contract suites, RTL helpers, and Payload mocks. Layer Vitest safety defaults (jsdom + node bases with coverage thresholds) into `core-typescript`. Add tests to every package and app, rewrite the docs to enforce TDD order, and add a CI workflow that runs typecheck + lint + boundaries + test + build + e2e + storybook on every PR. - -**Tech Stack:** Vitest 3, @testing-library/react, @testing-library/user-event, jsdom, @storybook/test-runner, GitHub Actions, msw (or fetch stubs), tsx. - -**Spec:** `docs/superpowers/specs/2026-05-05-tdd-foundation-design.md` — read this first if any task is unclear. - -**Worktree:** Execute on branch `feature/tdd-foundation` in `.worktrees/tdd-foundation/`. - ---- - -## Cross-cutting conventions (re-read at the start of every task) - -- **TDD always:** write a failing test, run it to confirm RED, write minimal implementation, run to confirm GREEN, refactor, commit. -- **Source files use relative imports** (`../foo.js`); test files use `@/` alias. -- **Every new vitest config** must extend `nodeVitestConfig` or `jsdomVitestConfig` from `@repo/core-typescript` and declare the `@/` alias. -- **Commit per task** with a clear message. Never bundle two tasks into one commit. -- **After each task:** run `pnpm typecheck && pnpm lint && pnpm test` from repo root before declaring DONE. -- **If a step says "expected: PASS"** and the test fails, do NOT proceed. Diagnose and fix before moving on. - ---- - -### Task 1: Scaffold `@repo/core-testing` package - -**Files:** -- Create: `packages/core-testing/package.json` -- Create: `packages/core-testing/tsconfig.json` -- Create: `packages/core-testing/vitest.config.ts` -- Create: `packages/core-testing/eslint.config.js` -- Create: `packages/core-testing/turbo.json` -- Create: `packages/core-testing/AGENTS.md` -- Create: `packages/core-testing/src/index.ts` -- Create: `packages/core-testing/src/factory/define-factory.ts` -- Create: `packages/core-testing/src/factory/define-factory.test.ts` -- Create: `packages/core-testing/src/factory/index.ts` -- Create: `packages/core-testing/src/contract/define-contract-suite.ts` -- Create: `packages/core-testing/src/contract/define-contract-suite.test.ts` -- Create: `packages/core-testing/src/contract/index.ts` -- Create: `packages/core-testing/src/setup/jsdom.ts` -- Create: `packages/core-testing/src/setup/node.ts` -- Create: `packages/core-testing/src/payload/stub-config.ts` -- Create: `packages/core-testing/src/payload/mock-payload-module.ts` -- Create: `packages/core-testing/src/payload/index.ts` -- Create: `packages/core-testing/src/react/render-with-providers.tsx` -- Create: `packages/core-testing/src/react/render-with-providers.test.tsx` -- Create: `packages/core-testing/src/react/mock-trpc.ts` -- Create: `packages/core-testing/src/react/index.ts` -- Modify: `tsconfig.base.json` — add `@repo/core-testing/*` aliases - -- [ ] **Step 1: Create package skeleton** - -```bash -mkdir -p packages/core-testing/src/{factory,contract,setup,payload,react} -``` - -- [ ] **Step 2: Write package.json** - -`packages/core-testing/package.json`: -```json -{ - "name": "@repo/core-testing", - "version": "0.0.1", - "private": true, - "type": "module", - "exports": { - ".": "./src/index.ts", - "./factory": "./src/factory/index.ts", - "./contract": "./src/contract/index.ts", - "./react": "./src/react/index.ts", - "./payload": "./src/payload/index.ts", - "./setup/jsdom": "./src/setup/jsdom.ts", - "./setup/node": "./src/setup/node.ts" - }, - "scripts": { - "build": "tsc --noEmit", - "lint": "eslint .", - "typecheck": "tsc --noEmit", - "test": "vitest run" - }, - "dependencies": { - "@testing-library/jest-dom": "^6.5.0", - "@testing-library/react": "^16.0.0", - "@testing-library/user-event": "^14.5.0", - "@trpc/client": "^11.0.0", - "@trpc/react-query": "^11.0.0", - "@trpc/tanstack-react-query": "^11.0.0", - "@tanstack/react-query": "^5.59.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "superjson": "^2.2.0", - "vitest": "^3.0.0" - }, - "peerDependencies": { - "payload": "^3.0.0" - }, - "peerDependenciesMeta": { - "payload": { "optional": true } - }, - "devDependencies": { - "@repo/core-eslint": "workspace:*", - "@repo/core-typescript": "workspace:*", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "jsdom": "^25.0.0", - "typescript": "^5.8.0" - } -} -``` - -- [ ] **Step 3: Write tsconfig.json** - -`packages/core-testing/tsconfig.json`: -```json -{ - "extends": "@repo/core-typescript/react-library.json", - "compilerOptions": { - "rootDir": ".", - "outDir": "dist" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} -``` - -- [ ] **Step 4: Write vitest.config.ts** - -`packages/core-testing/vitest.config.ts`: -```typescript -import path from "node:path"; -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - globals: true, - environment: "jsdom", - include: ["src/**/*.test.{ts,tsx}"], - setupFiles: ["./src/setup/jsdom.ts"], - clearMocks: true, - restoreMocks: true, - }, - resolve: { - alias: { "@": path.resolve(__dirname, "./src") }, - }, -}); -``` - -- [ ] **Step 5: Write eslint.config.js** - -`packages/core-testing/eslint.config.js`: -```javascript -import { config as base } from "@repo/core-eslint/base"; - -export default [ - ...base, - { - rules: { - // test utilities are intended to be used in test contexts - "no-console": "off", - }, - }, -]; -``` - -- [ ] **Step 6: Write turbo.json (tag: tooling)** - -`packages/core-testing/turbo.json`: -```json -{ - "$schema": "https://turborepo.dev/schema.json", - "extends": ["//"], - "tags": ["tooling"] -} -``` - -- [ ] **Step 7: Write failing test for `defineFactory`** - -`packages/core-testing/src/factory/define-factory.test.ts`: -```typescript -import { describe, it, expect, beforeEach } from "vitest"; -import { defineFactory } from "@/factory/define-factory"; - -interface User { - id: string; - name: string; - age: number; - createdAt: Date; -} - -describe("defineFactory", () => { - const userFactory = defineFactory(({ sequence }) => ({ - id: `user-${sequence}`, - name: `User ${sequence}`, - age: 30, - createdAt: new Date("2026-01-01T00:00:00Z"), - })); - - beforeEach(() => userFactory.reset()); - - it("builds a default object", () => { - const u = userFactory.build(); - expect(u).toEqual({ - id: "user-1", - name: "User 1", - age: 30, - createdAt: new Date("2026-01-01T00:00:00Z"), - }); - }); - - it("increments sequence per build", () => { - const a = userFactory.build(); - const b = userFactory.build(); - expect(a.id).toBe("user-1"); - expect(b.id).toBe("user-2"); - }); - - it("applies overrides", () => { - const u = userFactory.build({ name: "Alice", age: 25 }); - expect(u.name).toBe("Alice"); - expect(u.age).toBe(25); - expect(u.id).toBe("user-1"); - }); - - it("buildList builds N items with same overrides", () => { - const list = userFactory.buildList(3, { age: 40 }); - expect(list).toHaveLength(3); - expect(list.map((u) => u.id)).toEqual(["user-1", "user-2", "user-3"]); - expect(list.every((u) => u.age === 40)).toBe(true); - }); - - it("reset() restarts the sequence", () => { - userFactory.build(); - userFactory.build(); - userFactory.reset(); - expect(userFactory.build().id).toBe("user-1"); - }); -}); -``` - -- [ ] **Step 8: Run test to verify RED** - -```bash -pnpm install -cd packages/core-testing && pnpm test -``` - -Expected: FAIL — `defineFactory` not exported. - -- [ ] **Step 9: Implement `defineFactory`** - -`packages/core-testing/src/factory/define-factory.ts`: -```typescript -export interface FactoryContext { - sequence: number; -} - -export interface Factory { - build(overrides?: Partial): T; - buildList(count: number, overrides?: Partial): T[]; - reset(): void; -} - -export function defineFactory( - builder: (ctx: FactoryContext) => T, -): Factory { - let sequence = 0; - return { - build(overrides) { - sequence += 1; - const base = builder({ sequence }); - return { ...base, ...(overrides ?? {}) } as T; - }, - buildList(count, overrides) { - return Array.from({ length: count }, () => this.build(overrides)); - }, - reset() { - sequence = 0; - }, - }; -} -``` - -`packages/core-testing/src/factory/index.ts`: -```typescript -export { defineFactory, type Factory, type FactoryContext } from "./define-factory.js"; -``` - -- [ ] **Step 10: Run test to verify GREEN** - -```bash -cd packages/core-testing && pnpm test -``` - -Expected: 5/5 PASS in `define-factory.test.ts`. - -- [ ] **Step 11: Write failing test for `defineContractSuite`** - -`packages/core-testing/src/contract/define-contract-suite.test.ts`: -```typescript -import { describe, it, expect } from "vitest"; -import { defineContractSuite } from "@/contract/define-contract-suite"; - -interface Adder { - add(a: number, b: number): number; -} - -const adderContract = defineContractSuite("Adder", ({ buildSubject }) => { - it("adds two positive numbers", async () => { - const subject = await buildSubject(); - expect(subject.add(2, 3)).toBe(5); - }); - it("handles zero", async () => { - const subject = await buildSubject(); - expect(subject.add(0, 0)).toBe(0); - }); -}); - -class RealAdder implements Adder { - add(a: number, b: number) { - return a + b; - } -} - -describe("RealAdder satisfies Adder contract", () => { - adderContract.run(() => new RealAdder()); -}); -``` - -- [ ] **Step 12: Run test to verify RED** - -```bash -cd packages/core-testing && pnpm test -``` - -Expected: FAIL — `defineContractSuite` not exported. - -- [ ] **Step 13: Implement `defineContractSuite`** - -`packages/core-testing/src/contract/define-contract-suite.ts`: -```typescript -import { describe } from "vitest"; - -export interface ContractContext { - buildSubject: () => Promise | T; -} - -export interface ContractSuite { - run(buildSubject: () => Promise | T): void; -} - -export function defineContractSuite( - name: string, - suite: (ctx: ContractContext) => void, -): ContractSuite { - return { - run(buildSubject) { - describe(`Contract: ${name}`, () => { - suite({ buildSubject }); - }); - }, - }; -} -``` - -`packages/core-testing/src/contract/index.ts`: -```typescript -export { defineContractSuite, type ContractContext, type ContractSuite } from "./define-contract-suite.js"; -``` - -- [ ] **Step 14: Run test to verify GREEN** - -```bash -cd packages/core-testing && pnpm test -``` - -Expected: 7/7 PASS (5 factory + 2 contract). - -- [ ] **Step 15: Implement setup files** - -`packages/core-testing/src/setup/jsdom.ts`: -```typescript -import "@testing-library/jest-dom/vitest"; -import { afterEach } from "vitest"; -import { cleanup } from "@testing-library/react"; - -afterEach(() => { - cleanup(); -}); -``` - -`packages/core-testing/src/setup/node.ts`: -```typescript -// Reserved for future global node-env setup. Currently a no-op so that -// vitest configs may reference @repo/core-testing/setup/node uniformly. -export {}; -``` - -- [ ] **Step 16: Implement payload stubs** - -`packages/core-testing/src/payload/stub-config.ts`: -```typescript -import type { SanitizedConfig } from "payload"; - -// Minimal SanitizedConfig stub for tests that need to construct repos -// without actually loading the real Payload config. Repository tests -// that mock the `payload` module never read fields off this object. -export const stubPayloadConfig = {} as SanitizedConfig; -``` - -`packages/core-testing/src/payload/mock-payload-module.ts`: -```typescript -import { vi } from "vitest"; -import type { Payload } from "payload"; - -// Helper to mock the `payload` package with a custom getPayload impl. -// Call inside a test file BEFORE importing the SUT. -export function mockPayloadModule(impl: Partial): void { - vi.mock("payload", () => ({ - getPayload: vi.fn().mockResolvedValue(impl), - })); -} -``` - -`packages/core-testing/src/payload/index.ts`: -```typescript -export { stubPayloadConfig } from "./stub-config.js"; -export { mockPayloadModule } from "./mock-payload-module.js"; -``` - -- [ ] **Step 17: Implement react helpers** - -`packages/core-testing/src/react/mock-trpc.ts`: -```typescript -import { createTRPCClient, httpBatchLink } from "@trpc/client"; -import superjson from "superjson"; -import type { AnyTRPCRouter } from "@trpc/server"; - -// Returns a tRPC client whose fetch is a stub honouring the provided mocks. -// Mocks are keyed by procedure path ("blog.articleBySlug") returning the -// raw response body. -export function createMockTrpcClient( - mocks: Record = {}, -) { - const fetchStub: typeof fetch = async (input) => { - const url = typeof input === "string" ? input : (input as Request).url; - const path = new URL(url, "http://mock").pathname.replace(/^\/api\/trpc\//, ""); - const result = mocks[path]; - if (result === undefined) { - return new Response(JSON.stringify([{ error: { code: -32603, message: `No mock for ${path}` } }]), { status: 200 }); - } - return new Response(JSON.stringify([{ result: { data: superjson.serialize(result) } }]), { status: 200 }); - }; - - return createTRPCClient({ - links: [ - httpBatchLink({ - url: "http://mock/api/trpc", - transformer: superjson, - fetch: fetchStub, - }), - ], - }); -} -``` - -`packages/core-testing/src/react/render-with-providers.tsx`: -```typescript -import type { PropsWithChildren, ReactElement } from "react"; -import { render, type RenderResult } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -export interface RenderOptions { - queryClient?: QueryClient; -} - -export function renderWithProviders( - ui: ReactElement, - options: RenderOptions = {}, -): RenderResult & { queryClient: QueryClient } { - const queryClient = - options.queryClient ?? - new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); - - const Wrapper = ({ children }: PropsWithChildren) => ( - {children} - ); - - return { ...render(ui, { wrapper: Wrapper }), queryClient }; -} -``` - -`packages/core-testing/src/react/render-with-providers.test.tsx`: -```typescript -import { describe, it, expect } from "vitest"; -import { screen } from "@testing-library/react"; -import { renderWithProviders } from "@/react/render-with-providers"; - -describe("renderWithProviders", () => { - it("renders the child", () => { - renderWithProviders(
hi
); - expect(screen.getByTestId("x")).toBeInTheDocument(); - }); - - it("returns the queryClient instance", () => { - const { queryClient } = renderWithProviders(
); - expect(queryClient).toBeDefined(); - }); -}); -``` - -`packages/core-testing/src/react/index.ts`: -```typescript -export { renderWithProviders, type RenderOptions } from "./render-with-providers.js"; -export { createMockTrpcClient } from "./mock-trpc.js"; -``` - -- [ ] **Step 18: Write top-level barrel** - -`packages/core-testing/src/index.ts`: -```typescript -export * from "./factory/index.js"; -export * from "./contract/index.js"; -``` - -- [ ] **Step 19: Add tsconfig path aliases** - -Edit `tsconfig.base.json` and add to `compilerOptions.paths`: -```json -"@repo/core-testing": ["packages/core-testing/src/index.ts"], -"@repo/core-testing/factory": ["packages/core-testing/src/factory/index.ts"], -"@repo/core-testing/contract": ["packages/core-testing/src/contract/index.ts"], -"@repo/core-testing/react": ["packages/core-testing/src/react/index.ts"], -"@repo/core-testing/payload": ["packages/core-testing/src/payload/index.ts"], -"@repo/core-testing/setup/jsdom": ["packages/core-testing/src/setup/jsdom.ts"], -"@repo/core-testing/setup/node": ["packages/core-testing/src/setup/node.ts"] -``` - -- [ ] **Step 20: Write AGENTS.md** - -`packages/core-testing/AGENTS.md`: -```markdown -# @repo/core-testing - -Shared testing utilities. Tag: `tooling`. May be depended on by any package as a devDependency. - -## Subpath exports - -- `@repo/core-testing/factory` — `defineFactory(builder)` for test data factories -- `@repo/core-testing/contract` — `defineContractSuite(name, suite)` for cross-impl contract tests -- `@repo/core-testing/react` — `renderWithProviders`, `createMockTrpcClient` -- `@repo/core-testing/payload` — `stubPayloadConfig`, `mockPayloadModule` -- `@repo/core-testing/setup/jsdom` — vitest setupFile (jest-dom + cleanup) -- `@repo/core-testing/setup/node` — vitest setupFile (no-op placeholder) - -## Adding a factory - -```typescript -import { defineFactory } from "@repo/core-testing/factory"; - -export const articleFactory = defineFactory
(({ sequence }) => ({ - id: `article-${sequence}`, - title: `Article ${sequence}`, - // stable defaults — overrides drive variation -})); -``` - -## Adding a contract suite - -See `docs/guides/tdd-workflow.md` §"Contract suite usage". -``` - -- [ ] **Step 21: Verify everything** - -```bash -pnpm install -pnpm typecheck --filter @repo/core-testing -pnpm test --filter @repo/core-testing -pnpm lint --filter @repo/core-testing -pnpm turbo boundaries -``` - -Expected: all green; 7 tests pass. - -- [ ] **Step 22: Commit** - -```bash -git add packages/core-testing tsconfig.base.json -git commit -m "feat(core-testing): scaffold shared testing utilities package - -Adds @repo/core-testing (tag: tooling) with: -- factory/defineFactory: monotonic-sequence object factories with overrides -- contract/defineContractSuite: shared test suites runnable against multiple impls -- react/renderWithProviders + createMockTrpcClient: RTL helpers -- payload/stubPayloadConfig + mockPayloadModule: Payload mocking helpers -- setup/{jsdom,node}: vitest setup files - -Spec: docs/superpowers/specs/2026-05-05-tdd-foundation-design.md §5" -``` - ---- - -### Task 2: Vitest base configs (jsdom + node) in core-typescript - -**Files:** -- Modify: `packages/core-typescript/vitest.base.ts` → split into node/jsdom -- Create: `packages/core-typescript/vitest.base.node.ts` -- Create: `packages/core-typescript/vitest.base.jsdom.ts` -- Modify: `packages/core-typescript/package.json` (exports) -- Modify: 5 feature `vitest.config.ts` to use new base + alias -- Modify: `packages/core-shared/vitest.config.ts` - -- [ ] **Step 1: Write failing test for the new node base** - -`packages/core-typescript/vitest.base.node.test.ts`: -```typescript -import { describe, it, expect } from "vitest"; -import { nodeVitestConfig } from "./vitest.base.node"; - -describe("nodeVitestConfig", () => { - it("uses node environment", () => { - expect(nodeVitestConfig.test?.environment).toBe("node"); - }); - it("enables clearMocks, restoreMocks, mockReset", () => { - expect(nodeVitestConfig.test?.clearMocks).toBe(true); - expect(nodeVitestConfig.test?.restoreMocks).toBe(true); - expect(nodeVitestConfig.test?.mockReset).toBe(true); - }); - it("declares coverage thresholds", () => { - expect(nodeVitestConfig.test?.coverage?.thresholds).toMatchObject({ - statements: 80, branches: 75, functions: 80, lines: 80, - }); - }); - it("includes src and tests glob", () => { - expect(nodeVitestConfig.test?.include).toEqual( - expect.arrayContaining(["src/**/*.test.ts", "tests/**/*.test.ts"]), - ); - }); - it("excludes factories and contracts from coverage", () => { - expect(nodeVitestConfig.test?.coverage?.exclude).toEqual( - expect.arrayContaining(["src/__factories__/**", "src/__contracts__/**"]), - ); - }); -}); -``` - -- [ ] **Step 2: Run to verify RED** - -```bash -cd packages/core-typescript && pnpm test -``` - -Expected: FAIL — module does not exist. - -- [ ] **Step 3: Implement `vitest.base.node.ts`** - -`packages/core-typescript/vitest.base.node.ts`: -```typescript -import { defineConfig } from "vitest/config"; - -export const nodeVitestConfig = defineConfig({ - test: { - globals: true, - environment: "node", - include: ["src/**/*.test.ts", "tests/**/*.test.ts"], - setupFiles: ["@repo/core-testing/setup/node"], - clearMocks: true, - restoreMocks: true, - mockReset: true, - unstubGlobals: true, - sequence: { shuffle: true }, - coverage: { - provider: "v8", - reporter: ["text", "html", "lcov"], - include: ["src/**"], - exclude: [ - "src/**/*.test.{ts,tsx}", - "src/**/index.ts", - "src/__factories__/**", - "src/__contracts__/**", - ], - thresholds: { - statements: 80, branches: 75, functions: 80, lines: 80, - }, - }, - }, -}); -``` - -- [ ] **Step 4: Write failing test for jsdom base** - -`packages/core-typescript/vitest.base.jsdom.test.ts`: -```typescript -import { describe, it, expect } from "vitest"; -import { jsdomVitestConfig } from "./vitest.base.jsdom"; - -describe("jsdomVitestConfig", () => { - it("uses jsdom environment", () => { - expect(jsdomVitestConfig.test?.environment).toBe("jsdom"); - }); - it("loads the jsdom setup file", () => { - expect(jsdomVitestConfig.test?.setupFiles).toEqual( - expect.arrayContaining(["@repo/core-testing/setup/jsdom"]), - ); - }); - it("includes tsx files", () => { - expect(jsdomVitestConfig.test?.include).toEqual( - expect.arrayContaining(["src/**/*.test.{ts,tsx}", "tests/**/*.test.{ts,tsx}"]), - ); - }); - it("inherits clearMocks from node base", () => { - expect(jsdomVitestConfig.test?.clearMocks).toBe(true); - }); -}); -``` - -- [ ] **Step 5: Run to verify RED** - -```bash -cd packages/core-typescript && pnpm test -``` - -Expected: FAIL — module does not exist. - -- [ ] **Step 6: Implement `vitest.base.jsdom.ts`** - -`packages/core-typescript/vitest.base.jsdom.ts`: -```typescript -import { defineConfig, mergeConfig } from "vitest/config"; -import { nodeVitestConfig } from "./vitest.base.node.js"; - -export const jsdomVitestConfig = mergeConfig( - nodeVitestConfig, - defineConfig({ - test: { - environment: "jsdom", - setupFiles: ["@repo/core-testing/setup/jsdom"], - include: ["src/**/*.test.{ts,tsx}", "tests/**/*.test.{ts,tsx}"], - }, - }), -); -``` - -- [ ] **Step 7: Update package.json exports** - -Edit `packages/core-typescript/package.json` exports map to add: -```json -"./vitest.base.node": "./vitest.base.node.ts", -"./vitest.base.jsdom": "./vitest.base.jsdom.ts" -``` -(Keep existing `./vitest.base` for backwards-compat — it should re-export `nodeVitestConfig` as `baseVitestConfig` from `vitest.base.ts`.) - -- [ ] **Step 8: Update legacy `vitest.base.ts`** - -`packages/core-typescript/vitest.base.ts`: -```typescript -// Backwards-compat re-export. New code should import from -// vitest.base.node or vitest.base.jsdom directly. -export { nodeVitestConfig as baseVitestConfig } from "./vitest.base.node.js"; -``` - -- [ ] **Step 9: Add devDependency on @repo/core-testing for the type aliases used by setupFiles** - -Edit `packages/core-typescript/package.json` devDependencies — add `"@repo/core-testing": "workspace:*"`. - -- [ ] **Step 10: Run tests for core-typescript to verify GREEN** - -```bash -pnpm install -cd packages/core-typescript && pnpm test -``` - -Expected: 9/9 PASS. - -- [ ] **Step 11: Migrate feature vitest configs** - -For each of `packages/{auth,blog,marketing-pages,navigation,core-shared}/vitest.config.ts`, replace contents with: - -```typescript -import path from "node:path"; -import { mergeConfig } from "vitest/config"; -import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node"; - -export default mergeConfig(nodeVitestConfig, { - resolve: { - alias: { "@": path.resolve(__dirname, "./src") }, - }, -}); -``` - -- [ ] **Step 12: Run all tests to confirm migration didn't break anything** - -```bash -pnpm test -``` - -Expected: existing 96 tests still pass (some may now flag coverage threshold misses — note them, will fix in Task 12). - -- [ ] **Step 13: Commit** - -```bash -git add packages/core-typescript packages/auth/vitest.config.ts packages/blog/vitest.config.ts packages/marketing-pages/vitest.config.ts packages/navigation/vitest.config.ts packages/core-shared/vitest.config.ts -git commit -m "feat(core-typescript): split vitest base into node + jsdom flavors - -Adds vitest.base.node and vitest.base.jsdom with safety defaults -(clearMocks, restoreMocks, mockReset, unstubGlobals, sequence.shuffle) -and coverage thresholds (80/75/80/80). Migrates all feature configs -to the new base. Existing baseVitestConfig kept as backwards-compat -re-export of nodeVitestConfig. - -Spec: §6.2" -``` - ---- - -### Task 3: Add factories to all 5 features - -**Files:** -- Create: `packages/auth/src/__factories__/user.factory.ts` -- Create: `packages/auth/src/__factories__/user.factory.test.ts` -- Create: `packages/auth/src/__factories__/index.ts` -- Create: `packages/blog/src/__factories__/article.factory.ts` -- Create: `packages/blog/src/__factories__/article.factory.test.ts` -- Create: `packages/blog/src/__factories__/index.ts` -- Create: `packages/marketing-pages/src/__factories__/page.factory.ts` -- Create: `packages/marketing-pages/src/__factories__/page.factory.test.ts` -- Create: `packages/marketing-pages/src/__factories__/site-settings.factory.ts` -- Create: `packages/marketing-pages/src/__factories__/index.ts` -- Create: `packages/navigation/src/__factories__/header.factory.ts` -- Create: `packages/navigation/src/__factories__/header.factory.test.ts` -- Create: `packages/navigation/src/__factories__/index.ts` -- Create: `packages/media/src/__factories__/media.factory.ts` -- Create: `packages/media/src/__factories__/media.factory.test.ts` -- Create: `packages/media/src/__factories__/index.ts` -- Modify: each feature's `package.json` — add `@repo/core-testing` devDependency -- Modify: existing tests in each feature to use factories where it cuts boilerplate (lightweight pass; do not rewrite working tests just for stylistic reasons) - -- [ ] **Step 1: Write failing test for `articleFactory`** - -`packages/blog/src/__factories__/article.factory.test.ts`: -```typescript -import { describe, it, expect, beforeEach } from "vitest"; -import { articleFactory } from "@/__factories__/article.factory"; - -describe("articleFactory", () => { - beforeEach(() => articleFactory.reset()); - it("returns an Article with stable defaults", () => { - const a = articleFactory.build(); - expect(a.title).toBe("Article 1"); - expect(a.slug).toBe("article-1"); - expect(a.status).toBe("draft"); - expect(a.createdAt).toEqual(new Date("2026-01-01T00:00:00Z")); - }); - it("applies overrides", () => { - const a = articleFactory.build({ status: "published", title: "X" }); - expect(a.status).toBe("published"); - expect(a.title).toBe("X"); - }); -}); -``` - -- [ ] **Step 2: Run to verify RED** - -```bash -cd packages/blog && pnpm test -``` - -Expected: FAIL — `article.factory.ts` does not exist. - -- [ ] **Step 3: Implement factory + add devDependency** - -Edit `packages/blog/package.json` to add `"@repo/core-testing": "workspace:*"` under `devDependencies`. Run `pnpm install`. - -`packages/blog/src/__factories__/article.factory.ts`: -```typescript -import { defineFactory } from "@repo/core-testing/factory"; -import type { Article } from "../entities/article.js"; - -export const articleFactory = defineFactory
(({ sequence }) => ({ - id: `article-${sequence}`, - title: `Article ${sequence}`, - slug: `article-${sequence}`, - content: null, - status: "draft", - authorId: "user-1", - createdAt: new Date("2026-01-01T00:00:00Z"), - updatedAt: new Date("2026-01-01T00:00:00Z"), -})); -``` - -`packages/blog/src/__factories__/index.ts`: -```typescript -export { articleFactory } from "./article.factory.js"; -``` - -- [ ] **Step 4: Run to verify GREEN** - -```bash -cd packages/blog && pnpm test -``` - -Expected: existing tests still pass + 2 new factory tests pass. - -- [ ] **Step 5: Repeat steps 1-4 for each remaining factory** - -For each, write a small `*.factory.test.ts` (2-3 tests), then implement: - -`packages/auth/src/__factories__/user.factory.ts`: -```typescript -import { defineFactory } from "@repo/core-testing/factory"; -import type { User } from "../entities/user.js"; - -export const userFactory = defineFactory(({ sequence }) => ({ - id: `user-${sequence}`, - email: `user${sequence}@example.com`, - role: "user", - createdAt: new Date("2026-01-01T00:00:00Z"), - updatedAt: new Date("2026-01-01T00:00:00Z"), -})); -``` -Adjust fields to match the actual `User` entity. Read `packages/auth/src/entities/user.ts` first. - -`packages/marketing-pages/src/__factories__/page.factory.ts`: -```typescript -import { defineFactory } from "@repo/core-testing/factory"; -import type { Page } from "../entities/page.js"; - -export const pageFactory = defineFactory(({ sequence }) => ({ - id: `page-${sequence}`, - slug: `page-${sequence}`, - title: `Page ${sequence}`, - content: null, - createdAt: new Date("2026-01-01T00:00:00Z"), - updatedAt: new Date("2026-01-01T00:00:00Z"), -})); -``` -Read `packages/marketing-pages/src/entities/page.ts` and adapt. - -`packages/marketing-pages/src/__factories__/site-settings.factory.ts`: -```typescript -import { defineFactory } from "@repo/core-testing/factory"; -import type { SiteSettings } from "../entities/site-settings.js"; - -export const siteSettingsFactory = defineFactory(({ sequence }) => ({ - id: `settings-${sequence}`, - siteName: `Site ${sequence}`, - // adapt to actual entity -})); -``` -Read `packages/marketing-pages/src/entities/site-settings.ts` and adapt. - -`packages/navigation/src/__factories__/header.factory.ts`: -```typescript -import { defineFactory } from "@repo/core-testing/factory"; -import type { Header } from "../entities/header.js"; - -export const headerFactory = defineFactory
(({ sequence }) => ({ - id: `header-${sequence}`, - navItems: [], - // adapt to actual entity -})); -``` -Read `packages/navigation/src/entities/header.ts` and adapt. - -`packages/media/src/__factories__/media.factory.ts`: -Media has no entity layer currently — create a minimal `Media` interface in this factory's file or skip if no consumer needs it. Read `packages/media/src` first; if there's no entity, write the factory to return a Payload Media doc shape: -```typescript -import { defineFactory } from "@repo/core-testing/factory"; - -export interface Media { - id: string; - alt: string; - url: string; - filename: string; - mimeType: string; - filesize: number; -} - -export const mediaFactory = defineFactory(({ sequence }) => ({ - id: `media-${sequence}`, - alt: `Media ${sequence}`, - url: `https://cdn.example.com/media-${sequence}.png`, - filename: `media-${sequence}.png`, - mimeType: "image/png", - filesize: 1024, -})); -``` - -- [ ] **Step 6: Refactor existing tests to consume factories where it removes boilerplate** - -In each feature, find tests that inline raw fixture objects (like `packages/blog/src/application/use-cases/get-articles.use-case.test.ts:23-32`) and replace with `articleFactory.build({ overrides })`. Light pass — only edit tests where the change is mechanical and obviously correct. - -Example refactor: -```typescript -// before: -await repo.createArticle({ - id: "1", title: "A", slug: "a", content: null, status: "draft", - authorId: "u1", createdAt: now, updatedAt: now, -}); -// after: -await repo.createArticle(articleFactory.build({ id: "1", title: "A", slug: "a" })); -``` - -- [ ] **Step 7: Run all tests** - -```bash -pnpm test -``` - -Expected: all tests pass; factories add ~10 new tests. - -- [ ] **Step 8: Commit** - -```bash -git add packages/auth packages/blog packages/marketing-pages packages/navigation packages/media -git commit -m "feat(features): add test factories to all 5 features - -Adds src/__factories__/.factory.ts to auth, blog, marketing-pages, -navigation, media. Each factory uses defineFactory from @repo/core-testing -with stable date defaults (2026-01-01) so snapshot diffs reflect SUT -behavior only. Refactors mechanical inline-fixture tests to use factories. - -Spec: §5.1, §6.3" -``` - ---- - -### Task 4: Contract suites for repository interfaces - -**Files:** -- Create: `packages/blog/src/__contracts__/articles-repository.contract.ts` -- Create: `packages/auth/src/__contracts__/users-repository.contract.ts` -- Create: `packages/marketing-pages/src/__contracts__/pages-repository.contract.ts` -- Create: `packages/marketing-pages/src/__contracts__/site-settings-repository.contract.ts` -- Create: `packages/navigation/src/__contracts__/header-repository.contract.ts` -- Modify: each `*.repository.test.ts` (mock + payload impls) to invoke `contract.run()` - -- [ ] **Step 1: Write failing contract test for `IArticlesRepository`** - -`packages/blog/src/__contracts__/articles-repository.contract.ts`: -```typescript -import { it, expect, beforeEach } from "vitest"; -import { defineContractSuite } from "@repo/core-testing/contract"; -import type { IArticlesRepository } from "../application/repositories/articles-repository.interface.js"; -import { articleFactory } from "../__factories__/article.factory.js"; - -export const articlesRepositoryContract = defineContractSuite( - "IArticlesRepository", - ({ buildSubject }) => { - let repo: IArticlesRepository; - beforeEach(async () => { - articleFactory.reset(); - repo = await buildSubject(); - }); - - it("createArticle persists then getArticleBySlug returns it", async () => { - const seed = articleFactory.build({ slug: "x" }); - await repo.createArticle(seed); - const result = await repo.getArticleBySlug("x"); - expect(result?.id).toBe(seed.id); - }); - - it("getArticleBySlug returns undefined for missing slug", async () => { - expect(await repo.getArticleBySlug("does-not-exist")).toBeUndefined(); - }); - - it("listArticles returns all when no filter", async () => { - await repo.createArticle(articleFactory.build()); - await repo.createArticle(articleFactory.build()); - const list = await repo.listArticles(); - expect(list).toHaveLength(2); - }); - - it("listArticles filters by status", async () => { - await repo.createArticle(articleFactory.build({ status: "draft" })); - await repo.createArticle(articleFactory.build({ status: "published" })); - const drafts = await repo.listArticles({ status: "draft" }); - expect(drafts).toHaveLength(1); - expect(drafts[0]?.status).toBe("draft"); - }); - }, -); -``` - -(If the actual `IArticlesRepository` interface differs, adjust assertions to match. Read the interface file first.) - -- [ ] **Step 2: Wire contract into mock impl test** - -Create or edit `packages/blog/src/infrastructure/repositories/mock-articles.repository.test.ts`: -```typescript -import { describe } from "vitest"; -import { MockArticlesRepository } from "./mock-articles.repository"; -import { articlesRepositoryContract } from "../../__contracts__/articles-repository.contract"; - -describe("MockArticlesRepository", () => { - articlesRepositoryContract.run(() => new MockArticlesRepository()); -}); -``` - -- [ ] **Step 3: Run to verify GREEN for mock impl** - -```bash -cd packages/blog && pnpm test mock-articles.repository -``` - -Expected: 4 contract tests pass against MockArticlesRepository. If any fail, the mock has a bug — fix the mock to match the contract. - -- [ ] **Step 4: Wire contract into payload impl test** - -Edit `packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts` — wrap existing impl-specific tests in their own describe and add a contract block. Use `vi.mock('payload')` to back the contract's repo with an in-memory store: - -```typescript -import { describe, vi, beforeEach } from "vitest"; -import { PayloadArticlesRepository } from "./payload-articles.repository"; -import { articlesRepositoryContract } from "../../__contracts__/articles-repository.contract"; -import { stubPayloadConfig } from "@repo/core-testing/payload"; - -// Build an in-memory Payload-shaped store so the same contract suite runs. -function buildPayloadStub() { - const store = new Map(); - return { - create: vi.fn(async ({ data }: { data: { id: string } & Record }) => { - store.set(data.id, data); - return data; - }), - find: vi.fn(async ({ where }: { where?: { slug?: { equals: string } } }) => { - const all = Array.from(store.values()) as Array<{ slug: string }>; - const docs = where?.slug ? all.filter((d) => d.slug === where.slug?.equals) : all; - return { docs }; - }), - findByID: vi.fn(async ({ id }: { id: string }) => store.get(id)), - }; -} - -vi.mock("payload", () => ({ - getPayload: vi.fn(), -})); - -describe("PayloadArticlesRepository", () => { - describe("contract", () => { - articlesRepositoryContract.run(async () => { - const stub = buildPayloadStub(); - const { getPayload } = await import("payload"); - (getPayload as ReturnType).mockResolvedValue(stub); - return new PayloadArticlesRepository(stubPayloadConfig); - }); - }); - - // ... keep existing impl-specific tests (Payload doc → domain mapping, etc.) -}); -``` - -If the existing tests cover specific Payload-doc-to-domain mapping cases that the contract can't hit (e.g., the `author` field becoming `authorId`), keep those as separate `it` blocks alongside the contract. - -- [ ] **Step 5: Run to verify GREEN for payload impl** - -```bash -cd packages/blog && pnpm test payload-articles.repository -``` - -Expected: contract tests pass against PayloadArticlesRepository (with the in-memory stub). - -- [ ] **Step 6: Repeat for the remaining 4 contracts** - -For each of: -- `IUsersRepository` (auth) — mock only currently; contract still defined for future Payload impl -- `IPagesRepository` (marketing-pages) — mock + payload -- `ISiteSettingsRepository` (marketing-pages) — mock + payload -- `IHeaderRepository` (navigation) — mock + payload - -Read the interface, draft the contract suite (4-8 cases per repo), wire into both impls (mock first, then payload via in-memory stub). - -- [ ] **Step 7: Run all tests** - -```bash -pnpm test -``` - -Expected: existing 96 + ~30 new contract assertions pass. - -- [ ] **Step 8: Commit** - -```bash -git add packages/{auth,blog,marketing-pages,navigation}/src/__contracts__ packages/{auth,blog,marketing-pages,navigation}/src/infrastructure/repositories/*.test.ts -git commit -m "feat(features): contract suites for all repository interfaces - -Each repository interface now has a contract suite under -src/__contracts__/. Both Mock and Payload implementations run the -same suite, eliminating mock-vs-real drift. Payload impls back the -contract with an in-memory stub via vi.mock('payload') + a small -buildPayloadStub helper. - -Spec: §5.2, §6.4" -``` - ---- - -### Task 5: Tests + jsdom config for `core-ui` - -**Files:** -- Create: `packages/core-ui/vitest.config.ts` -- Modify: `packages/core-ui/package.json` — add devDeps + scripts -- Create: `packages/core-ui/src/atoms/button/button.test.tsx` -- Create: `packages/core-ui/src/atoms/input/input.test.tsx` -- Create: `packages/core-ui/src/atoms/label/label.test.tsx` -- Create at least one `.test.tsx` per discovered molecule and organism (read `packages/core-ui/src/{molecules,organisms,templates}/` first) - -- [ ] **Step 1: Add devDependencies** - -Edit `packages/core-ui/package.json`: -```json -{ - "scripts": { - "build": "tsc --noEmit", - "lint": "eslint .", - "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests" - }, - "devDependencies": { - "@repo/core-eslint": "workspace:*", - "@repo/core-testing": "workspace:*", - "@repo/core-typescript": "workspace:*", - "@storybook/react": "^8.6.0", - "@testing-library/jest-dom": "^6.5.0", - "@testing-library/react": "^16.0.0", - "@testing-library/user-event": "^14.5.0", - "@types/react": "^19.0.0", - "jsdom": "^25.0.0", - "vitest": "^3.0.0" - } -} -``` - -Run `pnpm install`. - -- [ ] **Step 2: Create `vitest.config.ts`** - -`packages/core-ui/vitest.config.ts`: -```typescript -import path from "node:path"; -import { mergeConfig } from "vitest/config"; -import { jsdomVitestConfig } from "@repo/core-typescript/vitest.base.jsdom"; - -export default mergeConfig(jsdomVitestConfig, { - resolve: { - alias: { "@": path.resolve(__dirname, "./src") }, - }, -}); -``` - -- [ ] **Step 3: Write failing test for Button** - -`packages/core-ui/src/atoms/button/button.test.tsx`: -```typescript -import { describe, it, expect, vi } from "vitest"; -import { renderWithProviders } from "@repo/core-testing/react"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { Button } from "./button"; - -describe("Button", () => { - it("renders children inside a button", () => { - renderWithProviders(); - expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument(); - }); - - it("calls onClick when activated", async () => { - const handleClick = vi.fn(); - renderWithProviders(); - await userEvent.click(screen.getByRole("button", { name: "Go" })); - expect(handleClick).toHaveBeenCalledOnce(); - }); - - it("applies the variant class", () => { - renderWithProviders(); - expect(screen.getByRole("button")).toHaveClass(/destructive/); - }); - - it("applies the size class", () => { - renderWithProviders(); - expect(screen.getByRole("button")).toHaveClass(/h-11/); - }); - - it("disabled prop sets the attribute", () => { - renderWithProviders(); - expect(screen.getByRole("button")).toBeDisabled(); - }); -}); -``` - -- [ ] **Step 4: Run to verify GREEN** - -```bash -cd packages/core-ui && pnpm test -``` - -Expected: 5/5 PASS (Button already exists). - -- [ ] **Step 5: Repeat for Input, Label** - -Read each component file first, then write a small test suite that exercises rendering, props, and one user interaction. - -`packages/core-ui/src/atoms/input/input.test.tsx`: -```typescript -import { describe, it, expect } from "vitest"; -import { renderWithProviders } from "@repo/core-testing/react"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { Input } from "./input"; - -describe("Input", () => { - it("renders an input element", () => { - renderWithProviders(); - expect(screen.getByPlaceholderText("email")).toBeInTheDocument(); - }); - - it("accepts user input", async () => { - renderWithProviders(); - const input = screen.getByPlaceholderText("email"); - await userEvent.type(input, "hi@example.com"); - expect(input).toHaveValue("hi@example.com"); - }); -}); -``` - -`packages/core-ui/src/atoms/label/label.test.tsx`: -```typescript -import { describe, it, expect } from "vitest"; -import { renderWithProviders } from "@repo/core-testing/react"; -import { screen } from "@testing-library/react"; -import { Label } from "./label"; - -describe("Label", () => { - it("renders the text", () => { - renderWithProviders(); - expect(screen.getByText("Email")).toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 6: Discover and test molecules + organisms + templates** - -```bash -ls packages/core-ui/src/molecules packages/core-ui/src/organisms packages/core-ui/src/templates -``` - -For each component discovered, create a corresponding `*.test.tsx` with at least: -1. A "renders" smoke test -2. One prop-driven assertion -3. One interaction test if the component handles user events - -Keep tests minimal but real. The goal is establishing the pattern + at least one test per file, not exhaustive coverage in this task. - -- [ ] **Step 7: Run all core-ui tests** - -```bash -cd packages/core-ui && pnpm test -``` - -Expected: all new tests pass. - -- [ ] **Step 8: Commit** - -```bash -git add packages/core-ui -git commit -m "feat(core-ui): add jsdom Vitest config + RTL tests for components - -Adopts jsdomVitestConfig from @repo/core-typescript. Adds -@testing-library/react, @testing-library/user-event, jsdom devDeps. -Writes smoke + interaction tests for every atom/molecule/organism/template -using renderWithProviders from @repo/core-testing/react. - -Spec: §6.1, §6.5" -``` - ---- - -### Task 6: Tests + node config for `core-api`, `core-cms`, `core-trpc` - -**Files:** -- Create: `packages/core-api/vitest.config.ts` -- Create: `packages/core-api/src/router.test.ts` -- Modify: `packages/core-api/package.json` — add test script + devDeps -- Create: `packages/core-cms/vitest.config.ts` -- Create: `packages/core-cms/src/payload.config.test.ts` -- Modify: `packages/core-cms/package.json` — add test script + devDeps -- Create: `packages/core-trpc/vitest.config.ts` -- Create: `packages/core-trpc/src/client.test.ts` -- Modify: `packages/core-trpc/package.json` — add test script + devDeps - -- [ ] **Step 1: Add vitest devDep + script to core-api** - -Edit `packages/core-api/package.json`: -- Scripts: add `"test": "vitest run --passWithNoTests"` -- DevDependencies: add `"@repo/core-testing": "workspace:*"`, `"vitest": "^3.0.0"` - -Run `pnpm install`. - -- [ ] **Step 2: Create vitest.config.ts** - -`packages/core-api/vitest.config.ts`: -```typescript -import path from "node:path"; -import { mergeConfig } from "vitest/config"; -import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node"; - -export default mergeConfig(nodeVitestConfig, { - resolve: { alias: { "@": path.resolve(__dirname, "./src") } }, -}); -``` - -- [ ] **Step 3: Write failing composition test** - -`packages/core-api/src/router.test.ts`: -```typescript -import { describe, it, expect } from "vitest"; -import { appRouter } from "./root"; - -describe("appRouter composition", () => { - it("exposes auth, blog, marketingPages, navigation routers", () => { - const procedures = appRouter._def.procedures; - expect(Object.keys(procedures)).toEqual( - expect.arrayContaining(["auth", "blog", "marketingPages", "navigation"]), - ); - }); - - it("blog router has expected procedures", () => { - const blog = (appRouter._def.procedures as Record } }>).blog; - expect(blog._def.procedures).toHaveProperty("articleBySlug"); - expect(blog._def.procedures).toHaveProperty("listArticles"); - }); -}); -``` - -- [ ] **Step 4: Run to verify GREEN** - -```bash -cd packages/core-api && pnpm test -``` - -Expected: 2/2 PASS. If procedure shape differs, adjust assertions to match reality (the goal is verifying composition, not enforcing a specific shape). - -- [ ] **Step 5: Repeat for core-cms** - -Edit `packages/core-cms/package.json` similarly. - -`packages/core-cms/vitest.config.ts`: same pattern. - -`packages/core-cms/src/payload.config.test.ts`: -```typescript -import { describe, it, expect } from "vitest"; -import config from "./payload.config"; - -describe("payloadConfig composition", () => { - it("registers all feature collections", async () => { - const resolved = await config; - const slugs = resolved.collections?.map((c) => c.slug) ?? []; - expect(slugs).toEqual(expect.arrayContaining(["users", "articles", "pages", "media"])); - }); - - it("registers all feature globals", async () => { - const resolved = await config; - const slugs = resolved.globals?.map((g) => g.slug) ?? []; - expect(slugs).toEqual(expect.arrayContaining(["site-settings", "header"])); - }); -}); -``` - -(`buildConfig` returns either a Promise or value; awaiting works for both.) - -- [ ] **Step 6: Repeat for core-trpc** - -Edit `packages/core-trpc/package.json` similarly. - -`packages/core-trpc/vitest.config.ts`: same pattern, but jsdom (provider tests): -```typescript -import path from "node:path"; -import { mergeConfig } from "vitest/config"; -import { jsdomVitestConfig } from "@repo/core-typescript/vitest.base.jsdom"; - -export default mergeConfig(jsdomVitestConfig, { - resolve: { alias: { "@": path.resolve(__dirname, "./src") } }, -}); -``` - -`packages/core-trpc/src/client.test.ts`: -```typescript -import { describe, it, expect } from "vitest"; -import { useTRPC, TRPCProvider } from "./client"; - -describe("core-trpc client exports", () => { - it("exports useTRPC hook", () => { - expect(useTRPC).toBeTypeOf("function"); - }); - it("exports TRPCProvider component", () => { - expect(TRPCProvider).toBeTypeOf("function"); - }); -}); -``` - -Add a more meaningful provider test if the providers/ folder has stable wiring to assert (e.g., assert `httpBatchLink` config includes `superjson`). - -- [ ] **Step 7: Run all tests** - -```bash -pnpm test -``` - -Expected: existing tests + 6+ new core-* tests pass. - -- [ ] **Step 8: Commit** - -```bash -git add packages/core-api packages/core-cms packages/core-trpc -git commit -m "feat(core-*): add Vitest configs + composition tests - -core-api: appRouter exposes all 4 feature routers + blog procedure shape. -core-cms: payloadConfig registers all collections + globals. -core-trpc: client + provider exports verified. - -Spec: §6.1, §6.6" -``` - ---- - -### Task 7: Unit tests for apps - -**Files:** -- Create: `apps/web-next/vitest.config.ts` -- Create: `apps/web-next/src/server/bind-production.test.ts` -- Create: `apps/web-next/src/app/providers.test.tsx` -- Modify: `apps/web-next/package.json` — add test script + devDeps -- Create: `apps/web-tanstack/vitest.config.ts` -- Create: `apps/web-tanstack/src/.test.tsx` -- Modify: `apps/web-tanstack/package.json` — add test script + devDeps -- Create: `apps/cms/vitest.config.ts` -- Create: `apps/cms/src/payload.config.test.ts` (or wherever the config is exported) -- Modify: `apps/cms/package.json` — add test script + devDeps - -- [ ] **Step 1: Add devDeps and script to web-next** - -Edit `apps/web-next/package.json`: -- Scripts: `"test": "vitest run --passWithNoTests"` -- DevDependencies: `"@repo/core-testing": "workspace:*"`, `"vitest": "^3.0.0"`, plus `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`, `jsdom` if not already present. - -Run `pnpm install`. - -- [ ] **Step 2: Create vitest.config.ts** - -`apps/web-next/vitest.config.ts`: -```typescript -import path from "node:path"; -import { mergeConfig } from "vitest/config"; -import { jsdomVitestConfig } from "@repo/core-typescript/vitest.base.jsdom"; - -export default mergeConfig(jsdomVitestConfig, { - resolve: { alias: { "@": path.resolve(__dirname, "./src") } }, -}); -``` - -- [ ] **Step 3: Write failing test for `bindAllProduction`** - -`apps/web-next/src/server/bind-production.test.ts`: -```typescript -import { describe, it, expect, vi, beforeEach } from "vitest"; - -vi.mock("@repo/core-cms", () => ({ default: Promise.resolve({}) })); -vi.mock("@repo/blog/di/bind-production", () => ({ bindProductionBlog: vi.fn() })); -vi.mock("@repo/auth/di/bind-production", () => ({ bindProductionAuth: vi.fn() })); -vi.mock("@repo/marketing-pages/di/bind-production", () => ({ bindProductionMarketingPages: vi.fn() })); -vi.mock("@repo/navigation/di/bind-production", () => ({ bindProductionNavigation: vi.fn() })); - -describe("bindAllProduction", () => { - beforeEach(() => { - vi.resetModules(); - vi.clearAllMocks(); - }); - - it("binds all four feature production repos", async () => { - const { bindAllProduction } = await import("./bind-production"); - const { bindProductionBlog } = await import("@repo/blog/di/bind-production"); - const { bindProductionAuth } = await import("@repo/auth/di/bind-production"); - const { bindProductionMarketingPages } = await import("@repo/marketing-pages/di/bind-production"); - const { bindProductionNavigation } = await import("@repo/navigation/di/bind-production"); - - await bindAllProduction(); - - expect(bindProductionBlog).toHaveBeenCalledOnce(); - expect(bindProductionAuth).toHaveBeenCalledOnce(); - expect(bindProductionMarketingPages).toHaveBeenCalledOnce(); - expect(bindProductionNavigation).toHaveBeenCalledOnce(); - }); - - it("is idempotent — second call does not re-bind", async () => { - const { bindAllProduction } = await import("./bind-production"); - const { bindProductionBlog } = await import("@repo/blog/di/bind-production"); - await bindAllProduction(); - await bindAllProduction(); - expect(bindProductionBlog).toHaveBeenCalledOnce(); - }); -}); -``` - -- [ ] **Step 4: Run to verify GREEN** - -```bash -cd apps/web-next && pnpm test -``` - -Expected: 2/2 PASS. - -- [ ] **Step 5: Write failing providers test** - -Read `apps/web-next/src/app/providers.tsx` to understand its shape, then: - -`apps/web-next/src/app/providers.test.tsx`: -```typescript -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { Providers } from "./providers"; - -describe("Providers", () => { - it("renders children", () => { - render( - -
hi
-
, - ); - expect(screen.getByTestId("child")).toBeInTheDocument(); - }); -}); -``` - -If `Providers` requires server-side data (e.g., a tRPC initial state), pass it as a prop or mock the dependency. - -- [ ] **Step 6: Run to verify GREEN** - -```bash -cd apps/web-next && pnpm test -``` - -Expected: PASS. - -- [ ] **Step 7: Repeat for web-tanstack and cms** - -For `apps/web-tanstack`: -- vitest.config.ts (jsdom) -- One test asserting the providers wire `QueryClientProvider` -- One test asserting the equivalent of bindAllProduction (if applicable) - -For `apps/cms`: -- vitest.config.ts (node) -- A `payload.config.test.ts` that asserts the exported config has expected collections/globals (similar to core-cms but with the app-level config) - -- [ ] **Step 8: Run all tests** - -```bash -pnpm test -``` - -Expected: all tests pass. - -- [ ] **Step 9: Commit** - -```bash -git add apps/web-next apps/web-tanstack apps/cms -git commit -m "feat(apps): add unit tests for providers + bind-production + cms config - -web-next: bindAllProduction calls all 4 feature binders exactly once; -Providers renders children. web-tanstack: equivalent providers + bind tests. -cms: payload.config exports a SanitizedConfig with all expected collections. - -Spec: §6.7, §9" -``` - ---- - -### Task 8: Storybook test-runner integration - -**Files:** -- Modify: `apps/storybook/package.json` — add `@storybook/test-runner`, playwright, scripts -- Create: `apps/storybook/test-runner.config.ts` -- Create: `apps/storybook/.storybook/test-runner.test.ts` (smoke check the test-runner config loads) -- Modify: root `package.json` — add `test:stories` script -- Modify: root `turbo.json` — add `test-storybook` task - -- [ ] **Step 1: Add devDeps** - -Edit `apps/storybook/package.json` devDependencies: -```json -{ - "@storybook/test-runner": "^0.21.0", - "playwright": "^1.50.0", - "concurrently": "^9.0.0", - "http-server": "^14.0.0", - "wait-on": "^8.0.0" -} -``` - -And scripts: -```json -{ - "scripts": { - "build-storybook": "storybook build", - "test-storybook": "test-storybook --url http://localhost:6006", - "test:stories": "concurrently -k -s first -n 'SB,TEST' -c 'magenta,blue' 'pnpm exec http-server storybook-static --port 6006 --silent' 'pnpm exec wait-on tcp:6006 && pnpm test-storybook'" - } -} -``` - -Run `pnpm install`. - -- [ ] **Step 2: Create test-runner.config.ts** - -`apps/storybook/test-runner.config.ts`: -```typescript -import type { TestRunnerConfig } from "@storybook/test-runner"; - -const config: TestRunnerConfig = { - async preVisit(page) { - page.on("console", (msg) => { - if (msg.type() === "error") { - throw new Error(`Console error in story: ${msg.text()}`); - } - }); - }, -}; - -export default config; -``` - -- [ ] **Step 3: Add test script to root package.json** - -Edit root `package.json` scripts: -```json -{ - "scripts": { - "test:stories": "turbo run test:stories" - } -} -``` - -- [ ] **Step 4: Add task to root turbo.json** - -Edit root `turbo.json`: -```json -{ - "tasks": { - "test:stories": { - "dependsOn": ["build-storybook"], - "cache": false - }, - "build-storybook": { - "outputs": ["storybook-static/**"] - } - } -} -``` - -- [ ] **Step 5: Verify the storybook test runner works locally** - -```bash -pnpm install -pnpm exec playwright install --with-deps chromium -pnpm build-storybook --filter @repo/storybook -cd apps/storybook && pnpm test:stories -``` - -Expected: every story mounts without console errors. If any story fails, fix it (or document as a known issue with a TODO). - -- [ ] **Step 6: Commit** - -```bash -git add apps/storybook package.json turbo.json -git commit -m "feat(storybook): wire @storybook/test-runner for story smoke tests - -Every story is now executed as a smoke test (mount + no console errors) -via @storybook/test-runner. New script: pnpm test:stories runs -build-storybook then test-storybook against the static build. - -Spec: §6.8" -``` - ---- - -### Task 9: Documentation — `tdd-workflow.md` + restructure `adding-a-feature.md` - -**Files:** -- Create: `docs/guides/tdd-workflow.md` -- Modify: `docs/guides/adding-a-feature.md` — interleave tests with implementation -- Modify: `docs/guides/testing-strategy.md` — cross-link to tdd-workflow -- Modify: `AGENTS.md` — link both guides -- Modify: `CLAUDE.md` — add TDD subsection under Quick Start - -- [ ] **Step 1: Write `docs/guides/tdd-workflow.md`** - -Sections required (no placeholders): - -1. **Header + intent** (~50 words on why TDD here, not as dogma) -2. **The cycle** — Red, Green, Refactor with a fully worked blog example: write failing `getArticleBySlug` test → run → see fail → implement → run → see pass → refactor -3. **Test naming** — `describe(SubjectUnderTest)` + `it("does X when Y")` with 3 examples -4. **AAA** — Arrange / Act / Assert with two examples (use case + component) -5. **When to mock — decision tree** as a DOT graph block: - ``` - Pure function? → no mock - Use case? → rebind repository at DI level - Repository (Payload)?→ vi.mock('payload') + stubPayloadConfig - Component with data? → renderWithProviders with mocks - Route handler? → mock at boundary only - ``` -6. **Test pyramid for this monorepo** — table with ratios (entities ≥ use-cases ≥ controllers ≥ feature integration ≥ component ≥ e2e) -7. **What NOT to test** — bullet list (getters/setters, framework code, third-party libs, types-only modules, generated code) -8. **Coverage targets** — 80/75/80/80 baseline, 100% in entities/use-cases/controllers, how to inspect locally -9. **Factory usage** — when to call `factory.build()` vs hand-crafted object; how to add a new factory -10. **Contract suite usage** — how to add a new repo impl: write impl → run contract → fix until green; example with code -11. **Running tests** — watch mode, focused tests (`it.only`), debugging failures, `--coverage`, `pnpm test:stories`, `pnpm test:e2e` - -(Each section short and concrete; no "TODO" or "see elsewhere" without an actual link.) - -- [ ] **Step 2: Restructure `adding-a-feature.md`** - -Replace Part 2 ("Build the Layers") with an interleaved test-first sequence. Each layer becomes red→green: - -```markdown -### Step 1: Write failing test for entity schema - -Create `packages//src/entities/.test.ts`: -[concrete failing test code] - -Run: `pnpm test --filter @repo/` -Expected: FAIL — entity does not exist. - -### Step 2: Implement entity to pass -[concrete entity code] - -Run: `pnpm test --filter @repo/` -Expected: PASS. - -### Step 3: Write factory in src/__factories__/.factory.ts -[concrete factory code] - -### Step 4: Write failing test for use case -[concrete use case test using factory] - -### Step 5: Implement use case to pass -[concrete use case code] - -### Step 6: Write contract suite in src/__contracts__/-repository.contract.ts -[concrete contract code] - -### Step 7: Implement Mock repo, run contract -[concrete mock code] - -### Step 8: Implement Payload repo, run same contract -[concrete payload code with vi.mock setup] - -### Step 9: Write failing controller test -[concrete controller test] - -### Step 10: Implement controller to pass -[concrete controller code] - -### Step 11: Write failing tRPC integration test -[concrete tests/.feature.test.ts code] - -### Step 12: Wire router to pass -[concrete router code] - -### Step 13: (UI optional) Write failing component test -### Step 14: Implement component to pass -### Step 15: Wire into core-api / core-cms, run typecheck + lint + boundaries -``` - -Add at the top: -```markdown -> **TDD Order Required:** You may not advance to the next layer until the -> current layer's tests are red, then green. See `docs/guides/tdd-workflow.md`. -``` - -- [ ] **Step 3: Cross-link** - -Edit `docs/guides/testing-strategy.md` — add a sentence at the top: `For the *how* of TDD (red-green-refactor cycle, when to mock, what NOT to test), see ./tdd-workflow.md. This document covers test *placement* and infrastructure.` - -Edit `AGENTS.md` — under "Specification & Guides", add bullet: `**TDD Workflow** — docs/guides/tdd-workflow.md — red-green-refactor cycle, mocking decision tree, coverage targets`. - -Edit `CLAUDE.md` — under "Quick Start", add: -```markdown -## TDD - -```bash -pnpm test --watch --filter @repo/ # watch one feature -pnpm test -- --coverage # full run with coverage -pnpm test:stories # Storybook smoke tests -pnpm test:e2e # Playwright e2e -``` - -See `docs/guides/tdd-workflow.md` for the full cycle. -``` - -- [ ] **Step 4: Commit** - -```bash -git add docs/guides/tdd-workflow.md docs/guides/adding-a-feature.md docs/guides/testing-strategy.md AGENTS.md CLAUDE.md -git commit -m "docs(guides): add TDD workflow + restructure adding-a-feature for TDD order - -New: docs/guides/tdd-workflow.md — red-green-refactor cycle, AAA, -mocking decision tree, coverage targets, factory + contract usage. -Restructured: adding-a-feature.md interleaves tests with implementation; -TDD order is required, not optional. testing-strategy.md cross-links -the new guide. AGENTS.md and CLAUDE.md surface both. - -Spec: §7" -``` - ---- - -### Task 10: ADR-011 + per-feature AGENTS.md updates - -**Files:** -- Create: `docs/decisions/adr-011-tdd-foundation.md` -- Modify: `AGENTS.md` — add `@repo/core-testing` row to package map -- Modify: each feature `AGENTS.md` — add "Tests" section -- Modify: `packages/core-testing/AGENTS.md` (already created in Task 1; verify complete) - -- [ ] **Step 1: Write ADR-011** - -`docs/decisions/adr-011-tdd-foundation.md`: -```markdown -# ADR-011: TDD Foundation - -**Status:** Accepted -**Date:** 2026-05-05 -**Supersedes:** none -**Spec:** docs/superpowers/specs/2026-05-05-tdd-foundation-design.md - -## Context - -The vertical-feature monorepo refactor (ADRs 001-010) established -clean architecture with per-feature DI containers but did not enforce -TDD as the path of least resistance. Agentic workers were producing -code-first commits with tests added later, leading to test theatre and -mock/real drift. - -## Decision - -1. **New package `@repo/core-testing` (tag: tooling)** — shared test - utilities: defineFactory, defineContractSuite, renderWithProviders, - mock-payload helpers, jsdom setup file. Tagged `tooling` so any - package may depend on it as devDependency without boundary violation. - -2. **Vitest base configs split into node + jsdom** with safety defaults - (clearMocks, restoreMocks, mockReset, unstubGlobals, sequence.shuffle) - and coverage thresholds (80/75/80/80 baseline; 100% in entities + - use-cases + controllers). - -3. **Factories per feature** in `src/__factories__/` replace inline - fixtures. Stable date defaults (2026-01-01) so snapshot diffs reflect - SUT behavior only. - -4. **Contract suites per repository interface** in `src/__contracts__/` - run against every implementation (Mock + Payload). Eliminates the - class of bug where the mock and the real impl drift apart. - -5. **Tests in core-* packages and apps** — composition smoke tests - (appRouter, payloadConfig, bind-production, providers). - -6. **Storybook test-runner** — every story executed as a smoke test. - -7. **CI workflow** — typecheck + lint + boundaries + test + build + - e2e + storybook on every PR. Coverage uploaded as artifact. - -8. **Two new docs** — tdd-workflow.md (process) + restructured - adding-a-feature.md (interleaves tests with impl). - -## Alternatives considered - -- **Fixture files instead of factories** — rejected. Fixtures rot with - schema changes and require manual updates per test. -- **One shared test file per impl** — rejected. Contract suites give - the same coverage in fewer LOC and prevent drift. -- **Real Postgres in tests via testcontainers** — rejected for unit - tests (slow, complex). Repository contract suites + vi.mock('payload') - give equivalent confidence in milliseconds. -- **Stryker mutation testing** — deferred. Coverage thresholds + contract - suites get us most of the way; mutation testing is incremental. - -## Consequences - -- New package to maintain (small, mostly stable surface). -- Coverage thresholds may fail builds initially; we add tests to cross - threshold as part of Plan 7. -- Sequence shuffle may surface latent flakes; we fix as found. -- Templates for new features now require writing tests first; this is - by design. - -## Refines - -- ADR-006 (boundary tags) — adds @repo/core-testing as a tooling package. -``` - -- [ ] **Step 2: Update root AGENTS.md package map** - -In `AGENTS.md`, add row to the Package Map table: -```markdown -| `@repo/core-testing` | tooling | Shared test utilities (defineFactory, defineContractSuite, renderWithProviders, payload mocks) | -``` - -Update the "Five tags" section: `tooling (3 packages) — packages/core-eslint, core-typescript, core-testing`. - -- [ ] **Step 3: Add Tests section to each feature AGENTS.md** - -For each `packages//AGENTS.md`, add a section near the bottom: -```markdown -## Tests - -- **Factories:** `src/__factories__/.factory.ts` — use `factoryName.build({ overrides })` to construct test data with stable defaults. -- **Contract suite:** `src/__contracts__/-repository.contract.ts` — runs against every repository implementation (mock + payload). -- **Unit tests:** colocated as `*.test.ts` next to the source file. -- **Feature integration:** `tests/.feature.test.ts` — full slice through tRPC router → controller → use case → mock repo. - -```bash -pnpm test --filter @repo/ # all tests for this feature -pnpm test --filter @repo/ -- --watch # watch mode -``` - -See `docs/guides/tdd-workflow.md` for the cycle. -``` - -- [ ] **Step 4: Verify core-testing AGENTS.md is complete** - -The file was created in Task 1; ensure it documents factory + contract usage. - -- [ ] **Step 5: Commit** - -```bash -git add docs/decisions/adr-011-tdd-foundation.md AGENTS.md packages/*/AGENTS.md -git commit -m "docs(adr): ADR-011 TDD foundation; update AGENTS.md per-feature - -Captures the decision to add @repo/core-testing, factories, contract -suites, vitest safety defaults, coverage thresholds, Storybook -test-runner, and CI as one cohesive TDD foundation. Per-feature -AGENTS.md gains a Tests section pointing to factories, contract suite, -and the canonical test commands. - -Spec: §7.4, §7.5" -``` - ---- - -### Task 11: CI workflow - -**Files:** -- Create: `.github/workflows/ci.yml` - -- [ ] **Step 1: Create `.github/workflows/ci.yml`** - -```yaml -name: CI - -on: - push: - branches: [main] - pull_request: - -env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: ${{ vars.TURBO_TEAM }} - CI: true - -jobs: - validate: - name: typecheck + lint + boundaries + test + build - runs-on: ubuntu-latest - services: - postgres: - image: postgres:16-alpine - env: - POSTGRES_PASSWORD: postgres - POSTGRES_USER: postgres - POSTGRES_DB: cms_test - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: - version: 9 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm typecheck - - run: pnpm lint - - run: pnpm turbo boundaries - - name: Test with coverage - env: - DATABASE_URL: postgres://postgres:postgres@localhost:5432/cms_test - PAYLOAD_SECRET: test-secret-do-not-use-in-prod - run: pnpm test -- --coverage - - run: pnpm build - - uses: actions/upload-artifact@v4 - if: always() - with: - name: coverage - path: '**/coverage/lcov.info' - retention-days: 7 - - e2e: - name: Playwright e2e - needs: validate - runs-on: ubuntu-latest - services: - postgres: - image: postgres:16-alpine - env: - POSTGRES_PASSWORD: postgres - POSTGRES_USER: postgres - POSTGRES_DB: cms_test - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: - version: 9 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm exec playwright install --with-deps chromium - - name: Run e2e - env: - DATABASE_URL: postgres://postgres:postgres@localhost:5432/cms_test - PAYLOAD_SECRET: test-secret-do-not-use-in-prod - run: pnpm test:e2e - - storybook: - name: Storybook smoke tests - needs: validate - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: - version: 9 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm exec playwright install --with-deps chromium - - run: pnpm build-storybook --filter @repo/storybook - - run: pnpm test:stories -``` - -- [ ] **Step 2: Commit** - -```bash -mkdir -p .github/workflows -git add .github/workflows/ci.yml -git commit -m "ci: add GitHub Actions workflow - -Runs typecheck + lint + boundaries + test (with coverage) + build -on every push to main and every PR. Postgres service for tests that -need DB. Playwright e2e and Storybook smoke tests gated on validate -job passing. Coverage uploaded as artifact (lcov format) for downstream -tools (Codecov, etc.) — wiring left to template users. - -Spec: §6.11" -``` - ---- - -### Task 12: Tighten coverage thresholds where suites are mature - -**Files:** -- Modify: `packages/{auth,blog,marketing-pages,navigation}/vitest.config.ts` — add per-directory thresholds - -- [ ] **Step 1: Run coverage to see current state** - -```bash -pnpm test -- --coverage -``` - -Expected: each feature reports coverage. Note any features below 80/75/80/80 baseline. - -- [ ] **Step 2: Add per-directory thresholds for each feature** - -For each feature where `entities/`, `use-cases/`, and `controllers/` are at 100%: - -```typescript -import path from "node:path"; -import { mergeConfig } from "vitest/config"; -import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node"; - -export default mergeConfig(nodeVitestConfig, { - test: { - coverage: { - thresholds: { - "src/entities/**": { statements: 100, branches: 100, functions: 100, lines: 100 }, - "src/application/use-cases/**": { statements: 100, branches: 95, functions: 100, lines: 100 }, - "src/interface-adapters/controllers/**": { statements: 100, branches: 95, functions: 100, lines: 100 }, - statements: 80, branches: 75, functions: 80, lines: 80, - }, - }, - }, - resolve: { - alias: { "@": path.resolve(__dirname, "./src") }, - }, -}); -``` - -If a directory is below threshold, either add tests to cross threshold or document why (e.g., generated types) and exclude. - -- [ ] **Step 3: Run coverage again** - -```bash -pnpm test -- --coverage -``` - -Expected: all thresholds met. - -- [ ] **Step 4: Commit** - -```bash -git add packages/*/vitest.config.ts -git commit -m "test: enforce per-directory coverage thresholds - -entities + use-cases + controllers must hit 100% (95% branches). -Project-wide baseline remains 80/75/80/80. Tightening these directories -reflects the architectural intent: these are the pure-logic layers and -should be exhaustively tested. - -Spec: §6.9" -``` - ---- - -## Final verification - -After Task 12 commits: - -```bash -pnpm install -pnpm typecheck -pnpm lint -pnpm turbo boundaries -pnpm test -- --coverage -pnpm build -pnpm exec playwright install --with-deps chromium -pnpm test:e2e -pnpm build-storybook --filter @repo/storybook -pnpm test:stories -``` - -Expected: all green. - -## Self-review (run after writing the plan, before execution) - -- [x] Spec coverage: all 10 gaps mapped to tasks (1→T1+T2+core-testing setup, 2→T1+T5, 3→T9, 4→T1+T3, 5→T1+T4, 6→T2, 7→T9, 8→T8, 9→T7, 10→T11) -- [x] No placeholders — every step has concrete code or commands -- [x] Type consistency — `defineFactory`, `defineContractSuite`, `renderWithProviders`, `mockPayloadModule` named identically across all tasks -- [x] Each task is independently committable - -## Execution - -Per `superpowers:subagent-driven-development`: dispatch one implementer subagent per task with the task text + scene-setting context, then spec compliance review, then code quality review, then proceed. diff --git a/docs/superpowers/plans/2026-05-05-plan-8-lazar-conformance.md b/docs/superpowers/plans/2026-05-05-plan-8-lazar-conformance.md deleted file mode 100644 index e254e64..0000000 --- a/docs/superpowers/plans/2026-05-05-plan-8-lazar-conformance.md +++ /dev/null @@ -1,1259 +0,0 @@ -# Plan 8 — Lazar Nikolov Pattern Conformance - -> **Note (post-Plan-9, 2026-05-06):** Some controller / router / use-case patterns in this plan shifted in Plan 9. Use cases now own input + output schemas (`xInputSchema`, `xOutputSchema`); controllers receive `unknown` and run a top-level `function presenter` (Lazar pattern); routers consume `xProcedure` from each feature's `integrations/api/procedures.ts` instead of bare `publicProcedure`. See `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md` and ADR-013 for the post-Plan-9 layout. - -> **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:** Bring every feature in the monorepo into structural conformance with Lazar Nikolov's Clean Architecture pattern, while preserving our intentional vertical-feature design. - -**Architecture:** Refactor each feature so use cases and controllers are factory functions with explicit DI; entities split into `models/` + `errors/` subdirs; mock files use `.mock.ts` suffix; one controller per use case; real Payload-backed `UsersRepository` and `AuthenticationService` added for auth; full Clean Architecture scaffold added for media. Intentional divergences (per-feature DI containers, inversify retained, colocated tests) documented in spec §4. - -**Spec:** `docs/superpowers/specs/2026-05-05-lazar-pattern-conformance-design.md` — read first if any task is unclear. - -**Worktree:** Execute on `feature/lazar-conformance` in `.worktrees/lazar-conformance/`. - -**Refactor changelog:** Maintain `docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md` throughout — every architectural change is captured for a follow-up doc-update pass. Update the changelog at the END of every task with: files renamed, files added, files deleted, pattern changes for the layer touched, and any new doc-update entries. - ---- - -## 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. -- **Inversify `.toDynamicValue` for factory bindings** — `bind(SYMBOL).toDynamicValue((ctx) => xUseCase(ctx.container.get(...)))`. -- **`I*UseCase` / `I*Controller` type aliases** — every use case and controller exports these via `ReturnType`. -- **Commit per task** with clear message. Never bundle two tasks. -- **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. -- **NEVER touch external docs** (CLAUDE.md, AGENTS.md, etc.) during this plan. Doc updates are a separate follow-up pass driven by the changelog. - ---- - -### Task 1: Refactor changelog scaffold + Doc-update checklist - -**Files:** -- Create: `docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md` - -- [ ] **Step 1: Create the changelog file with the standard sections** - -`docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md`: - -```markdown -# Refactor Changelog — Lazar Pattern Conformance - -**Started:** 2026-05-05 -**Spec:** [2026-05-05-lazar-pattern-conformance-design.md](../specs/2026-05-05-lazar-pattern-conformance-design.md) -**Plan:** [2026-05-05-plan-8-lazar-conformance.md](../plans/2026-05-05-plan-8-lazar-conformance.md) -**Branch:** feature/lazar-conformance - -This document captures every architectural change made during Plan 8 -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. - ---- - -## 1. File renames (before → after) - -(populated as work progresses) - -## 2. Files added (with purpose) - -(populated as work progresses) - -## 3. Files deleted (with reason) - -(populated as work progresses) - -## 4. Pattern changes (code-level) - -### 4.1 Use cases — factory function pattern -(populated when a use case is migrated) - -### 4.2 Controllers — one per use case -(populated when controllers are split) - -### 4.3 Entities split — models/ + errors/ subdirs -(populated when entities are reshaped) - -## 5. DI changes - -### 5.1 Inversify `.toDynamicValue` bindings -(populated when DI modules are updated) - -### 5.2 Mock siblings registered as default bindings -(populated when modules are updated) - -## 6. Test refactor patterns - -### 6.1 Direct injection (no container rebinding) -(populated when tests are migrated) - -## 7. Open issues / deferred decisions - -(populated as encountered) - ---- - -## Doc update checklist (deferred — run after merge) - -- [ ] `CLAUDE.md` — Key Conventions section: update file path examples to use `entities/models/.ts`, mention factory-function use cases -- [ ] `AGENTS.md` (root) — update Per-Package Conventions; add note about `I*UseCase` type aliases; update naming examples -- [ ] `docs/guides/adding-a-feature.md` — restructure to use factory-function pattern in every step; update file paths to new layout -- [ ] `docs/guides/tdd-workflow.md` — update "When to mock" section to show direct injection of mocks instead of container rebinding; update factory usage examples to reference new entity model paths -- [ ] `docs/guides/testing-strategy.md` — update Mocking section to remove DI-rebinding pattern; show direct factory injection -- [ ] `docs/architecture/vertical-feature-spec.md` — update §13 (testing) to reflect factory pattern; update file shape examples in §10 -- [ ] `docs/architecture/overview.md` — update layer descriptions to mention factory functions -- [ ] `docs/architecture/dependency-flow.md` — verify dep flow still accurate with new DI bindings -- [ ] `docs/decisions/adr-012-lazar-conformance.md` — NEW ADR documenting the conformance decision + four intentional divergences (per-feature DI, inversify, colocated tests, no Sentry services) -- [ ] Per-feature `AGENTS.md` (auth/blog/media/marketing-pages/navigation) — update file path references; document factory pattern; update Tests section -- [ ] `packages/core-testing/AGENTS.md` — note that factories now live alongside entities at `entities/models/.ts` paths -- [ ] `packages/auth/AGENTS.md` — document the new real PayloadUsersRepository + PayloadAuthenticationService -- [ ] `packages/media/AGENTS.md` — full rewrite — media now has all Clean Architecture layers -- [ ] Plan 7 plan/spec docs — add a note at the top that paths reference the pre-Plan-8 layout - ---- - -## Notes for the doc-update pass author - -- Replace any `entities/.ts` reference with `entities/models/.ts` -- Replace any `mock-.repository.ts` reference with `.repository.mock.ts` -- Replace any `-repository.interface.ts` reference with `.repository.interface.ts` -- Replace any `payload-.repository.ts` reference with `.repository.ts` (the real impl is now the canonical name) -- Add `I*UseCase` and `I*Controller` type alias examples -- The DI binding pattern code samples need to switch from `.to()` to `.toDynamicValue()` for use cases and controllers -- Test examples should show direct factory injection: `signInUseCase(mockUsers, mockAuth)(input)` instead of `container.get(SYMBOLS.ISignInUseCase)(input)` -``` - -- [ ] **Step 2: Verify file is readable** - -```bash -ls -la docs/superpowers/refactor-logs/ -cat docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md | head -20 -``` - -- [ ] **Step 3: Commit** - -```bash -mkdir -p docs/superpowers/refactor-logs -git add docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md -git commit -m "docs(refactor-log): scaffold Lazar conformance refactor changelog - -Empty section template plus the full doc-update checklist that the -follow-up pass will work through after the refactor is merged. Spec: -docs/superpowers/specs/2026-05-05-lazar-pattern-conformance-design.md §10." -``` - ---- - -### Task 2: Foundation — entities split into models/ + errors/ (all 5 features) - -**Files (per feature):** -- Move: `src/entities/.ts` → `src/entities/models/.ts` -- Split: `src/entities/errors.ts` → `src/entities/errors/.ts` + `src/entities/errors/common.ts` -- Update: every import that references the moved/split files - -For each feature, list of moves: - -**auth:** -- `entities/user.ts` → `entities/models/user.ts` -- `entities/session.ts` → `entities/models/session.ts` -- `entities/cookie.ts` → `entities/models/cookie.ts` -- `entities/errors.ts` → split into `entities/errors/auth.ts` (AuthenticationError, UnauthenticatedError, UnauthorizedError) + `entities/errors/common.ts` (InputParseError) -- Delete: `entities/errors.ts` (replaced by subdir contents) - -**blog:** -- `entities/article.ts` → `entities/models/article.ts` -- `entities/errors.ts` → split into `entities/errors/article.ts` (ArticleNotFoundError) + `entities/errors/common.ts` (InputParseError) - -**marketing-pages:** -- `entities/page.ts` → `entities/models/page.ts` -- `entities/site-settings.ts` → `entities/models/site-settings.ts` -- `entities/errors.ts` → split into `entities/errors/page.ts` (PageNotFoundError) + `entities/errors/common.ts` (InputParseError) - -**navigation:** -- `entities/header.ts` → `entities/models/header.ts` -- `entities/errors.ts` → split into `entities/errors/header.ts` (HeaderNotFoundError if it exists, otherwise just empty placeholder) + `entities/errors/common.ts` (InputParseError) - -**media:** -- No entities yet (created in Task 9). Skip for now. - -- [ ] **Step 1: Per feature, create the new directory structure first** - -```bash -for feat in auth blog marketing-pages navigation; do - mkdir -p packages/$feat/src/entities/models packages/$feat/src/entities/errors -done -``` - -- [ ] **Step 2: For each feature, move entity files using `git mv`** - -```bash -# auth -git mv packages/auth/src/entities/user.ts packages/auth/src/entities/models/user.ts -git mv packages/auth/src/entities/session.ts packages/auth/src/entities/models/session.ts -git mv packages/auth/src/entities/cookie.ts packages/auth/src/entities/models/cookie.ts -# blog -git mv packages/blog/src/entities/article.ts packages/blog/src/entities/models/article.ts -# marketing-pages -git mv packages/marketing-pages/src/entities/page.ts packages/marketing-pages/src/entities/models/page.ts -git mv packages/marketing-pages/src/entities/site-settings.ts packages/marketing-pages/src/entities/models/site-settings.ts -# navigation -git mv packages/navigation/src/entities/header.ts packages/navigation/src/entities/models/header.ts -``` - -- [ ] **Step 3: For each feature, split errors.ts into domain + common files** - -Read each `errors.ts` first to know what classes are inside. Then create domain-grouped files. - -For auth — read `packages/auth/src/entities/errors.ts`. Create: - -`packages/auth/src/entities/errors/auth.ts`: -```typescript -export class AuthenticationError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - } -} - -export class UnauthenticatedError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - } -} - -export class UnauthorizedError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - } -} -``` - -`packages/auth/src/entities/errors/common.ts`: -```typescript -export class InputParseError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - } -} -``` - -Adjust per feature based on what errors actually exist in each `errors.ts`. - -- [ ] **Step 4: Delete old `entities/errors.ts`** - -```bash -git rm packages/auth/src/entities/errors.ts -git rm packages/blog/src/entities/errors.ts -git rm packages/marketing-pages/src/entities/errors.ts -git rm packages/navigation/src/entities/errors.ts -``` - -- [ ] **Step 5: Update all imports referencing the old paths** - -Use grep to find consumers, then update one at a time. - -```bash -grep -rln 'from "../../entities/errors"\|from "../../entities/article"\|from "../../entities/user"\|from "../../entities/session"\|from "../../entities/cookie"\|from "../../entities/page"\|from "../../entities/site-settings"\|from "../../entities/header"' packages/ -``` - -Update each occurrence to the new path: -- `entities/errors` → `entities/errors/` or `entities/errors/common` (depending on which class is imported) -- `entities/` → `entities/models/` - -Common imports to fix: -- `entities/errors` (anywhere it's imported) — split into either `errors/auth`, `errors/article`, `errors/page`, `errors/header` (for domain errors) or `errors/common` (for InputParseError) -- `entities/article` → `entities/models/article` -- `entities/user` → `entities/models/user` -- … etc - -**Critical:** check `__factories__/`, `__contracts__/`, `*.test.ts`, repositories, use cases, controllers, integrations/api/router.ts, integrations/cms/collections/. Every import must resolve. - -- [ ] **Step 6: Verify** - -```bash -pnpm install -pnpm typecheck # all type imports resolve -pnpm lint -pnpm test # all 244 tests still pass -pnpm turbo boundaries -``` - -If any test fails because the factory or contract imports the wrong path, fix the import, NOT the structure. - -- [ ] **Step 7: Update refactor changelog** - -Edit `docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md`. Under §1 File renames, append: -``` -### Task 2: Entities split - -- packages/auth/src/entities/user.ts → packages/auth/src/entities/models/user.ts -- packages/auth/src/entities/session.ts → packages/auth/src/entities/models/session.ts -- packages/auth/src/entities/cookie.ts → packages/auth/src/entities/models/cookie.ts -- packages/auth/src/entities/errors.ts → packages/auth/src/entities/errors/auth.ts + packages/auth/src/entities/errors/common.ts (split) -- packages/blog/src/entities/article.ts → packages/blog/src/entities/models/article.ts -- packages/blog/src/entities/errors.ts → packages/blog/src/entities/errors/article.ts + common.ts -- packages/marketing-pages/src/entities/page.ts → packages/marketing-pages/src/entities/models/page.ts -- packages/marketing-pages/src/entities/site-settings.ts → packages/marketing-pages/src/entities/models/site-settings.ts -- packages/marketing-pages/src/entities/errors.ts → packages/marketing-pages/src/entities/errors/page.ts + common.ts -- packages/navigation/src/entities/header.ts → packages/navigation/src/entities/models/header.ts -- packages/navigation/src/entities/errors.ts → packages/navigation/src/entities/errors/header.ts + common.ts -``` - -Under §4.3 Pattern changes — Entities split: -``` -- Entities now live at `entities/models/.ts` (Zod schema + type) -- Errors live at `entities/errors/.ts` (domain-specific classes) + `entities/errors/common.ts` (InputParseError) -- Each feature owns its own InputParseError (duplicated, ~6 lines per feature) -``` - -- [ ] **Step 8: Commit** - -```bash -git add packages/ docs/superpowers/refactor-logs/ -git commit -m "refactor(features): split entities into models/ + errors/ subdirs - -All 5 features (auth, blog, marketing-pages, navigation; media has no -entities yet) now follow Lazar's pattern: -- entities/.ts → entities/models/.ts -- entities/errors.ts → entities/errors/.ts + errors/common.ts - -Updates all import paths across factories, contracts, tests, use cases, -controllers, repositories, integrations. - -Refactor log: docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md -Spec: §5, §9.3" -``` - ---- - -### Task 3: Foundation — file renames (mock + payload + interface) - -**Files (per feature):** - -For each repository: -- `infrastructure/repositories/mock-.repository.ts` → `.repository.mock.ts` -- `infrastructure/repositories/payload-.repository.ts` → `.repository.ts` -- `application/repositories/-repository.interface.ts` → `.repository.interface.ts` - -For each service (auth only currently): -- `infrastructure/services/mock-.service.ts` → `.service.mock.ts` -- `application/services/-service.interface.ts` → `.service.interface.ts` - -Specific renames: - -**auth:** -- `mock-users.repository.ts` → `users.repository.mock.ts` -- `users-repository.interface.ts` → `users.repository.interface.ts` -- `mock-authentication.service.ts` → `authentication.service.mock.ts` -- `authentication-service.interface.ts` → `authentication.service.interface.ts` -(Note: real `users.repository.ts` and `authentication.service.ts` are added in Task 5.) - -**blog:** -- `mock-articles.repository.ts` → `articles.repository.mock.ts` -- `payload-articles.repository.ts` → `articles.repository.ts` -- `articles-repository.interface.ts` → `articles.repository.interface.ts` - -**marketing-pages:** -- `mock-pages.repository.ts` → `pages.repository.mock.ts` -- `payload-pages.repository.ts` → `pages.repository.ts` -- `pages-repository.interface.ts` → `pages.repository.interface.ts` -- `mock-site-settings.repository.ts` → `site-settings.repository.mock.ts` -- `payload-site-settings.repository.ts` → `site-settings.repository.ts` -- `site-settings-repository.interface.ts` → `site-settings.repository.interface.ts` - -**navigation:** -- `mock-header.repository.ts` → `header.repository.mock.ts` -- `payload-header.repository.ts` → `header.repository.ts` -- `header-repository.interface.ts` → `header.repository.interface.ts` - -**media:** none (Task 9 creates these from scratch). - -- [ ] **Step 1: Use `git mv` for every rename** - -```bash -# auth -git mv packages/auth/src/infrastructure/repositories/mock-users.repository.ts packages/auth/src/infrastructure/repositories/users.repository.mock.ts -git mv packages/auth/src/application/repositories/users-repository.interface.ts packages/auth/src/application/repositories/users.repository.interface.ts -git mv packages/auth/src/infrastructure/services/mock-authentication.service.ts packages/auth/src/infrastructure/services/authentication.service.mock.ts -git mv packages/auth/src/application/services/authentication-service.interface.ts packages/auth/src/application/services/authentication.service.interface.ts -# blog -git mv packages/blog/src/infrastructure/repositories/mock-articles.repository.ts packages/blog/src/infrastructure/repositories/articles.repository.mock.ts -git mv packages/blog/src/infrastructure/repositories/payload-articles.repository.ts packages/blog/src/infrastructure/repositories/articles.repository.ts -git mv packages/blog/src/application/repositories/articles-repository.interface.ts packages/blog/src/application/repositories/articles.repository.interface.ts -# marketing-pages -git mv packages/marketing-pages/src/infrastructure/repositories/mock-pages.repository.ts packages/marketing-pages/src/infrastructure/repositories/pages.repository.mock.ts -git mv packages/marketing-pages/src/infrastructure/repositories/payload-pages.repository.ts packages/marketing-pages/src/infrastructure/repositories/pages.repository.ts -git mv packages/marketing-pages/src/application/repositories/pages-repository.interface.ts packages/marketing-pages/src/application/repositories/pages.repository.interface.ts -git mv packages/marketing-pages/src/infrastructure/repositories/mock-site-settings.repository.ts packages/marketing-pages/src/infrastructure/repositories/site-settings.repository.mock.ts -git mv packages/marketing-pages/src/infrastructure/repositories/payload-site-settings.repository.ts packages/marketing-pages/src/infrastructure/repositories/site-settings.repository.ts -git mv packages/marketing-pages/src/application/repositories/site-settings-repository.interface.ts packages/marketing-pages/src/application/repositories/site-settings.repository.interface.ts -# navigation -git mv packages/navigation/src/infrastructure/repositories/mock-header.repository.ts packages/navigation/src/infrastructure/repositories/header.repository.mock.ts -git mv packages/navigation/src/infrastructure/repositories/payload-header.repository.ts packages/navigation/src/infrastructure/repositories/header.repository.ts -git mv packages/navigation/src/application/repositories/header-repository.interface.ts packages/navigation/src/application/repositories/header.repository.interface.ts -``` - -- [ ] **Step 2: Update all imports referencing the old paths** - -```bash -grep -rln 'mock-users.repository\|mock-articles.repository\|mock-pages.repository\|mock-site-settings.repository\|mock-header.repository\|mock-authentication.service\|payload-articles.repository\|payload-pages.repository\|payload-site-settings.repository\|payload-header.repository\|users-repository.interface\|articles-repository.interface\|pages-repository.interface\|site-settings-repository.interface\|header-repository.interface\|authentication-service.interface' packages/ -``` - -Update each — replace the old path with the new path. Watch: -- DI module bindings (still bind to the same class names, just import from new files) -- Test files that import the mock or real impl -- Contract suites that import the interface -- Factories that import nothing from these (probably none) - -- [ ] **Step 3: Verify class names unchanged** - -The class names stay (`MockUsersRepository`, `PayloadUsersRepository` → wait, `PayloadUsersRepository` doesn't exist in auth yet; for the others, the class name changes from e.g. `PayloadArticlesRepository` to `ArticlesRepository`). - -**Decision:** also rename the classes for consistency. So: -- `class PayloadArticlesRepository` → `class ArticlesRepository` -- `class PayloadPagesRepository` → `class PagesRepository` -- `class PayloadSiteSettingsRepository` → `class SiteSettingsRepository` -- `class PayloadHeaderRepository` → `class HeaderRepository` - -The `Mock` prefix stays: `MockArticlesRepository`, `MockUsersRepository`, etc. - -Update: -1. Class name in the file -2. All consumers (DI module, test, contract `buildSubject`) - -- [ ] **Step 4: Verify** - -```bash -pnpm install -pnpm typecheck -pnpm lint -pnpm test -pnpm turbo boundaries -``` - -If anything fails, the import or class-rename is the issue. - -- [ ] **Step 5: Update refactor changelog** - -Append to `docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md` §1: - -``` -### Task 3: File and class renames - -File renames (16 files): -- packages/auth/src/infrastructure/repositories/mock-users.repository.ts → users.repository.mock.ts -- packages/auth/src/application/repositories/users-repository.interface.ts → users.repository.interface.ts -- packages/auth/src/infrastructure/services/mock-authentication.service.ts → authentication.service.mock.ts -- packages/auth/src/application/services/authentication-service.interface.ts → authentication.service.interface.ts -- packages/blog/src/infrastructure/repositories/mock-articles.repository.ts → articles.repository.mock.ts -- packages/blog/src/infrastructure/repositories/payload-articles.repository.ts → articles.repository.ts -- packages/blog/src/application/repositories/articles-repository.interface.ts → articles.repository.interface.ts -- packages/marketing-pages/src/infrastructure/repositories/mock-pages.repository.ts → pages.repository.mock.ts -- packages/marketing-pages/src/infrastructure/repositories/payload-pages.repository.ts → pages.repository.ts -- packages/marketing-pages/src/application/repositories/pages-repository.interface.ts → pages.repository.interface.ts -- packages/marketing-pages/src/infrastructure/repositories/mock-site-settings.repository.ts → site-settings.repository.mock.ts -- packages/marketing-pages/src/infrastructure/repositories/payload-site-settings.repository.ts → site-settings.repository.ts -- packages/marketing-pages/src/application/repositories/site-settings-repository.interface.ts → site-settings.repository.interface.ts -- packages/navigation/src/infrastructure/repositories/mock-header.repository.ts → header.repository.mock.ts -- packages/navigation/src/infrastructure/repositories/payload-header.repository.ts → header.repository.ts -- packages/navigation/src/application/repositories/header-repository.interface.ts → header.repository.interface.ts - -Class renames: -- PayloadArticlesRepository → ArticlesRepository -- PayloadPagesRepository → PagesRepository -- PayloadSiteSettingsRepository → SiteSettingsRepository -- PayloadHeaderRepository → HeaderRepository -- (Mock* class names unchanged) -``` - -- [ ] **Step 6: Commit** - -```bash -git add packages/ docs/superpowers/refactor-logs/ -git commit -m "refactor(features): rename mock/payload/interface files per Lazar pattern - -Convention now: .repository.{ts,mock.ts,interface.ts}. -Renames .mock prefix to .mock suffix; drops .payload prefix from real -impls (canonical name = real impl); dot-separates the .repository -qualifier in interface filenames. Class names follow suit: -PayloadXRepository → XRepository; Mock* unchanged. - -Refactor log: §1, §3 -Spec: §9.1" -``` - ---- - -### Task 4: Refactor `auth` to factory functions + add real Payload impls - -**Files (auth):** -- Modify: every use case (`sign-in.use-case.ts`, `sign-up.use-case.ts`, `sign-out.use-case.ts`) — convert to factory function, export `I*UseCase` type -- Modify: every controller (`sign-in.controller.ts`, `sign-up.controller.ts`, `sign-out.controller.ts`) — convert to factory function, export `I*Controller` type -- Modify: `di/symbols.ts` — add use case + controller symbols -- Modify: `di/module.ts` — add `.toDynamicValue()` bindings for use cases and controllers -- Create: `infrastructure/repositories/users.repository.ts` — real Payload-backed `UsersRepository` -- Create: `infrastructure/services/authentication.service.ts` — real `AuthenticationService` using Payload's auth API -- Modify: `di/bind-production.ts` — swap mocks for real impls -- Modify: `integrations/api/router.ts` — controllers resolved via container.get() -- Modify: tests — direct factory injection (no container.get) - -#### Step 1-N: per use case TDD migration - -For each of `sign-in.use-case.ts`, `sign-up.use-case.ts`, `sign-out.use-case.ts`: - -- [ ] **Step 1: Update the use case test FIRST** to use direct factory injection (RED initially because factory doesn't exist yet) - -`packages/auth/src/application/use-cases/sign-in.use-case.test.ts`: -```typescript -import { describe, it, expect } from "vitest"; -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 { AuthenticationError } from "@/entities/errors/auth"; -import { userFactory } from "@/__factories__/user.factory"; - -describe("signInUseCase", () => { - it("creates a session for valid credentials", 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 result = await useCase({ username: "alice", password: seedUser.passwordHash.replace("hashed_", "") }); - - expect(result.session).toBeDefined(); - expect(result.cookie.value).toBe(result.session.id); - }); - - it("throws AuthenticationError when user does not exist", async () => { - const users = new MockUsersRepository(); - const auth = new MockAuthenticationService(users); - const useCase = signInUseCase(users, auth); - - await expect(useCase({ username: "missing", password: "x" })).rejects.toThrow(AuthenticationError); - }); - - it("throws AuthenticationError when password is incorrect", async () => { - const users = new MockUsersRepository(); - const auth = new MockAuthenticationService(users); - await users.createUser(userFactory.build({ username: "alice" })); - - const useCase = signInUseCase(users, auth); - await expect(useCase({ username: "alice", password: "wrong" })).rejects.toThrow(AuthenticationError); - }); -}); -``` - -- [ ] **Step 2: Run test — RED (factory function not exported yet)** - -```bash -pnpm test --filter @repo/auth -- sign-in.use-case -``` - -Expected: FAIL — `signInUseCase is not a function` or similar. - -- [ ] **Step 3: Convert the use case to a factory function** - -`packages/auth/src/application/use-cases/sign-in.use-case.ts`: -```typescript -import { AuthenticationError } from "../../entities/errors/auth"; -import type { Cookie } from "../../entities/models/cookie"; -import type { Session } from "../../entities/models/session"; -import type { IUsersRepository } from "../repositories/users.repository.interface"; -import type { IAuthenticationService } from "../services/authentication.service.interface"; - -export type ISignInUseCase = ReturnType; - -export const signInUseCase = - (usersRepository: IUsersRepository, authenticationService: IAuthenticationService) => - async (input: { username: string; password: string }): Promise<{ session: Session; cookie: Cookie }> => { - 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"); - } - return await authenticationService.createSession(existingUser); - }; -``` - -- [ ] **Step 4: Run test — GREEN** - -```bash -pnpm test --filter @repo/auth -- sign-in.use-case -``` - -- [ ] **Step 5: Repeat for `sign-up.use-case.ts` and `sign-out.use-case.ts`** - -Same pattern. Read the existing use case, identify its dependencies, write the test first with direct injection, then convert to factory. - -#### Step N+1: Convert controllers similarly - -For each of `sign-in.controller.ts`, `sign-up.controller.ts`, `sign-out.controller.ts`: - -- [ ] **Step a: Update the controller test to use direct injection** - -`packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts`: -```typescript -import { describe, it, expect } from "vitest"; -import { signInController } from "@/interface-adapters/controllers/sign-in.controller"; -import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock"; -import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; -import { signInUseCase } from "@/application/use-cases/sign-in.use-case"; -import { InputParseError } from "@/entities/errors/common"; -import { userFactory } from "@/__factories__/user.factory"; - -describe("signInController", () => { - it("returns the 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 cookie = await controller({ - username: "alice", - password: seedUser.passwordHash.replace("hashed_", ""), - }); - - expect(cookie).toBeDefined(); - expect(cookie.name).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: "x" })).rejects.toThrow(InputParseError); - }); -}); -``` - -- [ ] **Step b: Run — RED** - -- [ ] **Step c: Convert the controller** - -`packages/auth/src/interface-adapters/controllers/sign-in.controller.ts`: -```typescript -import { z } from "zod"; -import { InputParseError } from "../../entities/errors/common"; -import type { Cookie } from "../../entities/models/cookie"; -import type { ISignInUseCase } from "../../application/use-cases/sign-in.use-case"; - -const inputSchema = z.object({ - username: z.string().min(3).max(31), - password: z.string().min(6).max(255), -}); - -export type ISignInController = ReturnType; - -export const signInController = - (signInUseCase: ISignInUseCase) => - async (input: Partial>): Promise => { - const parsed = inputSchema.safeParse(input); - if (!parsed.success) { - throw new InputParseError("Invalid sign-in input", { cause: parsed.error }); - } - const { cookie } = await signInUseCase(parsed.data); - return cookie; - }; -``` - -- [ ] **Step d: GREEN** - -#### Step N+2: Update DI - -- [ ] **Step a: Add use case and controller symbols** - -`packages/auth/src/di/symbols.ts` — add to `AUTH_SYMBOLS`: -```typescript -export const AUTH_SYMBOLS = { - IUsersRepository: Symbol.for("IUsersRepository"), - IAuthenticationService: Symbol.for("IAuthenticationService"), - // Use cases - ISignInUseCase: Symbol.for("ISignInUseCase"), - ISignUpUseCase: Symbol.for("ISignUpUseCase"), - ISignOutUseCase: Symbol.for("ISignOutUseCase"), - // Controllers - ISignInController: Symbol.for("ISignInController"), - ISignUpController: Symbol.for("ISignUpController"), - ISignOutController: Symbol.for("ISignOutController"), -}; -``` - -- [ ] **Step b: Add `.toDynamicValue()` bindings** - -`packages/auth/src/di/module.ts`: -```typescript -import { ContainerModule } from "inversify"; -import { AUTH_SYMBOLS } from "./symbols"; -import { MockUsersRepository } from "../infrastructure/repositories/users.repository.mock"; -import { MockAuthenticationService } from "../infrastructure/services/authentication.service.mock"; -import { signInUseCase, type ISignInUseCase } from "../application/use-cases/sign-in.use-case"; -import { signUpUseCase, type ISignUpUseCase } from "../application/use-cases/sign-up.use-case"; -import { signOutUseCase, type ISignOutUseCase } from "../application/use-cases/sign-out.use-case"; -import { signInController, type ISignInController } from "../interface-adapters/controllers/sign-in.controller"; -import { signUpController, type ISignUpController } from "../interface-adapters/controllers/sign-up.controller"; -import { signOutController, type ISignOutController } from "../interface-adapters/controllers/sign-out.controller"; -import type { IUsersRepository } from "../application/repositories/users.repository.interface"; -import type { IAuthenticationService } from "../application/services/authentication.service.interface"; - -export const authModule = new ContainerModule((bind) => { - bind(AUTH_SYMBOLS.IUsersRepository).to(MockUsersRepository); - bind(AUTH_SYMBOLS.IAuthenticationService).to(MockAuthenticationService); - - bind(AUTH_SYMBOLS.ISignInUseCase).toDynamicValue((ctx) => - signInUseCase( - ctx.container.get(AUTH_SYMBOLS.IUsersRepository), - ctx.container.get(AUTH_SYMBOLS.IAuthenticationService), - ), - ); - bind(AUTH_SYMBOLS.ISignUpUseCase).toDynamicValue((ctx) => - signUpUseCase( - ctx.container.get(AUTH_SYMBOLS.IUsersRepository), - ctx.container.get(AUTH_SYMBOLS.IAuthenticationService), - ), - ); - bind(AUTH_SYMBOLS.ISignOutUseCase).toDynamicValue((ctx) => - signOutUseCase(ctx.container.get(AUTH_SYMBOLS.IAuthenticationService)), - ); - - bind(AUTH_SYMBOLS.ISignInController).toDynamicValue((ctx) => - signInController(ctx.container.get(AUTH_SYMBOLS.ISignInUseCase)), - ); - bind(AUTH_SYMBOLS.ISignUpController).toDynamicValue((ctx) => - signUpController(ctx.container.get(AUTH_SYMBOLS.ISignUpUseCase)), - ); - bind(AUTH_SYMBOLS.ISignOutController).toDynamicValue((ctx) => - signOutController(ctx.container.get(AUTH_SYMBOLS.ISignOutUseCase)), - ); -}); -``` - -#### Step N+3: Update integrations/api/router.ts - -- [ ] **Step a: Update tRPC router to resolve controllers via DI** - -`packages/auth/src/integrations/api/router.ts`: -```typescript -import { router, publicProcedure } from "@repo/core-shared/trpc/init"; -import { z } from "zod"; -import { authContainer } from "../../di/container"; -import { AUTH_SYMBOLS } from "../../di/symbols"; -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"; - -const signInInput = z.object({ username: z.string(), password: z.string() }); -const signUpInput = z.object({ username: z.string(), password: z.string() }); -const signOutInput = z.object({ sessionId: z.string() }); - -export const authRouter = router({ - signIn: publicProcedure.input(signInInput).mutation(async ({ input }) => { - const ctrl = authContainer.get(AUTH_SYMBOLS.ISignInController); - return ctrl(input); - }), - signUp: publicProcedure.input(signUpInput).mutation(async ({ input }) => { - const ctrl = authContainer.get(AUTH_SYMBOLS.ISignUpController); - return ctrl(input); - }), - signOut: publicProcedure.input(signOutInput).mutation(async ({ input }) => { - const ctrl = authContainer.get(AUTH_SYMBOLS.ISignOutController); - return ctrl(input); - }), -}); -``` - -#### Step N+4: Add real PayloadUsersRepository + AuthenticationService - -- [ ] **Step a: Write failing test for `UsersRepository` (Payload-backed)** - -`packages/auth/src/infrastructure/repositories/users.repository.test.ts`: -```typescript -import { describe, vi, beforeEach } from "vitest"; -import { UsersRepository } from "./users.repository"; -import { usersRepositoryContract } from "../../__contracts__/users-repository.contract"; -import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config"; - -vi.mock("payload", () => ({ getPayload: vi.fn() })); - -function buildPayloadStub() { - const store = new Map(); - return { - create: vi.fn(async ({ data }) => { store.set(data.id, data); return data; }), - find: vi.fn(async ({ where }) => { - const all = Array.from(store.values()); - if (where?.username?.equals) return { docs: all.filter((u) => u.username === where.username.equals) }; - return { docs: all }; - }), - findByID: vi.fn(async ({ id }) => store.get(id) ?? null), - }; -} - -describe("UsersRepository", () => { - describe("contract", () => { - usersRepositoryContract.run(async () => { - const stub = buildPayloadStub(); - const { getPayload } = await import("payload"); - (getPayload as ReturnType).mockResolvedValue(stub); - return new UsersRepository(stubPayloadConfig); - }); - }); -}); -``` - -- [ ] **Step b: RED** - -- [ ] **Step c: Implement UsersRepository** - -`packages/auth/src/infrastructure/repositories/users.repository.ts`: -```typescript -import { getPayload } from "payload"; -import type { Config } from "payload"; -import type { IUsersRepository } from "../../application/repositories/users.repository.interface"; -import { type User, userSchema } from "../../entities/models/user"; - -export class UsersRepository implements IUsersRepository { - constructor(private config: Config) {} - - async getUser(id: string): Promise { - const payload = await getPayload({ config: this.config }); - const result = await payload.findByID({ collection: "users", id, overrideAccess: true }); - return result ? userSchema.parse(this.toDomain(result)) : undefined; - } - - async getUserByUsername(username: string): Promise { - const payload = await getPayload({ config: this.config }); - const { docs } = await payload.find({ - collection: "users", - where: { username: { equals: username } }, - limit: 1, - overrideAccess: true, - }); - return docs[0] ? userSchema.parse(this.toDomain(docs[0])) : undefined; - } - - async createUser(input: User): Promise { - const payload = await getPayload({ config: this.config }); - const created = await payload.create({ - collection: "users", - data: { id: input.id, username: input.username, passwordHash: input.passwordHash }, - overrideAccess: true, - }); - return userSchema.parse(this.toDomain(created)); - } - - private toDomain(doc: Record): User { - return { - id: doc.id as string, - username: doc.username as string, - passwordHash: doc.passwordHash as string, - }; - } -} -``` - -- [ ] **Step d: GREEN** - -- [ ] **Step e: Write test + impl for AuthenticationService (similar pattern, using Payload's auth API)** - -This is more involved. Read the Mock impl as a guide. The real impl should: -- `verifyPassword`: use Payload's `local.login` (or bcrypt directly) — depends on Payload's auth strategy -- `createSession`: use Payload's `local.login` to issue a session token; map to `Session` and `Cookie` -- `invalidateSession`: clear session via Payload -- `validateSession`: use Payload's `local.findByID` on sessions or rely on session token - -Document any deviation in the changelog under Open issues if Payload's auth API doesn't map cleanly. If too complex, make it a stub that always throws "not implemented" with a TODO comment, and document as deferred work in the changelog. Don't block this task on perfect Payload auth integration. - -- [ ] **Step f: Update `bind-production.ts` to swap mocks for real impls** - -`packages/auth/src/di/bind-production.ts`: -```typescript -import type { Config } from "payload"; -import { authContainer } from "./container"; -import { AUTH_SYMBOLS } from "./symbols"; -import { UsersRepository } from "../infrastructure/repositories/users.repository"; -import { AuthenticationService } from "../infrastructure/services/authentication.service"; -import type { IUsersRepository } from "../application/repositories/users.repository.interface"; -import type { IAuthenticationService } from "../application/services/authentication.service.interface"; - -let bound = false; - -export function bindProductionAuth(config: Config): void { - if (bound) return; - bound = true; - - if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { - authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); - } - authContainer - .bind(AUTH_SYMBOLS.IUsersRepository) - .toConstantValue(new UsersRepository(config)); - - if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) { - authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService); - } - authContainer - .bind(AUTH_SYMBOLS.IAuthenticationService) - .toConstantValue(new AuthenticationService(config)); -} -``` - -#### Step N+5: Verify - -- [ ] **Verification** - -```bash -pnpm install -pnpm test --filter @repo/auth # all auth tests pass with new pattern -pnpm test # global suite still green -pnpm typecheck -pnpm lint -pnpm turbo boundaries -``` - -#### Step N+6: Update changelog - -- [ ] **Update changelog with Task 4 entries**: - -Under §2 Files added: list `users.repository.ts`, `authentication.service.ts`, controller test files (if new), use case test files (if new). - -Under §4.1 Use cases — factory function pattern: -``` -- All auth use cases (sign-in, sign-up, sign-out) refactored to factory function: - `(deps) => async (input) => result` -- Each exports `I*UseCase = ReturnType` -- Use cases NO LONGER call `authContainer.get()` — deps are passed in -- Tests construct mocks directly: `signInUseCase(mockUsers, mockAuth)(input)` -``` - -Under §4.2 Controllers — one per use case: -``` -- auth controllers already split (sign-in, sign-up, sign-out — one file each) -- Refactored to factory function: `(useCase) => async (input) => result` -- Each exports `I*Controller = ReturnType` -``` - -Under §5.1 DI bindings: -``` -- AUTH_SYMBOLS expanded with use case and controller keys -- Use case and controller bindings use `.toDynamicValue((ctx) => factoryFn(ctx.container.get(...)))` -- tRPC router resolves controllers via container.get() instead of calling use cases directly -``` - -Under §6.1 Test refactor patterns: -``` -- Use case + controller tests now construct mocks and inject directly: - `const useCase = signInUseCase(mockUsers, mockAuth); await useCase(input);` -- No more container.get() in tests -- No more rebinding in beforeEach -``` - -#### Step N+7: Commit - -- [ ] **Commit** - -```bash -git add packages/auth docs/superpowers/refactor-logs/ -git commit -m "refactor(auth): factory-style use cases + controllers + real Payload impls - -- Use cases (sign-in, sign-up, sign-out) → factory functions with I*UseCase aliases -- Controllers → factory functions with I*Controller aliases -- DI symbols + module updated with .toDynamicValue() bindings for factories -- New: real UsersRepository (Payload-backed) -- New: real AuthenticationService (Payload-backed; some methods may be deferred — see refactor log) -- bindProductionAuth swaps both mocks for real impls -- Tests refactored to construct mocks and inject directly (no container) - -Refactor log: §2, §4.1, §4.2, §5.1, §6.1 -Spec: §6.1, §7" -``` - ---- - -### Task 5: Refactor `blog` to factory functions + add getArticleBySlug use case - -**Files:** -- Modify: `application/use-cases/get-articles.use-case.ts`, `create-article.use-case.ts` — convert to factory -- Create: `application/use-cases/get-article-by-slug.use-case.ts` (factory) -- Modify: `interface-adapters/controllers/articles.controller.ts` — DELETE this file -- Create: `interface-adapters/controllers/get-articles.controller.ts`, `create-article.controller.ts`, `get-article-by-slug.controller.ts` (factory each) -- Modify: `di/symbols.ts` — add use case + controller symbols -- Modify: `di/module.ts` — add `.toDynamicValue()` bindings -- Modify: `integrations/api/router.ts` — resolve controllers via container -- Modify: tests for use cases + controllers - -Use Task 4 as the template. Same pattern, different feature. - -- [ ] **Step 1: For each existing use case (get-articles, create-article), update test → RED → convert to factory → GREEN** - -- [ ] **Step 2: Create the new `get-article-by-slug` use case TDD-style** - -Test: -```typescript -// packages/blog/src/application/use-cases/get-article-by-slug.use-case.test.ts -import { describe, it, expect } from "vitest"; -import { getArticleBySlugUseCase } from "@/application/use-cases/get-article-by-slug.use-case"; -import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock"; -import { ArticleNotFoundError } from "@/entities/errors/article"; -import { articleFactory } from "@/__factories__/article.factory"; - -describe("getArticleBySlugUseCase", () => { - it("returns the article when slug exists", async () => { - const repo = new MockArticlesRepository(); - const seed = articleFactory.build({ slug: "test-slug" }); - await repo.createArticle(seed); - - const useCase = getArticleBySlugUseCase(repo); - const result = await useCase({ slug: "test-slug" }); - - expect(result?.slug).toBe("test-slug"); - }); - - it("throws ArticleNotFoundError when slug is missing", async () => { - const repo = new MockArticlesRepository(); - const useCase = getArticleBySlugUseCase(repo); - await expect(useCase({ slug: "does-not-exist" })).rejects.toThrow(ArticleNotFoundError); - }); -}); -``` - -Implementation: -```typescript -// packages/blog/src/application/use-cases/get-article-by-slug.use-case.ts -import { ArticleNotFoundError } from "../../entities/errors/article"; -import type { Article } from "../../entities/models/article"; -import type { IArticlesRepository } from "../repositories/articles.repository.interface"; - -export type IGetArticleBySlugUseCase = ReturnType; - -export const getArticleBySlugUseCase = - (articlesRepository: IArticlesRepository) => - async (input: { slug: string }): Promise
=> { - const article = await articlesRepository.getArticleBySlug(input.slug); - if (!article) { - throw new ArticleNotFoundError(`Article with slug "${input.slug}" not found`); - } - return article; - }; -``` - -- [ ] **Step 3: Convert each controller to its own factory file** - -Delete the old `articles.controller.ts`. Create three new files: `get-articles.controller.ts`, `create-article.controller.ts`, `get-article-by-slug.controller.ts`. Each is a factory function consuming its corresponding use case. - -- [ ] **Step 4: Update DI symbols, module, router** - -Same pattern as Task 4 — add symbols, add `.toDynamicValue()` bindings, update tRPC router to resolve via container. - -- [ ] **Step 5: Verify** - -- [ ] **Step 6: Update changelog** - -Under §2 Files added: `get-article-by-slug.use-case.ts` and three new controller files. Under §3 Files deleted: `articles.controller.ts`. Under §4.1, §4.2, §5.1: confirm patterns applied. - -- [ ] **Step 7: Commit** - -``` -refactor(blog): factory-style use cases + per-use-case controllers + getArticleBySlug - -- Use cases (create-article, get-articles, get-article-by-slug NEW) → factory functions -- Controllers split: articles.controller.ts → 3 single-responsibility files -- DI module wires factories with .toDynamicValue() -- tRPC router resolves controllers via container - -Refactor log: §2, §3, §4.1, §4.2, §5.1 -Spec: §6.2" -``` - ---- - -### Task 6: Refactor `marketing-pages` to factory functions - -Same pattern as Task 5. Use cases: `get-page-by-slug`, `get-site-settings`. Controllers: split `pages.controller.ts` into per-use-case files. - -Each TDD'd, single-commit. - -Commit message: `refactor(marketing-pages): factory-style use cases + per-use-case controllers` - ---- - -### Task 7: Refactor `navigation` to factory functions - -Same pattern. Single use case (`get-header`) and single controller (`get-header.controller.ts` — already isomorphic). Just convert to factory style + add type aliases + DI updates. - -Commit message: `refactor(navigation): factory-style use case + controller` - ---- - -### Task 8: Scaffold `media` as a full Clean Architecture feature - -This is the largest task. Read media's current state: - -```bash -ls packages/media/src/ -ls packages/media/src/integrations/cms/collections/ -``` - -Today: only `integrations/cms/collections/media.ts` exists. Need to add the full template: - -**Files to create:** -- `src/entities/models/media.ts` — Zod schema (Media type — id, alt, url, filename, mimeType, filesize, width, height) — adapt from existing factory -- `src/entities/errors/media.ts` (MediaNotFoundError) -- `src/entities/errors/common.ts` (InputParseError) -- `src/application/repositories/media.repository.interface.ts` — IMediaRepository: `getMedia(id)`, `getMediaById(id)`, `listMedia(opts)`, `deleteMedia(id)` -- `src/application/use-cases/get-media.use-case.ts` (factory) -- `src/application/use-cases/list-media.use-case.ts` (factory) -- `src/application/use-cases/delete-media.use-case.ts` (factory) -- `src/infrastructure/repositories/media.repository.ts` (Payload-backed) -- `src/infrastructure/repositories/media.repository.mock.ts` -- `src/interface-adapters/controllers/get-media.controller.ts` -- `src/interface-adapters/controllers/list-media.controller.ts` -- `src/interface-adapters/controllers/delete-media.controller.ts` -- `src/di/symbols.ts` (MEDIA_SYMBOLS) -- `src/di/module.ts` -- `src/di/container.ts` -- `src/di/bind-production.ts` -- `src/integrations/api/router.ts` (mediaRouter) -- `src/integrations/api/index.ts` -- `src/__factories__/media.factory.ts` (already exists — adapt to use new entity model path) -- `src/__contracts__/media-repository.contract.ts` (NEW) -- `src/index.ts` — public exports -- `tests/media.feature.test.ts` (feature integration) - -**Modify:** -- `packages/media/package.json` — add inversify and reflect-metadata deps; expose `./api` and `./di/bind-production` exports -- `packages/core-api/src/root.ts` — add `media: mediaRouter` to appRouter -- `apps/web-next/src/server/bind-production.ts` — call `bindProductionMedia(config)` at boot -- `tsconfig.base.json` — add `@repo/media/api` and `@repo/media/di/bind-production` aliases - -TDD each component. Commit at end. - -Commit message: `feat(media): full Clean Architecture scaffold` - ---- - -### Task 9: Update factories + contracts to point at new entity paths - -After tasks 2-8, the entity paths have changed (`models/.ts` instead of `.ts`). Verify all factory imports + contract imports are correctly pointing at the new paths. - -```bash -grep -rn "from.*entities/article\b" packages/blog/src/__factories__ packages/blog/src/__contracts__ -``` - -Each grep should return zero results because the paths are now `entities/models/article`. - -Run all tests to confirm. - -Commit message: `chore(features): align factory + contract imports with entities/models/* paths` (only if there are actual changes; this task may be a no-op if Task 2 already updated all imports). - ---- - -### Task 10: Final verification + refactor changelog completion - -- [ ] **Run full validation** - -```bash -pnpm install -pnpm typecheck -pnpm lint -pnpm test -pnpm turbo boundaries -pnpm build -``` - -All green. - -- [ ] **Verify file layout matches spec §5 template for every feature** - -```bash -for feat in auth blog marketing-pages navigation media; do - echo "=== $feat ===" - find packages/$feat/src -type f -name "*.ts" | sort -done -``` - -Compare against §5. Flag any deviations in the changelog. - -- [ ] **Verify no `entities/.ts` exists at root** (only `entities/models/.ts` and `entities/errors/.ts`) - -```bash -find packages/*/src/entities -maxdepth 1 -type f -name "*.ts" -``` - -Should be empty (any file at this level is a violation). - -- [ ] **Verify no `mock-` prefixed files exist** - -```bash -find packages -name "mock-*.ts" -not -path "*/node_modules/*" -``` - -Should be empty. - -- [ ] **Verify no `payload-` prefixed files exist** - -```bash -find packages -name "payload-*.ts" -not -path "*/node_modules/*" -``` - -Should be empty. - -- [ ] **Verify every use case has an `I*UseCase` type alias** - -```bash -grep -L "export type I.*UseCase = ReturnType **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add distributed tracing and exception capture to the monorepo with vendor-agnostic interfaces in `core-shared`, full-depth spans (procedure → controller → use-case → repository), throw-site error capture with double-report guard, and hard-coded PII rules across three apps (web-next, cms, web-tanstack). - -**Architecture:** Two interfaces (`ITracer`, `ILogger`) in `core-shared/instrumentation/` with `Noop`, `Sentry`, and (in `core-testing`) `Recording` implementations. Use-case + controller spans applied via a `withSpan` higher-order wrapper at DI binding time; repository methods emit explicit `tracer.startSpan(...)` calls. Per-app DSNs init Sentry only when env is set; Noop is the default everywhere else (orthogonal to USE_DEV_SEED / NODE_ENV). - -**Tech Stack:** `@sentry/nextjs` (web-next, cms), `@sentry/node` + `@sentry/vite-plugin` (web-tanstack), inversify (existing), zod (existing), vitest (existing), `eslint-plugin-no-restricted-imports` (boundary rule). - -**Source spec:** `docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md` (R31–R55). - ---- - -## File structure overview - -### Created (core-shared) - -``` -packages/core-shared/src/instrumentation/ -├── index.ts — public re-exports -├── tracer.interface.ts — ITracer, ISpan, SpanOpts, AttributeValue -├── logger.interface.ts — ILogger, CaptureContext, Breadcrumb -├── noop-tracer.ts — pass-through ITracer -├── noop-tracer.test.ts -├── noop-logger.ts — pass-through ILogger -├── noop-logger.test.ts -├── with-span.ts — higher-order span wrapper -├── with-span.test.ts -├── symbols.ts — TRACER, LOGGER inversify symbols -├── sentry/ -│ ├── sentry-tracer.ts -│ ├── sentry-tracer.test.ts -│ ├── sentry-logger.ts -│ ├── sentry-logger.test.ts -│ ├── scrub.ts — beforeSend + beforeSendTransaction -│ ├── scrub.test.ts -│ ├── pii-fields.ts — regex constants -│ ├── init-server.ts -│ ├── init-server.test.ts -│ ├── init-client.ts -│ └── init-client.test.ts -└── di/ - ├── bind-noop-instrumentation.ts - ├── bind-noop-instrumentation.test.ts - ├── bind-sentry-instrumentation.ts - └── bind-sentry-instrumentation.test.ts -``` - -### Created (core-testing) - -``` -packages/core-testing/src/instrumentation/ -├── index.ts -├── recording-tracer.ts — captures startSpan calls -├── recording-tracer.test.ts -├── recording-logger.ts — captures captureException calls -└── recording-logger.test.ts -``` - -### Modified (core-testing) - -- `packages/core-testing/src/index.ts` — re-export from `./instrumentation/index.js` -- `packages/core-testing/src/setup/vitest.setup.ts` — bind Noop instrumentation by default - -### Modified (every feature: blog, auth, marketing-pages, navigation, media) - -- `packages//src/di/bind-production.ts` — accept `(config, tracer, logger)`, wrap factories with `withSpan` -- `packages//src/di/bind-dev-seed.ts` — accept `(tracer, logger)`, wrap factories -- `packages//src/di/symbols.ts` — re-export shared TRACER/LOGGER (or bind directly to feature container) -- `packages//src/infrastructure/repositories/.repository.ts` — constructor takes `tracer`, `logger`; every method wraps body in `tracer.startSpan(...)` -- `packages//src/infrastructure/repositories/.repository.mock.ts` — same constructor signature with Noop defaults; same `startSpan` wrapping -- `packages//src/__contracts__/-repository.contract.ts` — assert span emission per method (R50) - -### Modified (apps) - -- `apps/web-next/src/server/bind-production.ts` — `bindAll()` gains Rule 0 (DSN → Sentry vs Noop), threads tracer/logger to every feature binder -- `apps/web-next/instrumentation.ts` — NEW -- `apps/web-next/instrumentation-client.ts` — NEW -- `apps/web-next/next.config.mjs` — wrap with `withSentryConfig` -- `apps/web-next/src/__tests__/sentry-pii-scrubber.test.ts` — NEW (R38) -- `apps/cms/instrumentation.ts` — NEW -- `apps/cms/next.config.mjs` — wrap with `withSentryConfig` -- `apps/cms/src/__tests__/sentry-pii-scrubber.test.ts` — NEW (R38) -- `apps/web-tanstack/src/instrumentation.ts` — NEW -- `apps/web-tanstack/src/instrumentation-client.ts` — NEW -- `apps/web-tanstack/vite.config.ts` — add `@sentry/vite-plugin` -- `apps/web-tanstack/src/__tests__/sentry-pii-scrubber.test.ts` — NEW (R38) - -### Modified (config + tooling) - -- `turbo.json` — `globalEnv` adds 8 new vars (per spec §4.7) -- `packages/core-eslint/base.js` — add `no-restricted-imports` for `@sentry/*` (R40) -- `.github/workflows/ci.yml` (or equivalent) — grep step for `sendDefaultPii: true` (R31) - -### Modified (docs + HTML) - -- `docs/superpowers/refactor-logs/2026-05-06-instrumentation-sentry.md` — NEW (R54) -- `docs/decisions/adr-014-instrumentation-sentry.md` — NEW (R55) -- `CLAUDE.md` — instrumentation conventions -- `AGENTS.md` — TRACER/LOGGER symbols, repo span rule, capture rule -- `docs/architecture/vertical-feature-spec.md` — add §10 instrumentation -- `docs/guides/tdd-workflow.md` — RecordingTracer/Logger usage -- `docs/guides/testing-strategy.md` — span/capture assertion patterns -- `docs/architecture/dependency-flow.md` — TRACER/LOGGER dataflow -- `docs/architecture/data-flow-explainer.html` — new §07 "Tracing & error capture" -- `docs/architecture/di-explainer.html` — instrumentation symbols + binders -- `packages/core-shared/AGENTS.md` — instrumentation/ subfolder conventions - ---- - -## Task index - -- **Phase A — Foundation:** Tasks 1–6 (scaffold, interfaces, Noops, withSpan, symbols) -- **Phase B — Sentry adapters:** Tasks 7–11 (SentryTracer, SentryLogger, scrubbers, init helpers) -- **Phase C — DI binders:** Tasks 12–14 (bindNoop, bindSentry, bindAll dispatcher) -- **Phase D — Test infra:** Tasks 15–17 (RecordingTracer, RecordingLogger, vitest setup) -- **Phase E — Per-feature wiring:** Tasks 18–22 (blog pilot + auth + marketing-pages + navigation + media) -- **Phase F — Contract + factory upgrades:** Tasks 23–24 (defineContractSuite expectSpan, contract suite updates) -- **Phase G — App integration:** Tasks 25–27 (web-next, cms, web-tanstack) -- **Phase H — Boundary + config:** Tasks 28–29 (ESLint rule + CI grep + turbo.json) -- **Phase I — Docs + HTML:** Tasks 30–33 (refactor-log/ADR final, docs pass, HTML updates) - ---- - -(Tasks below — each TDD'd, single-commit, code-complete.) - ---- - -## Phase A — Foundation - -### Task 1: Scaffold refactor log + ADR-014 stub - -**Files:** -- Create: `docs/superpowers/refactor-logs/2026-05-06-instrumentation-sentry.md` -- Create: `docs/decisions/adr-014-instrumentation-sentry.md` - -- [ ] **Step 1: Write the refactor log** - -```markdown -# Refactor Log — Instrumentation + Sentry Logging (Plan 10) - -**Date:** 2026-05-06 -**Spec:** docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md -**Plan:** docs/superpowers/plans/2026-05-06-plan-10-instrumentation-sentry.md -**Branch:** feature/instrumentation-sentry - -## Tasks - -- [ ] Task 1 — Scaffold refactor log + ADR-014 stub -- [ ] Task 2 — Tracer interface + ISpan + AttributeValue + SpanOpts -- [ ] Task 3 — NoopTracer -- [ ] Task 4 — Logger interface + NoopLogger + Breadcrumb + CaptureContext -- [ ] Task 5 — withSpan helper -- [ ] Task 6 — Symbols + index barrel -- [ ] Task 7 — SentryTracer adapter -- [ ] Task 8 — SentryLogger adapter (with double-report guard) -- [ ] Task 9 — pii-fields constants + scrub.beforeSend / scrub.beforeSendTransaction -- [ ] Task 10 — init-server helper -- [ ] Task 11 — init-client helper (browser-only) -- [ ] Task 12 — bindNoopInstrumentation + bindSentryInstrumentation -- [ ] Task 13 — apps/web-next bindAll() Rule 0 dispatcher -- [ ] Task 14 — Tests for bindAll() orthogonality (R47) -- [ ] Task 15 — RecordingTracer in core-testing -- [ ] Task 16 — RecordingLogger in core-testing -- [ ] Task 17 — vitest.setup.ts binds Noop by default -- [ ] Task 18 — Blog feature wiring (pilot) -- [ ] Task 19 — Auth feature wiring -- [ ] Task 20 — Marketing-pages feature wiring -- [ ] Task 21 — Navigation feature wiring -- [ ] Task 22 — Media feature wiring -- [ ] Task 23 — defineContractSuite expectSpan helper -- [ ] Task 24 — Update repo contract suites to assert span shape -- [ ] Task 25 — apps/web-next instrumentation files + scrubber test -- [ ] Task 26 — apps/cms instrumentation files + scrubber test -- [ ] Task 27 — apps/web-tanstack instrumentation files + scrubber test -- [ ] Task 28 — ESLint boundary rule (R40) + CI grep gate (R31) -- [ ] Task 29 — turbo.json globalEnv updates -- [ ] Task 30 — Doc updates (CLAUDE.md, AGENTS.md, vertical-feature-spec.md) -- [ ] Task 31 — Doc updates (tdd-workflow.md, testing-strategy.md, dependency-flow.md, core-shared/AGENTS.md) -- [ ] Task 32 — HTML updates (data-flow-explainer §07, di-explainer additions) -- [ ] Task 33 — ADR-014 final + refactor log final - -## Decisions deviated from spec - -(populate as work progresses) - -## Notable surprises - -(populate as work progresses) -``` - -- [ ] **Step 2: Write the ADR-014 stub** - -```markdown -# ADR-014 — Instrumentation & Sentry Logging - -**Status:** Proposed (will be Accepted on Plan 10 completion) -**Date:** 2026-05-06 -**Spec:** docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md - -## Context - -(stub — finalized in Task 33) - -## Decision - -(stub — finalized in Task 33) - -## Consequences - -(stub — finalized in Task 33) -``` - -- [ ] **Step 3: Commit** - -```bash -git add docs/superpowers/refactor-logs/2026-05-06-instrumentation-sentry.md \ - docs/decisions/adr-014-instrumentation-sentry.md -git commit -m "chore(plan-10): scaffold refactor log + ADR-014 stub" -``` - ---- - -### Task 2: Tracer interface + ISpan + types - -**Files:** -- Create: `packages/core-shared/src/instrumentation/tracer.interface.ts` -- (Tests come in Task 3 — interface alone has nothing to test.) - -- [ ] **Step 1: Write the file** - -```ts -// packages/core-shared/src/instrumentation/tracer.interface.ts - -export type AttributeValue = string | number | boolean | null; - -export type SpanOpts = { - name: string; - op?: "use-case" | "controller" | "repository" | "service" | string; - attributes?: Record; -}; - -export interface ISpan { - setAttribute(key: string, value: AttributeValue): void; - setStatus(status: "ok" | "error", message?: string): void; -} - -export interface ITracer { - startSpan(opts: SpanOpts, fn: (span: ISpan) => Promise): Promise; -} -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `pnpm --filter @repo/core-shared build` -Expected: build succeeds; new file present in `dist/`. - -- [ ] **Step 3: Commit** - -```bash -git add packages/core-shared/src/instrumentation/tracer.interface.ts -git commit -m "feat(core-shared): add ITracer/ISpan interfaces" -``` - ---- - -### Task 3: NoopTracer - -**Files:** -- Create: `packages/core-shared/src/instrumentation/noop-tracer.ts` -- Create: `packages/core-shared/src/instrumentation/noop-tracer.test.ts` - -- [ ] **Step 1: Write the failing test** - -```ts -// packages/core-shared/src/instrumentation/noop-tracer.test.ts -import { describe, it, expect, vi } from "vitest"; -import { NoopTracer } from "@/instrumentation/noop-tracer"; -import type { ISpan } from "@/instrumentation/tracer.interface"; - -describe("NoopTracer", () => { - it("startSpan returns the function result", async () => { - const tracer = new NoopTracer(); - const result = await tracer.startSpan({ name: "test.op" }, async () => 42); - expect(result).toBe(42); - }); - - it("startSpan passes a no-op ISpan to the function", async () => { - const tracer = new NoopTracer(); - let received: ISpan | undefined; - await tracer.startSpan({ name: "test.op" }, async (span) => { - received = span; - return undefined; - }); - expect(received).toBeDefined(); - // setAttribute and setStatus must be callable without throwing - expect(() => received!.setAttribute("k", "v")).not.toThrow(); - expect(() => received!.setStatus("ok")).not.toThrow(); - expect(() => received!.setStatus("error", "msg")).not.toThrow(); - }); - - it("propagates exceptions from the wrapped function", async () => { - const tracer = new NoopTracer(); - const err = new Error("boom"); - await expect( - tracer.startSpan({ name: "test.op" }, async () => { - throw err; - }), - ).rejects.toBe(err); - }); - - it("does not invoke external services", async () => { - const tracer = new NoopTracer(); - const fn = vi.fn(async () => "ok"); - await tracer.startSpan({ name: "test.op" }, fn); - expect(fn).toHaveBeenCalledTimes(1); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm --filter @repo/core-shared test noop-tracer` -Expected: FAIL — `NoopTracer` not found. - -- [ ] **Step 3: Implement NoopTracer** - -```ts -// packages/core-shared/src/instrumentation/noop-tracer.ts -import type { ITracer, ISpan, SpanOpts } from "./tracer.interface"; - -const NOOP_SPAN: ISpan = { - setAttribute: () => {}, - setStatus: () => {}, -}; - -export class NoopTracer implements ITracer { - async startSpan(_opts: SpanOpts, fn: (span: ISpan) => Promise): Promise { - return fn(NOOP_SPAN); - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pnpm --filter @repo/core-shared test noop-tracer` -Expected: PASS — 4 tests. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-shared/src/instrumentation/noop-tracer.ts \ - packages/core-shared/src/instrumentation/noop-tracer.test.ts -git commit -m "feat(core-shared): add NoopTracer" -``` - ---- - -### Task 4: Logger interface + NoopLogger - -**Files:** -- Create: `packages/core-shared/src/instrumentation/logger.interface.ts` -- Create: `packages/core-shared/src/instrumentation/noop-logger.ts` -- Create: `packages/core-shared/src/instrumentation/noop-logger.test.ts` - -- [ ] **Step 1: Write the interface** - -```ts -// packages/core-shared/src/instrumentation/logger.interface.ts - -export type Breadcrumb = { - category: string; - message: string; - level?: "info" | "warning" | "error"; - data?: Record; -}; - -export type CaptureContext = { - tags?: Record; - extras?: Record; - fingerprint?: string[]; -}; - -export interface ILogger { - captureException(err: unknown, ctx?: CaptureContext): void; - captureMessage( - msg: string, - level?: "info" | "warning" | "error", - ctx?: CaptureContext, - ): void; - addBreadcrumb(b: Breadcrumb): void; - setUser(user: { id: string } | null): void; -} -``` - -- [ ] **Step 2: Write the failing test** - -```ts -// packages/core-shared/src/instrumentation/noop-logger.test.ts -import { describe, it, expect } from "vitest"; -import { NoopLogger } from "@/instrumentation/noop-logger"; - -describe("NoopLogger", () => { - it("captureException is callable with err and ctx", () => { - const logger = new NoopLogger(); - expect(() => logger.captureException(new Error("x"))).not.toThrow(); - expect(() => - logger.captureException(new Error("x"), { tags: { feature: "blog" } }), - ).not.toThrow(); - }); - - it("captureMessage is callable", () => { - const logger = new NoopLogger(); - expect(() => logger.captureMessage("hello")).not.toThrow(); - expect(() => logger.captureMessage("hello", "warning")).not.toThrow(); - expect(() => - logger.captureMessage("hello", "error", { extras: { foo: 1 } }), - ).not.toThrow(); - }); - - it("addBreadcrumb is callable", () => { - const logger = new NoopLogger(); - expect(() => - logger.addBreadcrumb({ category: "test", message: "x" }), - ).not.toThrow(); - }); - - it("setUser accepts opaque id and null", () => { - const logger = new NoopLogger(); - expect(() => logger.setUser({ id: "u1" })).not.toThrow(); - expect(() => logger.setUser(null)).not.toThrow(); - }); -}); -``` - -- [ ] **Step 3: Run test to verify it fails** - -Run: `pnpm --filter @repo/core-shared test noop-logger` -Expected: FAIL — `NoopLogger` not found. - -- [ ] **Step 4: Implement NoopLogger** - -```ts -// packages/core-shared/src/instrumentation/noop-logger.ts -import type { ILogger, Breadcrumb, CaptureContext } from "./logger.interface"; - -export class NoopLogger implements ILogger { - captureException(_err: unknown, _ctx?: CaptureContext): void {} - captureMessage( - _msg: string, - _level?: "info" | "warning" | "error", - _ctx?: CaptureContext, - ): void {} - addBreadcrumb(_b: Breadcrumb): void {} - setUser(_user: { id: string } | null): void {} -} -``` - -- [ ] **Step 5: Run test to verify it passes** - -Run: `pnpm --filter @repo/core-shared test noop-logger` -Expected: PASS — 4 tests. - -- [ ] **Step 6: Commit** - -```bash -git add packages/core-shared/src/instrumentation/logger.interface.ts \ - packages/core-shared/src/instrumentation/noop-logger.ts \ - packages/core-shared/src/instrumentation/noop-logger.test.ts -git commit -m "feat(core-shared): add ILogger interface + NoopLogger" -``` - ---- - -### Task 5: `withSpan` helper - -**Files:** -- Create: `packages/core-shared/src/instrumentation/with-span.ts` -- Create: `packages/core-shared/src/instrumentation/with-span.test.ts` - -- [ ] **Step 1: Write the failing test** - -```ts -// packages/core-shared/src/instrumentation/with-span.test.ts -import { describe, it, expect, vi } from "vitest"; -import { withSpan } from "@/instrumentation/with-span"; -import type { ITracer, ISpan, SpanOpts } from "@/instrumentation/tracer.interface"; - -function makeRecordingTracer() { - const calls: SpanOpts[] = []; - const tracer: ITracer = { - startSpan: vi.fn(async (opts, fn) => { - calls.push(opts); - const span: ISpan = { setAttribute: () => {}, setStatus: () => {} }; - return fn(span); - }), - }; - return { tracer, calls }; -} - -describe("withSpan", () => { - it("wraps fn with a span using static opts", async () => { - const { tracer, calls } = makeRecordingTracer(); - const fn = async (a: number, b: number) => a + b; - const wrapped = withSpan(tracer, { name: "test.add", op: "use-case" }, fn); - const result = await wrapped(2, 3); - expect(result).toBe(5); - expect(calls).toHaveLength(1); - expect(calls[0]).toEqual({ name: "test.add", op: "use-case" }); - }); - - it("wraps fn with span opts derived from args (function form)", async () => { - const { tracer, calls } = makeRecordingTracer(); - const fn = async (id: string) => `result-${id}`; - const wrapped = withSpan( - tracer, - ([id]) => ({ name: "test.byId", op: "repository", attributes: { id } }), - fn, - ); - const result = await wrapped("abc"); - expect(result).toBe("result-abc"); - expect(calls).toHaveLength(1); - expect(calls[0]).toEqual({ - name: "test.byId", - op: "repository", - attributes: { id: "abc" }, - }); - }); - - it("propagates errors thrown by fn", async () => { - const { tracer } = makeRecordingTracer(); - const wrapped = withSpan(tracer, { name: "test.err" }, async () => { - throw new Error("boom"); - }); - await expect(wrapped()).rejects.toThrow("boom"); - }); - - it("preserves identity across multiple invocations (closure stable)", async () => { - const { tracer, calls } = makeRecordingTracer(); - const wrapped = withSpan(tracer, { name: "test.same" }, async (n: number) => n); - await wrapped(1); - await wrapped(2); - expect(calls).toHaveLength(2); - expect(calls.every((c) => c.name === "test.same")).toBe(true); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm --filter @repo/core-shared test with-span` -Expected: FAIL — `withSpan` not found. - -- [ ] **Step 3: Implement withSpan** - -```ts -// packages/core-shared/src/instrumentation/with-span.ts -import type { ITracer, SpanOpts } from "./tracer.interface"; - -export function withSpan( - tracer: ITracer, - opts: SpanOpts | ((args: Args) => SpanOpts), - fn: (...args: Args) => Promise, -): (...args: Args) => Promise { - return (...args) => { - const resolved = typeof opts === "function" ? opts(args) : opts; - return tracer.startSpan(resolved, () => fn(...args)); - }; -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pnpm --filter @repo/core-shared test with-span` -Expected: PASS — 4 tests. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-shared/src/instrumentation/with-span.ts \ - packages/core-shared/src/instrumentation/with-span.test.ts -git commit -m "feat(core-shared): add withSpan higher-order helper" -``` - ---- - -### Task 6: Symbols + index barrel - -**Files:** -- Create: `packages/core-shared/src/instrumentation/symbols.ts` -- Create: `packages/core-shared/src/instrumentation/index.ts` -- Modify: `packages/core-shared/src/index.ts` (re-export instrumentation) -- Modify: `packages/core-shared/package.json` (add `./instrumentation` subpath if not implicit) - -- [ ] **Step 1: Write the symbols file** - -```ts -// packages/core-shared/src/instrumentation/symbols.ts -export const INSTRUMENTATION_SYMBOLS = { - TRACER: Symbol.for("core-shared.TRACER"), - LOGGER: Symbol.for("core-shared.LOGGER"), -} as const; -``` - -- [ ] **Step 2: Write the index barrel** - -```ts -// packages/core-shared/src/instrumentation/index.ts -export type { - ITracer, - ISpan, - SpanOpts, - AttributeValue, -} from "./tracer.interface"; -export type { - ILogger, - Breadcrumb, - CaptureContext, -} from "./logger.interface"; -export { NoopTracer } from "./noop-tracer"; -export { NoopLogger } from "./noop-logger"; -export { withSpan } from "./with-span"; -export { INSTRUMENTATION_SYMBOLS } from "./symbols"; -``` - -- [ ] **Step 3: Re-export from package root** - -```ts -// packages/core-shared/src/index.ts (append at bottom; preserve existing exports) -export * from "./instrumentation/index"; -``` - -- [ ] **Step 4: Add subpath export in package.json** - -Open `packages/core-shared/package.json` and ensure the `exports` field includes: - -```json -{ - "exports": { - ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" }, - "./instrumentation": { - "import": "./dist/instrumentation/index.js", - "types": "./dist/instrumentation/index.d.ts" - }, - "./trpc/define-error-middleware": { - "import": "./dist/trpc/define-error-middleware.js", - "types": "./dist/trpc/define-error-middleware.d.ts" - } - } -} -``` - -(Preserve any existing entries — the example shows the *additions*. If the current file uses different export structure, integrate accordingly.) - -- [ ] **Step 5: Build to verify** - -Run: `pnpm --filter @repo/core-shared build` -Expected: build succeeds. - -- [ ] **Step 6: Verify subpath import works** - -Create a one-off check: - -```bash -cat <<'EOF' > /tmp/check-instrumentation.ts -import { NoopTracer, NoopLogger, withSpan, INSTRUMENTATION_SYMBOLS } from "@repo/core-shared/instrumentation"; -console.log(typeof NoopTracer, typeof NoopLogger, typeof withSpan, INSTRUMENTATION_SYMBOLS.TRACER); -EOF -cd packages/core-shared && pnpm tsc --noEmit /tmp/check-instrumentation.ts -``` - -Expected: no errors. - -(Delete `/tmp/check-instrumentation.ts` after.) - -- [ ] **Step 7: Commit** - -```bash -git add packages/core-shared/src/instrumentation/symbols.ts \ - packages/core-shared/src/instrumentation/index.ts \ - packages/core-shared/src/index.ts \ - packages/core-shared/package.json -git commit -m "feat(core-shared): symbols + barrel for instrumentation subpath" -``` - ---- - -## Phase B — Sentry adapters - -> **Prerequisite:** Add `@sentry/nextjs` to `packages/core-shared/package.json` dependencies before starting Task 7. The adapter files import from it. - -```bash -pnpm --filter @repo/core-shared add @sentry/nextjs -``` - -### Task 7: SentryTracer adapter - -**Files:** -- Create: `packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts` -- Create: `packages/core-shared/src/instrumentation/sentry/sentry-tracer.test.ts` - -- [ ] **Step 1: Write the failing test** - -```ts -// packages/core-shared/src/instrumentation/sentry/sentry-tracer.test.ts -import { describe, it, expect, vi, beforeEach } from "vitest"; - -vi.mock("@sentry/nextjs", () => ({ - startSpan: vi.fn((_opts, fn) => fn({ setAttribute: vi.fn(), setStatus: vi.fn() })), -})); - -import * as Sentry from "@sentry/nextjs"; -import { SentryTracer } from "@/instrumentation/sentry/sentry-tracer"; - -describe("SentryTracer", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("delegates startSpan to @sentry/nextjs.startSpan", async () => { - const tracer = new SentryTracer(); - const result = await tracer.startSpan( - { name: "blog.getArticles", op: "use-case" }, - async () => "value", - ); - expect(result).toBe("value"); - expect(Sentry.startSpan).toHaveBeenCalledTimes(1); - expect((Sentry.startSpan as any).mock.calls[0][0]).toMatchObject({ - name: "blog.getArticles", - op: "use-case", - }); - }); - - it("forwards attributes to Sentry", async () => { - const tracer = new SentryTracer(); - await tracer.startSpan( - { name: "articles.findAll", op: "repository", attributes: { collection: "articles", limit: 10 } }, - async () => undefined, - ); - expect((Sentry.startSpan as any).mock.calls[0][0].attributes).toEqual({ - collection: "articles", - limit: 10, - }); - }); - - it("propagates errors from the wrapped function", async () => { - const tracer = new SentryTracer(); - await expect( - tracer.startSpan({ name: "x" }, async () => { - throw new Error("boom"); - }), - ).rejects.toThrow("boom"); - }); - - it("ISpan adapter forwards setAttribute and setStatus to Sentry's span", async () => { - const sentrySpan = { setAttribute: vi.fn(), setStatus: vi.fn() }; - (Sentry.startSpan as any).mockImplementationOnce((_opts: unknown, fn: any) => fn(sentrySpan)); - const tracer = new SentryTracer(); - await tracer.startSpan({ name: "x" }, async (span) => { - span.setAttribute("k", "v"); - span.setStatus("error", "msg"); - return undefined; - }); - expect(sentrySpan.setAttribute).toHaveBeenCalledWith("k", "v"); - // Sentry uses status code 2 for error, 1 for ok — but our adapter passes through the string - expect(sentrySpan.setStatus).toHaveBeenCalled(); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm --filter @repo/core-shared test sentry-tracer` -Expected: FAIL — `SentryTracer` not found. - -- [ ] **Step 3: Implement SentryTracer** - -```ts -// packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts -import * as Sentry from "@sentry/nextjs"; -import type { - ITracer, - ISpan, - SpanOpts, -} from "../tracer.interface"; - -export class SentryTracer implements ITracer { - async startSpan(opts: SpanOpts, fn: (span: ISpan) => Promise): Promise { - return Sentry.startSpan( - { - name: opts.name, - op: opts.op, - attributes: opts.attributes, - }, - async (sentrySpan) => { - const adapter: ISpan = { - setAttribute(key, value) { - sentrySpan?.setAttribute?.(key, value); - }, - setStatus(status, message) { - // Sentry v8+ uses { code: number, message?: string }; we map our enum - const code = status === "ok" ? 1 : 2; - sentrySpan?.setStatus?.({ code, message }); - }, - }; - return fn(adapter); - }, - ); - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pnpm --filter @repo/core-shared test sentry-tracer` -Expected: PASS — 4 tests. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts \ - packages/core-shared/src/instrumentation/sentry/sentry-tracer.test.ts \ - packages/core-shared/package.json packages/core-shared/pnpm-lock.yaml -# (pnpm-lock if separate; otherwise root lockfile) -git commit -m "feat(core-shared): SentryTracer adapter" -``` - ---- - -### Task 8: SentryLogger adapter (with double-report guard) - -**Files:** -- Create: `packages/core-shared/src/instrumentation/sentry/sentry-logger.ts` -- Create: `packages/core-shared/src/instrumentation/sentry/sentry-logger.test.ts` - -- [ ] **Step 1: Write the failing test** - -```ts -// packages/core-shared/src/instrumentation/sentry/sentry-logger.test.ts -import { describe, it, expect, vi, beforeEach } from "vitest"; - -vi.mock("@sentry/nextjs", () => ({ - captureException: vi.fn(), - captureMessage: vi.fn(), - addBreadcrumb: vi.fn(), - setUser: vi.fn(), -})); - -import * as Sentry from "@sentry/nextjs"; -import { SentryLogger } from "@/instrumentation/sentry/sentry-logger"; - -describe("SentryLogger", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("captureException forwards to Sentry on first call", () => { - const logger = new SentryLogger(); - const err = new Error("boom"); - logger.captureException(err, { tags: { feature: "blog" } }); - expect(Sentry.captureException).toHaveBeenCalledTimes(1); - expect((Sentry.captureException as any).mock.calls[0][0]).toBe(err); - }); - - it("captureException is a no-op when err already marked __sentryReported", () => { - const logger = new SentryLogger(); - const err = new Error("already-reported"); - Object.defineProperty(err, "__sentryReported", { value: true }); - logger.captureException(err); - expect(Sentry.captureException).not.toHaveBeenCalled(); - }); - - it("captureException marks err as __sentryReported after sending", () => { - const logger = new SentryLogger(); - const err = new Error("once"); - logger.captureException(err); - expect((err as unknown as { __sentryReported: boolean }).__sentryReported).toBe(true); - // Second call: no-op - logger.captureException(err); - expect(Sentry.captureException).toHaveBeenCalledTimes(1); - }); - - it("__sentryReported is non-enumerable", () => { - const logger = new SentryLogger(); - const err = new Error("x"); - logger.captureException(err); - expect(Object.keys(err)).not.toContain("__sentryReported"); - expect(JSON.stringify(err)).not.toContain("__sentryReported"); - }); - - it("captureMessage forwards to Sentry", () => { - const logger = new SentryLogger(); - logger.captureMessage("hello", "warning", { tags: { foo: "bar" } }); - expect(Sentry.captureMessage).toHaveBeenCalledWith("hello", expect.objectContaining({ level: "warning" })); - }); - - it("addBreadcrumb forwards to Sentry", () => { - const logger = new SentryLogger(); - logger.addBreadcrumb({ category: "test", message: "x", data: { k: "v" } }); - expect(Sentry.addBreadcrumb).toHaveBeenCalledTimes(1); - }); - - it("setUser strips non-id keys and warns in dev", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const logger = new SentryLogger(); - logger.setUser({ id: "u1", email: "a@b.c", username: "alice" } as any); - expect(Sentry.setUser).toHaveBeenCalledWith({ id: "u1" }); - expect(warn).toHaveBeenCalled(); - warn.mockRestore(); - }); - - it("setUser passes null through", () => { - const logger = new SentryLogger(); - logger.setUser(null); - expect(Sentry.setUser).toHaveBeenCalledWith(null); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm --filter @repo/core-shared test sentry-logger` -Expected: FAIL — `SentryLogger` not found. - -- [ ] **Step 3: Implement SentryLogger** - -```ts -// packages/core-shared/src/instrumentation/sentry/sentry-logger.ts -import * as Sentry from "@sentry/nextjs"; -import type { - ILogger, - Breadcrumb, - CaptureContext, -} from "../logger.interface"; - -const REPORTED = "__sentryReported" as const; - -function isReported(err: unknown): boolean { - return ( - err !== null && - typeof err === "object" && - Boolean((err as Record)[REPORTED]) - ); -} - -function markReported(err: unknown): void { - if (err !== null && typeof err === "object") { - Object.defineProperty(err, REPORTED, { - value: true, - enumerable: false, - configurable: false, - writable: false, - }); - } -} - -export class SentryLogger implements ILogger { - captureException(err: unknown, ctx?: CaptureContext): void { - if (isReported(err)) return; - Sentry.captureException(err, ctx); - markReported(err); - } - - captureMessage( - msg: string, - level: "info" | "warning" | "error" = "info", - ctx?: CaptureContext, - ): void { - Sentry.captureMessage(msg, { level, ...ctx }); - } - - addBreadcrumb(b: Breadcrumb): void { - Sentry.addBreadcrumb({ - category: b.category, - message: b.message, - level: b.level, - data: b.data, - }); - } - - setUser(user: { id: string } | null): void { - if (user === null) { - Sentry.setUser(null); - return; - } - const { id, ...extra } = user as { id: string } & Record; - if (Object.keys(extra).length > 0) { - // R36 — strip non-id keys; warn in dev for visibility - console.warn( - "[SentryLogger.setUser] stripped non-id keys for PII safety:", - Object.keys(extra), - ); - } - Sentry.setUser({ id }); - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pnpm --filter @repo/core-shared test sentry-logger` -Expected: PASS — 8 tests. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-shared/src/instrumentation/sentry/sentry-logger.ts \ - packages/core-shared/src/instrumentation/sentry/sentry-logger.test.ts -git commit -m "feat(core-shared): SentryLogger with double-report guard + R36 user-context strip" -``` - ---- - -### Task 9: PII fields + scrubbers (R32, R33) - -**Files:** -- Create: `packages/core-shared/src/instrumentation/sentry/pii-fields.ts` -- Create: `packages/core-shared/src/instrumentation/sentry/scrub.ts` -- Create: `packages/core-shared/src/instrumentation/sentry/scrub.test.ts` - -- [ ] **Step 1: Write the constants** - -```ts -// packages/core-shared/src/instrumentation/sentry/pii-fields.ts - -// R32 — substring match on event keys (case-insensitive) -export const PII_KEY_SUBSTRINGS = [ - "email", - "password", - "token", - "cookie", - "authorization", - "set-cookie", - "x-api-key", - "apikey", - "api_key", - "secret", -] as const; - -// R33 — substring match on URL query-param keys (case-insensitive) -export const PII_QUERY_PARAM_SUBSTRINGS = [ - "token", - "email", - "password", - "key", - "sig", - "signature", - "access_token", - "accesstoken", - "secret", -] as const; - -export const REDACTED_VALUE = "[redacted]" as const; -export const REDACTED_IP = "[redacted-ip]" as const; - -// IPv4: simple dotted-quad; IPv6: any colon-separated hex with at least one :: -export const IPV4_REGEX = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g; -export const IPV6_REGEX = /\b(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}\b|::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}/g; - -export function keyContainsPii(key: string): boolean { - const lower = key.toLowerCase(); - return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s)); -} - -export function queryParamContainsPii(key: string): boolean { - const lower = key.toLowerCase(); - return PII_QUERY_PARAM_SUBSTRINGS.some((s) => lower.includes(s)); -} -``` - -- [ ] **Step 2: Write the failing test** - -```ts -// packages/core-shared/src/instrumentation/sentry/scrub.test.ts -import { describe, it, expect } from "vitest"; -import { beforeSend, beforeSendTransaction } from "@/instrumentation/sentry/scrub"; - -describe("beforeSend", () => { - it("redacts top-level keys whose names contain PII substrings", () => { - const event = { - extra: { email: "a@b.c", username: "alice" }, - contexts: { custom: { password: "p", note: "ok" } }, - } as any; - const result = beforeSend(event, {} as any) as any; - expect(result.extra.email).toBe("[redacted]"); - expect(result.extra.username).toBe("alice"); - expect(result.contexts.custom.password).toBe("[redacted]"); - expect(result.contexts.custom.note).toBe("ok"); - }); - - it("redacts derived key names (substring match): userEmail, accessToken, apiKey", () => { - const event = { - extra: { userEmail: "a@b.c", accessToken: "t", apiKey: "k", id: "u1" }, - } as any; - const result = beforeSend(event, {} as any) as any; - expect(result.extra.userEmail).toBe("[redacted]"); - expect(result.extra.accessToken).toBe("[redacted]"); - expect(result.extra.apiKey).toBe("[redacted]"); - expect(result.extra.id).toBe("u1"); - }); - - it("redacts headers map keys case-insensitively", () => { - const event = { - request: { - headers: { Authorization: "Bearer x", "Set-Cookie": "session=abc", "User-Agent": "ua" }, - }, - } as any; - const result = beforeSend(event, {} as any) as any; - expect(result.request.headers.Authorization).toBe("[redacted]"); - expect(result.request.headers["Set-Cookie"]).toBe("[redacted]"); - expect(result.request.headers["User-Agent"]).toBe("ua"); - }); - - it("redacts IPv4 addresses found in string values", () => { - const event = { extra: { note: "Connection from 192.168.1.10 failed" } } as any; - const result = beforeSend(event, {} as any) as any; - expect(result.extra.note).toBe("Connection from [redacted-ip] failed"); - }); - - it("redacts IPv6 addresses found in string values", () => { - const event = { extra: { note: "Tunnel to fe80::1ff:fe23:4567:890a established" } } as any; - const result = beforeSend(event, {} as any) as any; - expect(result.extra.note).toContain("[redacted-ip]"); - }); - - it("does not crash on null/undefined branches", () => { - expect(beforeSend({ extra: null } as any, {} as any)).toBeTruthy(); - expect(beforeSend({} as any, {} as any)).toBeTruthy(); - }); - - it("returns the event (not null) — keeps Sentry transport flowing", () => { - expect(beforeSend({ extra: { ok: true } } as any, {} as any)).toBeTruthy(); - }); -}); - -describe("beforeSendTransaction", () => { - it("strips PII query params from request.url", () => { - const event = { - request: { url: "https://app/api/foo?token=secret&user=alice&email=a@b.c" }, - } as any; - const result = beforeSendTransaction(event, {} as any) as any; - expect(result.request.url).toContain("token=%5Bredacted%5D"); - expect(result.request.url).toContain("email=%5Bredacted%5D"); - expect(result.request.url).toContain("user=alice"); - }); - - it("strips PII query params from event.transaction", () => { - const event = { transaction: "/foo?token=x&id=y" } as any; - const result = beforeSendTransaction(event, {} as any) as any; - expect(result.transaction).toContain("token=%5Bredacted%5D"); - expect(result.transaction).toContain("id=y"); - }); - - it("matches derived param names (accessToken, ApiSecret)", () => { - const event = { request: { url: "https://x/y?accessToken=t&ApiSecret=z&safe=1" } } as any; - const result = beforeSendTransaction(event, {} as any) as any; - expect(result.request.url).toContain("accessToken=%5Bredacted%5D"); - expect(result.request.url).toContain("ApiSecret=%5Bredacted%5D"); - expect(result.request.url).toContain("safe=1"); - }); - - it("returns the event when no URL present", () => { - expect(beforeSendTransaction({} as any, {} as any)).toBeTruthy(); - }); -}); -``` - -- [ ] **Step 3: Run test to verify it fails** - -Run: `pnpm --filter @repo/core-shared test scrub` -Expected: FAIL — `beforeSend`/`beforeSendTransaction` not found. - -- [ ] **Step 4: Implement scrub** - -```ts -// packages/core-shared/src/instrumentation/sentry/scrub.ts -import type { ErrorEvent, EventHint, TransactionEvent } from "@sentry/nextjs"; -import { - IPV4_REGEX, - IPV6_REGEX, - REDACTED_IP, - REDACTED_VALUE, - keyContainsPii, - queryParamContainsPii, -} from "./pii-fields"; - -function redactString(s: string): string { - return s.replace(IPV4_REGEX, REDACTED_IP).replace(IPV6_REGEX, REDACTED_IP); -} - -function deepScrub(value: unknown, parentKey = ""): unknown { - if (value === null || value === undefined) return value; - if (typeof value === "string") { - return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : redactString(value); - } - if (typeof value === "number" || typeof value === "boolean") { - return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : value; - } - if (Array.isArray(value)) { - return value.map((v) => deepScrub(v, parentKey)); - } - if (typeof value === "object") { - const out: Record = {}; - for (const [k, v] of Object.entries(value as Record)) { - out[k] = keyContainsPii(k) ? REDACTED_VALUE : deepScrub(v, k); - } - return out; - } - return value; -} - -export function beforeSend(event: ErrorEvent, _hint: EventHint): ErrorEvent | null { - return deepScrub(event) as ErrorEvent; -} - -function scrubUrl(url: string): string { - try { - const u = new URL(url, "http://placeholder.local"); - for (const [k] of Array.from(u.searchParams.entries())) { - if (queryParamContainsPii(k)) { - u.searchParams.set(k, REDACTED_VALUE); - } - } - return url.startsWith("/") ? `${u.pathname}${u.search}` : u.toString(); - } catch { - return url; - } -} - -export function beforeSendTransaction( - event: TransactionEvent, - _hint: EventHint, -): TransactionEvent | null { - const out: TransactionEvent = { ...event }; - if (out.request?.url) { - out.request = { ...out.request, url: scrubUrl(out.request.url) }; - } - if (out.transaction && (out.transaction.includes("?") || out.transaction.includes("="))) { - out.transaction = scrubUrl(out.transaction); - } - return out; -} -``` - -- [ ] **Step 5: Run test to verify it passes** - -Run: `pnpm --filter @repo/core-shared test scrub` -Expected: PASS — 11 tests. (If a test is flaky on URL encoding, adjust the assertions to use `decodeURIComponent` or compare against the literal `[redacted]`. The encoded form `%5Bredacted%5D` is the URLSearchParams default; either is acceptable as long as the test is consistent.) - -- [ ] **Step 6: Commit** - -```bash -git add packages/core-shared/src/instrumentation/sentry/pii-fields.ts \ - packages/core-shared/src/instrumentation/sentry/scrub.ts \ - packages/core-shared/src/instrumentation/sentry/scrub.test.ts -git commit -m "feat(core-shared): PII scrubbers — beforeSend (R32) + beforeSendTransaction (R33)" -``` - ---- - -### Task 10: `init-server` helper - -**Files:** -- Create: `packages/core-shared/src/instrumentation/sentry/init-server.ts` -- Create: `packages/core-shared/src/instrumentation/sentry/init-server.test.ts` - -- [ ] **Step 1: Write the failing test** - -```ts -// packages/core-shared/src/instrumentation/sentry/init-server.test.ts -import { describe, it, expect, vi, beforeEach } from "vitest"; - -vi.mock("@sentry/nextjs", () => ({ - init: vi.fn(), - replayIntegration: vi.fn(() => ({ name: "Replay" })), -})); - -import * as Sentry from "@sentry/nextjs"; -import { initSentryServer } from "@/instrumentation/sentry/init-server"; - -describe("initSentryServer", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("calls Sentry.init with sendDefaultPii: false (R31)", () => { - initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); - expect(Sentry.init).toHaveBeenCalledTimes(1); - const call = (Sentry.init as any).mock.calls[0][0]; - expect(call.sendDefaultPii).toBe(false); - }); - - it("passes the configured DSN", () => { - initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); - const call = (Sentry.init as any).mock.calls[0][0]; - expect(call.dsn).toBe("https://x@y/1"); - }); - - it("attaches beforeSend + beforeSendTransaction scrubbers", () => { - initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); - const call = (Sentry.init as any).mock.calls[0][0]; - expect(typeof call.beforeSend).toBe("function"); - expect(typeof call.beforeSendTransaction).toBe("function"); - }); - - it("uses SENTRY_TRACES_SAMPLE_RATE env when set", () => { - const prev = process.env.SENTRY_TRACES_SAMPLE_RATE; - process.env.SENTRY_TRACES_SAMPLE_RATE = "0.25"; - initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); - const call = (Sentry.init as any).mock.calls[0][0]; - expect(call.tracesSampleRate).toBe(0.25); - if (prev === undefined) delete process.env.SENTRY_TRACES_SAMPLE_RATE; - else process.env.SENTRY_TRACES_SAMPLE_RATE = prev; - }); - - it("defaults tracesSampleRate to 1.0 in dev, 0.1 in production", () => { - const prevEnv = process.env.NODE_ENV; - const prevRate = process.env.SENTRY_TRACES_SAMPLE_RATE; - delete process.env.SENTRY_TRACES_SAMPLE_RATE; - - process.env.NODE_ENV = "development"; - initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); - expect((Sentry.init as any).mock.calls[0][0].tracesSampleRate).toBe(1.0); - - (Sentry.init as any).mockClear(); - process.env.NODE_ENV = "production"; - initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); - expect((Sentry.init as any).mock.calls[0][0].tracesSampleRate).toBe(0.1); - - if (prevEnv === undefined) delete process.env.NODE_ENV; - else process.env.NODE_ENV = prevEnv; - if (prevRate !== undefined) process.env.SENTRY_TRACES_SAMPLE_RATE = prevRate; - }); - - it("tags events with the app name", () => { - initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); - const call = (Sentry.init as any).mock.calls[0][0]; - expect(call.initialScope?.tags?.app).toBe("web-next"); - }); - - it("is a no-op when dsn is empty/undefined", () => { - initSentryServer({ dsn: "", app: "web-next" }); - expect(Sentry.init).not.toHaveBeenCalled(); - initSentryServer({ dsn: undefined as any, app: "web-next" }); - expect(Sentry.init).not.toHaveBeenCalled(); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm --filter @repo/core-shared test init-server` -Expected: FAIL — `initSentryServer` not found. - -- [ ] **Step 3: Implement init-server** - -```ts -// packages/core-shared/src/instrumentation/sentry/init-server.ts -import * as Sentry from "@sentry/nextjs"; -import { beforeSend, beforeSendTransaction } from "./scrub"; - -export type InitServerOpts = { - dsn: string | undefined; - app: "web-next" | "cms" | "web-tanstack"; - release?: string; -}; - -export function initSentryServer(opts: InitServerOpts): void { - if (!opts.dsn) return; - - const isProd = process.env.NODE_ENV === "production"; - const tracesSampleRate = - process.env.SENTRY_TRACES_SAMPLE_RATE !== undefined - ? Number(process.env.SENTRY_TRACES_SAMPLE_RATE) - : isProd - ? 0.1 - : 1.0; - - const environment = process.env.SENTRY_ENVIRONMENT ?? process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? "development"; - const release = opts.release ?? process.env.VERCEL_GIT_COMMIT_SHA ?? "unknown"; - - Sentry.init({ - dsn: opts.dsn, - environment, - release, - tracesSampleRate, - sendDefaultPii: false, // R31 — non-negotiable - beforeSend, // R32 - beforeSendTransaction, // R33 - initialScope: { tags: { app: opts.app } }, - }); -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pnpm --filter @repo/core-shared test init-server` -Expected: PASS — 7 tests. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-shared/src/instrumentation/sentry/init-server.ts \ - packages/core-shared/src/instrumentation/sentry/init-server.test.ts -git commit -m "feat(core-shared): initSentryServer helper (R31, R32, R33, R37 defaults)" -``` - ---- - -### Task 11: `init-client` helper (browser-only) - -**Files:** -- Create: `packages/core-shared/src/instrumentation/sentry/init-client.ts` -- Create: `packages/core-shared/src/instrumentation/sentry/init-client.test.ts` - -- [ ] **Step 1: Write the failing test** - -```ts -// packages/core-shared/src/instrumentation/sentry/init-client.test.ts -import { describe, it, expect, vi, beforeEach } from "vitest"; - -const replayIntegration = vi.fn((opts: unknown) => ({ name: "Replay", _opts: opts })); - -vi.mock("@sentry/nextjs", () => ({ - init: vi.fn(), - replayIntegration, -})); - -import * as Sentry from "@sentry/nextjs"; -import { initSentryClient } from "@/instrumentation/sentry/init-client"; - -describe("initSentryClient", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("calls Sentry.init with sendDefaultPii: false (R31)", () => { - initSentryClient({ dsn: "https://x@y/1", app: "web-next" }); - const call = (Sentry.init as any).mock.calls[0][0]; - expect(call.sendDefaultPii).toBe(false); - }); - - it("attaches replay integration with maskAllText/maskAllInputs/blockAllMedia: true (R34, R35)", () => { - initSentryClient({ dsn: "https://x@y/1", app: "web-next" }); - expect(replayIntegration).toHaveBeenCalledTimes(1); - const replayOpts = (replayIntegration as any).mock.calls[0][0]; - expect(replayOpts.maskAllText).toBe(true); - expect(replayOpts.maskAllInputs).toBe(true); - expect(replayOpts.blockAllMedia).toBe(true); - }); - - it("defaults replaysSessionSampleRate to 0.0 (R37)", () => { - initSentryClient({ dsn: "https://x@y/1", app: "web-next" }); - const call = (Sentry.init as any).mock.calls[0][0]; - expect(call.replaysSessionSampleRate).toBe(0.0); - }); - - it("defaults replaysOnErrorSampleRate to 1.0 (R37)", () => { - initSentryClient({ dsn: "https://x@y/1", app: "web-next" }); - const call = (Sentry.init as any).mock.calls[0][0]; - expect(call.replaysOnErrorSampleRate).toBe(1.0); - }); - - it("attaches beforeSend + beforeSendTransaction", () => { - initSentryClient({ dsn: "https://x@y/1", app: "web-next" }); - const call = (Sentry.init as any).mock.calls[0][0]; - expect(typeof call.beforeSend).toBe("function"); - expect(typeof call.beforeSendTransaction).toBe("function"); - }); - - it("is a no-op when dsn is empty", () => { - initSentryClient({ dsn: "", app: "web-next" }); - expect(Sentry.init).not.toHaveBeenCalled(); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm --filter @repo/core-shared test init-client` -Expected: FAIL — `initSentryClient` not found. - -- [ ] **Step 3: Implement init-client** - -```ts -// packages/core-shared/src/instrumentation/sentry/init-client.ts -import * as Sentry from "@sentry/nextjs"; -import { beforeSend, beforeSendTransaction } from "./scrub"; - -export type InitClientOpts = { - dsn: string | undefined; - app: "web-next" | "cms" | "web-tanstack"; - release?: string; -}; - -export function initSentryClient(opts: InitClientOpts): void { - if (!opts.dsn) return; - - const isProd = process.env.NODE_ENV === "production"; - const tracesSampleRate = - process.env.SENTRY_TRACES_SAMPLE_RATE !== undefined - ? Number(process.env.SENTRY_TRACES_SAMPLE_RATE) - : isProd - ? 0.1 - : 1.0; - - const environment = process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV ?? "development"; - const release = opts.release ?? "unknown"; - - Sentry.init({ - dsn: opts.dsn, - environment, - release, - tracesSampleRate, - sendDefaultPii: false, // R31 - beforeSend, // R32 - beforeSendTransaction, // R33 - replaysSessionSampleRate: 0.0, // R37 — privacy default - replaysOnErrorSampleRate: 1.0, // R37 - integrations: [ - // R34, R35 — mandatory mask flags; allowlist starts empty - Sentry.replayIntegration({ - maskAllText: true, - maskAllInputs: true, - blockAllMedia: true, - }), - ], - initialScope: { tags: { app: opts.app } }, - }); -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pnpm --filter @repo/core-shared test init-client` -Expected: PASS — 6 tests. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-shared/src/instrumentation/sentry/init-client.ts \ - packages/core-shared/src/instrumentation/sentry/init-client.test.ts -git commit -m "feat(core-shared): initSentryClient helper (R34, R35, R37 mandatory replay defaults)" -``` - ---- - -## Phase C — DI binders + dispatcher - -> **Design note:** The instrumentation symbols (TRACER, LOGGER) live in `core-shared/instrumentation/symbols.ts`. The two binders accept a Container, bind both symbols, and (for `bindSentry`) call `initSentryServer` first. Apps construct `tracer` and `logger` once at boot, then pass them to every feature binder as parameters. This dual approach (container binding *and* parameter passing) keeps internal-resolution code working while letting feature factories receive instances without container lookups. - -### Task 12: bindNoopInstrumentation + bindSentryInstrumentation - -**Files:** -- Create: `packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.ts` -- Create: `packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.test.ts` -- Create: `packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.ts` -- Create: `packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.test.ts` -- Modify: `packages/core-shared/src/instrumentation/index.ts` (re-export the binders) -- Modify: `packages/core-shared/package.json` (add `./instrumentation/di/*` subpath if needed; the broad `./instrumentation` export should already cover this) - -- [ ] **Step 1: Write the failing test for bindNoopInstrumentation** - -```ts -// packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.test.ts -import "reflect-metadata"; -import { describe, it, expect } from "vitest"; -import { Container } from "inversify"; -import { bindNoopInstrumentation } from "@/instrumentation/di/bind-noop-instrumentation"; -import { INSTRUMENTATION_SYMBOLS } from "@/instrumentation/symbols"; -import { NoopTracer } from "@/instrumentation/noop-tracer"; -import { NoopLogger } from "@/instrumentation/noop-logger"; -import type { ITracer, ILogger } from "@/instrumentation"; - -describe("bindNoopInstrumentation", () => { - it("returns a tracer + logger pair", () => { - const c = new Container(); - const { tracer, logger } = bindNoopInstrumentation(c); - expect(tracer).toBeInstanceOf(NoopTracer); - expect(logger).toBeInstanceOf(NoopLogger); - }); - - it("binds TRACER and LOGGER symbols on the container", () => { - const c = new Container(); - bindNoopInstrumentation(c); - const tracer = c.get(INSTRUMENTATION_SYMBOLS.TRACER); - const logger = c.get(INSTRUMENTATION_SYMBOLS.LOGGER); - expect(tracer).toBeInstanceOf(NoopTracer); - expect(logger).toBeInstanceOf(NoopLogger); - }); - - it("is idempotent — second call rebinds the same instances", () => { - const c = new Container(); - const first = bindNoopInstrumentation(c); - const second = bindNoopInstrumentation(c); - // Implementations are NoopX, but instances may differ — that's fine - expect(c.get(INSTRUMENTATION_SYMBOLS.TRACER)).toBe(second.tracer); - expect(c.get(INSTRUMENTATION_SYMBOLS.LOGGER)).toBe(second.logger); - expect(first.tracer).toBeInstanceOf(NoopTracer); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm --filter @repo/core-shared test bind-noop-instrumentation` -Expected: FAIL — `bindNoopInstrumentation` not found. - -- [ ] **Step 3: Implement bindNoopInstrumentation** - -```ts -// packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.ts -import type { Container } from "inversify"; -import { NoopTracer } from "../noop-tracer"; -import { NoopLogger } from "../noop-logger"; -import { INSTRUMENTATION_SYMBOLS } from "../symbols"; -import type { ITracer, ILogger } from "../index"; - -export function bindNoopInstrumentation(container: Container): { - tracer: ITracer; - logger: ILogger; -} { - const tracer = new NoopTracer(); - const logger = new NoopLogger(); - if (container.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { - container.unbind(INSTRUMENTATION_SYMBOLS.TRACER); - } - if (container.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) { - container.unbind(INSTRUMENTATION_SYMBOLS.LOGGER); - } - container.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); - container.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); - return { tracer, logger }; -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pnpm --filter @repo/core-shared test bind-noop-instrumentation` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Write the failing test for bindSentryInstrumentation** - -```ts -// packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.test.ts -import "reflect-metadata"; -import { describe, it, expect, vi, beforeEach } from "vitest"; - -vi.mock("@sentry/nextjs", () => ({ - init: vi.fn(), - startSpan: vi.fn((_opts, fn) => fn({ setAttribute: vi.fn(), setStatus: vi.fn() })), - captureException: vi.fn(), - captureMessage: vi.fn(), - addBreadcrumb: vi.fn(), - setUser: vi.fn(), - replayIntegration: vi.fn(() => ({ name: "Replay" })), -})); - -import * as Sentry from "@sentry/nextjs"; -import { Container } from "inversify"; -import { bindSentryInstrumentation } from "@/instrumentation/di/bind-sentry-instrumentation"; -import { INSTRUMENTATION_SYMBOLS } from "@/instrumentation/symbols"; -import { SentryTracer } from "@/instrumentation/sentry/sentry-tracer"; -import { SentryLogger } from "@/instrumentation/sentry/sentry-logger"; - -describe("bindSentryInstrumentation", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("calls Sentry.init via initSentryServer", () => { - const c = new Container(); - bindSentryInstrumentation(c, { dsn: "https://x@y/1", app: "web-next" }); - expect(Sentry.init).toHaveBeenCalledTimes(1); - expect((Sentry.init as any).mock.calls[0][0].dsn).toBe("https://x@y/1"); - }); - - it("binds SentryTracer + SentryLogger to the container", () => { - const c = new Container(); - bindSentryInstrumentation(c, { dsn: "https://x@y/1", app: "web-next" }); - expect(c.get(INSTRUMENTATION_SYMBOLS.TRACER)).toBeInstanceOf(SentryTracer); - expect(c.get(INSTRUMENTATION_SYMBOLS.LOGGER)).toBeInstanceOf(SentryLogger); - }); - - it("returns the tracer + logger instances", () => { - const c = new Container(); - const { tracer, logger } = bindSentryInstrumentation(c, { - dsn: "https://x@y/1", - app: "web-next", - }); - expect(tracer).toBeInstanceOf(SentryTracer); - expect(logger).toBeInstanceOf(SentryLogger); - }); -}); -``` - -- [ ] **Step 6: Run test to verify it fails** - -Run: `pnpm --filter @repo/core-shared test bind-sentry-instrumentation` -Expected: FAIL — `bindSentryInstrumentation` not found. - -- [ ] **Step 7: Implement bindSentryInstrumentation** - -```ts -// packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.ts -import type { Container } from "inversify"; -import { SentryTracer } from "../sentry/sentry-tracer"; -import { SentryLogger } from "../sentry/sentry-logger"; -import { initSentryServer, type InitServerOpts } from "../sentry/init-server"; -import { INSTRUMENTATION_SYMBOLS } from "../symbols"; -import type { ITracer, ILogger } from "../index"; - -export type BindSentryOpts = InitServerOpts; - -export function bindSentryInstrumentation( - container: Container, - opts: BindSentryOpts, -): { tracer: ITracer; logger: ILogger } { - initSentryServer(opts); - - const tracer = new SentryTracer(); - const logger = new SentryLogger(); - - if (container.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { - container.unbind(INSTRUMENTATION_SYMBOLS.TRACER); - } - if (container.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) { - container.unbind(INSTRUMENTATION_SYMBOLS.LOGGER); - } - container.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); - container.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); - return { tracer, logger }; -} -``` - -- [ ] **Step 8: Run test to verify it passes** - -Run: `pnpm --filter @repo/core-shared test bind-sentry-instrumentation` -Expected: PASS — 3 tests. - -- [ ] **Step 9: Re-export both binders from instrumentation/index.ts** - -Append to `packages/core-shared/src/instrumentation/index.ts`: - -```ts -export { bindNoopInstrumentation } from "./di/bind-noop-instrumentation"; -export { - bindSentryInstrumentation, - type BindSentryOpts, -} from "./di/bind-sentry-instrumentation"; -``` - -- [ ] **Step 10: Build to verify barrel** - -Run: `pnpm --filter @repo/core-shared build` -Expected: build succeeds. - -- [ ] **Step 11: Commit** - -```bash -git add packages/core-shared/src/instrumentation/di/ \ - packages/core-shared/src/instrumentation/index.ts -git commit -m "feat(core-shared): bindNoopInstrumentation + bindSentryInstrumentation" -``` - ---- - -### Task 13: `bindAll()` Rule 0 dispatcher (apps/web-next) - -**Files:** -- Modify: `apps/web-next/src/server/bind-production.ts` - -> **Note:** Rule 0 wires instrumentation into `bindAll()`, but feature binders haven't been updated yet (those land in Phase E). For now, `bindAll()` constructs tracer + logger and stores them in module-scope variables for downstream binders to read. Per-feature binder signatures change in Phase E to accept tracer/logger as parameters. - -- [ ] **Step 1: Update bind-production.ts** - -Replace the contents of `apps/web-next/src/server/bind-production.ts` with the following. The header comment changes; the existing `bindAllProduction` and `bindAllDevSeed` keep their current signatures (we'll wire tracer/logger threading in Phase E). For now, just add the Rule 0 instrumentation step to `bindAll()`: - -```ts -// apps/web-next/src/server/bind-production.ts -// SERVER-ONLY: this module imports Payload config and must never be bundled into the browser. -import "reflect-metadata"; -import { Container } from "inversify"; -import config from "@repo/core-cms"; -import { - bindNoopInstrumentation, - bindSentryInstrumentation, - type ITracer, - type ILogger, -} from "@repo/core-shared/instrumentation"; -import { bindProductionBlog } from "@repo/blog/di/bind-production"; -import { bindProductionAuth } from "@repo/auth/di/bind-production"; -import { bindProductionMarketingPages } from "@repo/marketing-pages/di/bind-production"; -import { bindProductionNavigation } from "@repo/navigation/di/bind-production"; -import { bindProductionMedia } from "@repo/media/di/bind-production"; -import { bindDevSeedBlog } from "@repo/blog/di/bind-dev-seed"; -import { bindDevSeedAuth } from "@repo/auth/di/bind-dev-seed"; -import { bindDevSeedMarketingPages } from "@repo/marketing-pages/di/bind-dev-seed"; -import { bindDevSeedNavigation } from "@repo/navigation/di/bind-dev-seed"; -import { bindDevSeedMedia } from "@repo/media/di/bind-dev-seed"; - -let bound = false; - -// Shared container holds TRACER + LOGGER bindings; per-feature containers -// receive references via parameter passing. This separates the instrumentation -// container (one) from feature containers (per-feature, ADR-008). -const sharedContainer = new Container(); - -let resolvedTracer: ITracer | null = null; -let resolvedLogger: ILogger | null = null; - -/** Rule 0: pick instrumentation backend from DSN env (orthogonal to repo mode). */ -function resolveInstrumentation(): { tracer: ITracer; logger: ILogger } { - if (resolvedTracer && resolvedLogger) { - return { tracer: resolvedTracer, logger: resolvedLogger }; - } - const dsn = process.env.WEB_NEXT_SENTRY_DSN; - const result = dsn - ? bindSentryInstrumentation(sharedContainer, { dsn, app: "web-next" }) - : bindNoopInstrumentation(sharedContainer); - resolvedTracer = result.tracer; - resolvedLogger = result.logger; - return result; -} - -/** - * Production path: swap each feature's mock repository binding for the real - * Payload-backed one. Constructs `new XRepository(config, tracer, logger)` per - * feature once Phase E feature wiring lands. Until Phase E, this still calls - * the existing `bindProductionX(config)` signature; the unused `tracer`/`logger` - * are forwarded by Phase E commits as those binders are updated. - */ -export async function bindAllProduction(): Promise { - if (bound) return; - bound = true; - resolveInstrumentation(); // Rule 0 - const resolvedConfig = await config; - bindProductionAuth(resolvedConfig); - bindProductionBlog(resolvedConfig); - bindProductionMarketingPages(resolvedConfig); - bindProductionNavigation(resolvedConfig); - bindProductionMedia(resolvedConfig); -} - -/** - * Dev-seed path: keep each feature's MockXRepository in place but populate it - * with realistic seed data so the running app shows non-empty UI without - * Payload booted. Mutually exclusive with `bindAllProduction()`. - */ -export async function bindAllDevSeed(): Promise { - if (bound) return; - bound = true; - resolveInstrumentation(); // Rule 0 - await bindDevSeedAuth(); - await bindDevSeedBlog(); - await bindDevSeedMarketingPages(); - await bindDevSeedNavigation(); - await bindDevSeedMedia(); -} - -/** - * Boot dispatcher: pick the binder based on the environment. - * - * Resolution order (first match wins): - * - * Rule 0 (always): instrumentation (Noop vs Sentry) from WEB_NEXT_SENTRY_DSN - * presence — runs inside both bindAllProduction and - * bindAllDevSeed via resolveInstrumentation(). - * Rule 1: USE_DEV_SEED === "true" → dev seed (explicit override) - * Rule 2: NODE_ENV === "production" → real Payload via bindAllProduction - * Rule 3: otherwise → dev seed (developer-friendly default) - */ -export async function bindAll(): Promise { - if (process.env.USE_DEV_SEED === "true") { - await bindAllDevSeed(); - return; - } - if (process.env.NODE_ENV === "production") { - await bindAllProduction(); - return; - } - await bindAllDevSeed(); -} - -/** Test-only resets — not exported via package. Used by bind-production.test.ts. */ -export function __resetBindStateForTests(): void { - bound = false; - resolvedTracer = null; - resolvedLogger = null; -} - -/** Test-only accessor for resolved instrumentation. */ -export function __getInstrumentationForTests(): { - tracer: ITracer | null; - logger: ILogger | null; -} { - return { tracer: resolvedTracer, logger: resolvedLogger }; -} -``` - -- [ ] **Step 2: Run existing bindAllProduction tests to ensure no regression** - -Run: `pnpm --filter web-next test bind-production` -Expected: PASS — existing tests still pass (Rule 0 is additive). - -- [ ] **Step 3: Commit** - -```bash -git add apps/web-next/src/server/bind-production.ts -git commit -m "feat(app): bindAll() Rule 0 — DSN-driven instrumentation (orthogonal to repo mode)" -``` - ---- - -### Task 14: Tests for `bindAll()` orthogonality (R47) - -**Files:** -- Modify: `apps/web-next/src/server/bind-production.test.ts` - -- [ ] **Step 1: Add new test block to existing file** - -Append the following describe block to `apps/web-next/src/server/bind-production.test.ts` (after the existing two describes): - -```ts -// At the top of the file, extend the existing vi.mock list: -vi.mock("@repo/core-shared/instrumentation", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - bindSentryInstrumentation: vi.fn(actual.bindSentryInstrumentation), - bindNoopInstrumentation: vi.fn(actual.bindNoopInstrumentation), - }; -}); - -// New describe block: -describe("bindAll instrumentation orthogonality (Rule 0, R47)", () => { - beforeEach(() => { - vi.resetModules(); - vi.clearAllMocks(); - vi.unstubAllEnvs(); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("DSN absent → bindNoopInstrumentation regardless of NODE_ENV (R48)", async () => { - vi.stubEnv("WEB_NEXT_SENTRY_DSN", ""); - vi.stubEnv("NODE_ENV", "production"); - const { bindAll } = await import("./bind-production"); - const { bindNoopInstrumentation, bindSentryInstrumentation } = await import( - "@repo/core-shared/instrumentation" - ); - - await bindAll(); - - expect(bindNoopInstrumentation).toHaveBeenCalledOnce(); - expect(bindSentryInstrumentation).not.toHaveBeenCalled(); - }); - - it("DSN set → bindSentryInstrumentation regardless of NODE_ENV", async () => { - vi.stubEnv("WEB_NEXT_SENTRY_DSN", "https://x@y/1"); - vi.stubEnv("NODE_ENV", "development"); - const { bindAll } = await import("./bind-production"); - const { bindNoopInstrumentation, bindSentryInstrumentation } = await import( - "@repo/core-shared/instrumentation" - ); - - await bindAll(); - - expect(bindSentryInstrumentation).toHaveBeenCalledOnce(); - expect(bindNoopInstrumentation).not.toHaveBeenCalled(); - }); - - it("Sentry instrumentation works alongside dev seed (USE_DEV_SEED=true)", async () => { - vi.stubEnv("USE_DEV_SEED", "true"); - vi.stubEnv("WEB_NEXT_SENTRY_DSN", "https://x@y/1"); - const { bindAll } = await import("./bind-production"); - const { bindSentryInstrumentation } = await import("@repo/core-shared/instrumentation"); - const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed"); - - await bindAll(); - - expect(bindSentryInstrumentation).toHaveBeenCalledOnce(); - expect(bindDevSeedBlog).toHaveBeenCalledOnce(); - }); - - it("Noop instrumentation works alongside production binding (DSN unset, NODE_ENV=production)", async () => { - vi.stubEnv("WEB_NEXT_SENTRY_DSN", ""); - vi.stubEnv("NODE_ENV", "production"); - const { bindAll } = await import("./bind-production"); - const { bindNoopInstrumentation } = await import("@repo/core-shared/instrumentation"); - const { bindProductionBlog } = await import("@repo/blog/di/bind-production"); - - await bindAll(); - - expect(bindNoopInstrumentation).toHaveBeenCalledOnce(); - expect(bindProductionBlog).toHaveBeenCalledOnce(); - }); -}); -``` - -- [ ] **Step 2: Run the new tests** - -Run: `pnpm --filter web-next test bind-production` -Expected: PASS — original tests + 4 new orthogonality tests. - -- [ ] **Step 3: Commit** - -```bash -git add apps/web-next/src/server/bind-production.test.ts -git commit -m "test(app): R47 — bindAll instrumentation orthogonality matrix" -``` - ---- - -## Phase D — Test infrastructure (core-testing) - -> **Implementation note about R49:** The spec's R49 says "vitest setup MUST bind Noop by default." In practice, the codebase has no shared test container — repositories construct themselves directly with `new MockXRepository()`. Therefore "default Noop" is enforced by *default constructor parameters* on every repo (Phase E task work). What `core-testing` provides is a `RecordingTracer` and `RecordingLogger` for tests that want to assert capture/span calls, plus a setup-side guard that fails if Sentry's real SDK accidentally initializes during a test process. - -### Task 15: RecordingTracer - -**Files:** -- Create: `packages/core-testing/src/instrumentation/recording-tracer.ts` -- Create: `packages/core-testing/src/instrumentation/recording-tracer.test.ts` - -- [ ] **Step 1: Write the failing test** - -```ts -// packages/core-testing/src/instrumentation/recording-tracer.test.ts -import { describe, it, expect } from "vitest"; -import { RecordingTracer } from "@/instrumentation/recording-tracer"; - -describe("RecordingTracer", () => { - it("records every startSpan call with name, op, attributes, status, durationMs", async () => { - const tracer = new RecordingTracer(); - await tracer.startSpan( - { name: "blog.getArticles", op: "use-case", attributes: { limit: 10 } }, - async (span) => { - span.setAttribute("count", 3); - span.setStatus("ok"); - return undefined; - }, - ); - expect(tracer.spans).toHaveLength(1); - const s = tracer.spans[0]!; - expect(s.name).toBe("blog.getArticles"); - expect(s.op).toBe("use-case"); - expect(s.attributes).toMatchObject({ limit: 10, count: 3 }); - expect(s.status).toBe("ok"); - expect(typeof s.durationMs).toBe("number"); - expect(s.durationMs).toBeGreaterThanOrEqual(0); - }); - - it("records error status when fn throws", async () => { - const tracer = new RecordingTracer(); - await expect( - tracer.startSpan({ name: "x" }, async () => { - throw new Error("boom"); - }), - ).rejects.toThrow("boom"); - expect(tracer.spans).toHaveLength(1); - expect(tracer.spans[0]!.status).toBe("error"); - expect(tracer.spans[0]!.statusMessage).toBe("boom"); - }); - - it("records error status when set explicitly via span.setStatus", async () => { - const tracer = new RecordingTracer(); - await tracer.startSpan({ name: "x" }, async (span) => { - span.setStatus("error", "validation failed"); - return undefined; - }); - expect(tracer.spans[0]!.status).toBe("error"); - expect(tracer.spans[0]!.statusMessage).toBe("validation failed"); - }); - - it("reset() clears recorded spans", async () => { - const tracer = new RecordingTracer(); - await tracer.startSpan({ name: "x" }, async () => undefined); - expect(tracer.spans).toHaveLength(1); - tracer.reset(); - expect(tracer.spans).toHaveLength(0); - }); - - it("findSpan returns first matching span by name", async () => { - const tracer = new RecordingTracer(); - await tracer.startSpan({ name: "a" }, async () => undefined); - await tracer.startSpan({ name: "b" }, async () => undefined); - expect(tracer.findSpan("b")).toBeDefined(); - expect(tracer.findSpan("missing")).toBeUndefined(); - }); - - it("nested spans are recorded in order (children appear after parent end)", async () => { - const tracer = new RecordingTracer(); - await tracer.startSpan({ name: "parent" }, async () => { - await tracer.startSpan({ name: "child" }, async () => undefined); - }); - expect(tracer.spans.map((s) => s.name)).toEqual(["child", "parent"]); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm --filter @repo/core-testing test recording-tracer` -Expected: FAIL — `RecordingTracer` not found. - -- [ ] **Step 3: Implement RecordingTracer** - -```ts -// packages/core-testing/src/instrumentation/recording-tracer.ts -import type { - ITracer, - ISpan, - SpanOpts, - AttributeValue, -} from "@repo/core-shared/instrumentation"; - -export type RecordedSpan = { - name: string; - op?: string; - attributes: Record; - status: "ok" | "error"; - statusMessage?: string; - durationMs: number; -}; - -export class RecordingTracer implements ITracer { - spans: RecordedSpan[] = []; - - async startSpan(opts: SpanOpts, fn: (span: ISpan) => Promise): Promise { - const start = performance.now(); - const recorded: RecordedSpan = { - name: opts.name, - op: opts.op, - attributes: { ...(opts.attributes ?? {}) }, - status: "ok", - durationMs: 0, - }; - const span: ISpan = { - setAttribute(key, value) { - recorded.attributes[key] = value; - }, - setStatus(status, message) { - recorded.status = status; - recorded.statusMessage = message; - }, - }; - try { - const result = await fn(span); - recorded.durationMs = performance.now() - start; - this.spans.push(recorded); - return result; - } catch (err) { - recorded.status = "error"; - recorded.statusMessage = err instanceof Error ? err.message : String(err); - recorded.durationMs = performance.now() - start; - this.spans.push(recorded); - throw err; - } - } - - reset(): void { - this.spans = []; - } - - findSpan(name: string): RecordedSpan | undefined { - return this.spans.find((s) => s.name === name); - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pnpm --filter @repo/core-testing test recording-tracer` -Expected: PASS — 6 tests. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-testing/src/instrumentation/recording-tracer.ts \ - packages/core-testing/src/instrumentation/recording-tracer.test.ts -git commit -m "feat(core-testing): RecordingTracer for span assertions" -``` - ---- - -### Task 16: RecordingLogger + barrel - -**Files:** -- Create: `packages/core-testing/src/instrumentation/recording-logger.ts` -- Create: `packages/core-testing/src/instrumentation/recording-logger.test.ts` -- Create: `packages/core-testing/src/instrumentation/index.ts` -- Modify: `packages/core-testing/src/index.ts` (re-export) -- Modify: `packages/core-testing/package.json` (add `./instrumentation` subpath) - -- [ ] **Step 1: Write the failing test** - -```ts -// packages/core-testing/src/instrumentation/recording-logger.test.ts -import { describe, it, expect } from "vitest"; -import { RecordingLogger } from "@/instrumentation/recording-logger"; - -describe("RecordingLogger", () => { - it("records captureException calls (err + ctx)", () => { - const logger = new RecordingLogger(); - const err = new Error("x"); - logger.captureException(err, { tags: { feature: "blog" } }); - expect(logger.captures).toHaveLength(1); - expect(logger.captures[0]).toMatchObject({ - kind: "exception", - err, - ctx: { tags: { feature: "blog" } }, - }); - }); - - it("records captureMessage calls", () => { - const logger = new RecordingLogger(); - logger.captureMessage("hello", "warning", { extras: { foo: 1 } }); - expect(logger.captures[0]).toMatchObject({ - kind: "message", - message: "hello", - level: "warning", - }); - }); - - it("records breadcrumbs", () => { - const logger = new RecordingLogger(); - logger.addBreadcrumb({ category: "test", message: "x", level: "info" }); - expect(logger.breadcrumbs).toHaveLength(1); - expect(logger.breadcrumbs[0]!.category).toBe("test"); - }); - - it("records setUser calls", () => { - const logger = new RecordingLogger(); - logger.setUser({ id: "u1" }); - logger.setUser(null); - expect(logger.users).toEqual([{ id: "u1" }, null]); - }); - - it("reset() clears all recordings", () => { - const logger = new RecordingLogger(); - logger.captureException(new Error("x")); - logger.addBreadcrumb({ category: "c", message: "m" }); - logger.setUser({ id: "u" }); - logger.reset(); - expect(logger.captures).toHaveLength(0); - expect(logger.breadcrumbs).toHaveLength(0); - expect(logger.users).toHaveLength(0); - }); - - it("findCapture returns first capture matching predicate", () => { - const logger = new RecordingLogger(); - logger.captureException(new Error("first")); - logger.captureException(new Error("second")); - const found = logger.findCapture( - (c) => c.kind === "exception" && (c.err as Error).message === "second", - ); - expect(found).toBeDefined(); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm --filter @repo/core-testing test recording-logger` -Expected: FAIL — `RecordingLogger` not found. - -- [ ] **Step 3: Implement RecordingLogger** - -```ts -// packages/core-testing/src/instrumentation/recording-logger.ts -import type { - ILogger, - Breadcrumb, - CaptureContext, -} from "@repo/core-shared/instrumentation"; - -export type RecordedCapture = - | { kind: "exception"; err: unknown; ctx?: CaptureContext } - | { kind: "message"; message: string; level?: "info" | "warning" | "error"; ctx?: CaptureContext }; - -export class RecordingLogger implements ILogger { - captures: RecordedCapture[] = []; - breadcrumbs: Breadcrumb[] = []; - users: Array<{ id: string } | null> = []; - - captureException(err: unknown, ctx?: CaptureContext): void { - this.captures.push({ kind: "exception", err, ctx }); - } - - captureMessage( - message: string, - level?: "info" | "warning" | "error", - ctx?: CaptureContext, - ): void { - this.captures.push({ kind: "message", message, level, ctx }); - } - - addBreadcrumb(b: Breadcrumb): void { - this.breadcrumbs.push(b); - } - - setUser(user: { id: string } | null): void { - this.users.push(user); - } - - reset(): void { - this.captures = []; - this.breadcrumbs = []; - this.users = []; - } - - findCapture( - predicate: (c: RecordedCapture) => boolean, - ): RecordedCapture | undefined { - return this.captures.find(predicate); - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pnpm --filter @repo/core-testing test recording-logger` -Expected: PASS — 6 tests. - -- [ ] **Step 5: Write the barrel** - -```ts -// packages/core-testing/src/instrumentation/index.ts -export { RecordingTracer, type RecordedSpan } from "./recording-tracer"; -export { RecordingLogger, type RecordedCapture } from "./recording-logger"; -``` - -- [ ] **Step 6: Re-export from package root** - -Append to `packages/core-testing/src/index.ts`: - -```ts -export * from "./instrumentation/index.js"; -``` - -- [ ] **Step 7: Add subpath export in package.json** - -Update `packages/core-testing/package.json` `exports` field — add the entry while preserving existing entries: - -```json -{ - "exports": { - ".": "./src/index.ts", - "./factory": "./src/factory/index.ts", - "./contract": "./src/contract/index.ts", - "./instrumentation": "./src/instrumentation/index.ts", - "./react": "./src/react/index.ts", - "./payload": "./src/payload/index.ts", - "./payload/stub-config": "./src/payload/stub-config.ts", - "./setup/jsdom": "./src/setup/jsdom.ts", - "./setup/node": "./src/setup/node.ts" - } -} -``` - -- [ ] **Step 8: Verify build + import** - -Run: `pnpm --filter @repo/core-testing typecheck` -Expected: passes. - -- [ ] **Step 9: Commit** - -```bash -git add packages/core-testing/src/instrumentation/ \ - packages/core-testing/src/index.ts \ - packages/core-testing/package.json -git commit -m "feat(core-testing): RecordingLogger + ./instrumentation subpath" -``` - ---- - -### Task 17: vitest setup guard — fail if real Sentry initializes - -**Files:** -- Create: `packages/core-testing/src/setup/no-sentry.ts` -- Modify: `packages/core-testing/src/setup/jsdom.ts` (import the guard) -- Modify: `packages/core-testing/src/setup/node.ts` (import the guard) -- Modify: `packages/core-testing/package.json` (export the new file if needed) - -> **Why:** R49 forbids real Sentry SDK initialization in test processes. This guard mocks `@sentry/nextjs` globally so any code that imports it gets a no-op surface — including code that wasn't written with testing in mind. Tests that use SentryTracer/SentryLogger directly already mock the module per-file (Tasks 7, 8); this guard is a safety net for indirect imports. - -- [ ] **Step 1: Write the guard** - -```ts -// packages/core-testing/src/setup/no-sentry.ts -import { vi } from "vitest"; - -/** - * R49 — guard against real Sentry SDK initialization in test processes. - * - * Mocks @sentry/nextjs at the module level so any code that imports it - * receives a no-op surface. Tests that need to assert Sentry behavior - * still use vi.mock locally with their own implementation; this guard - * just ensures *unintentional* imports don't cause real network/init. - */ -vi.mock("@sentry/nextjs", () => ({ - init: vi.fn(), - startSpan: vi.fn((_opts: unknown, fn: any) => - fn({ setAttribute: vi.fn(), setStatus: vi.fn() }), - ), - captureException: vi.fn(), - captureMessage: vi.fn(), - addBreadcrumb: vi.fn(), - setUser: vi.fn(), - setContext: vi.fn(), - setTag: vi.fn(), - setExtra: vi.fn(), - withScope: vi.fn((fn: any) => fn({ setTag: vi.fn(), setExtra: vi.fn() })), - replayIntegration: vi.fn(() => ({ name: "Replay" })), - getActiveSpan: vi.fn(() => undefined), - getCurrentHub: vi.fn(() => ({ getClient: () => undefined })), -})); -``` - -- [ ] **Step 2: Import from existing setup files** - -Edit `packages/core-testing/src/setup/jsdom.ts` — add at the top: - -```ts -import "./no-sentry"; -``` - -Edit `packages/core-testing/src/setup/node.ts` — add at the top: - -```ts -import "./no-sentry"; -``` - -- [ ] **Step 3: Verify no test regressions** - -Run: `pnpm test` (full monorepo) -Expected: no new failures. Existing tests pass. - -- [ ] **Step 4: Add a positive test for the guard** - -Create `packages/core-testing/src/setup/no-sentry.test.ts`: - -```ts -import { describe, it, expect } from "vitest"; -import * as Sentry from "@sentry/nextjs"; - -describe("setup/no-sentry guard (R49)", () => { - it("Sentry.init is a vi.fn (mocked, not real)", () => { - expect(vi.isMockFunction(Sentry.init)).toBe(true); - }); - - it("Sentry.captureException is a vi.fn", () => { - expect(vi.isMockFunction(Sentry.captureException)).toBe(true); - }); - - it("calling Sentry.init does not throw or initialize", () => { - expect(() => Sentry.init({ dsn: "https://x@y/1" } as any)).not.toThrow(); - }); -}); -``` - -Run: `pnpm --filter @repo/core-testing test no-sentry` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-testing/src/setup/no-sentry.ts \ - packages/core-testing/src/setup/no-sentry.test.ts \ - packages/core-testing/src/setup/jsdom.ts \ - packages/core-testing/src/setup/node.ts -git commit -m "feat(core-testing): R49 guard — block real Sentry SDK init in test processes" -``` - ---- - -## Phase E — Per-feature wiring - -> **Pattern reference (applies to every feature task in this phase):** -> -> 1. **Real repository class** — constructor signature changes from `(config)` to `(config, tracer = new NoopTracer(), logger = new NoopLogger())`. Each public async method's body is wrapped: -> -> ```ts -> async findX(args): Promise { -> return this.tracer.startSpan( -> { name: ".", op: "repository", attributes: { ...documentedAttrs } }, -> async (span) => { -> try { -> const result = await /* existing payload op */; -> span.setAttribute("count", /* if applicable */); -> return /* mapped result */; -> } catch (err) { -> this.logger.captureException(err, { -> tags: { feature: "", repo: "", method: "" }, -> }); -> span.setStatus("error", err instanceof Error ? err.message : String(err)); -> throw err; -> } -> }, -> ); -> } -> ``` -> -> 2. **Mock repository class** — constructor signature change identical to real (with NoopTracer/NoopLogger defaults). Body wrapping identical structure (calls `tracer.startSpan` with the same name/op/attrs), but the body just runs the mock's existing logic — no `try/catch logger.captureException` because mocks don't originate infra errors. Pattern: -> -> ```ts -> async findX(args): Promise { -> return this.tracer.startSpan( -> { name: ".", op: "repository", attributes: { ... } }, -> async () => /* existing mock logic */, -> ); -> } -> ``` -> -> 3. **`bind-production.ts`** — signature: `bindProductionX(config, tracer, logger)`. Wrap factory results with `withSpan` at bind time. Bind TRACER/LOGGER to feature container as `.toConstantValue(...)`. For each use case: construct real factory, wrap with `withSpan(tracer, { name: ".", op: "use-case" }, factory(...deps))`, bind to symbol. For each controller: same pattern with `op: "controller"`. -> -> 4. **`bind-dev-seed.ts`** — signature: `bindDevSeedX(tracer, logger)`. Same wrapping but mock repo gets the same tracer/logger (mocks pass `tracer.startSpan` through but emit recorded spans for tests/dev breakdowns). -> -> 5. **Tests** — direct injection of `RecordingTracer` / `RecordingLogger` into the factory. No DI container manipulation. Assert span shape and capture calls. -> -> 6. **`apps/web-next/src/server/bind-production.ts`** — once the feature's binder signature changes, update its caller in this dispatcher to pass `tracer` + `logger`. Keep `bindAllProduction` and `bindAllDevSeed` consistent. - -### Task 18: Blog feature wiring (pilot) - -**Files:** -- Modify: `packages/blog/src/infrastructure/repositories/articles.repository.ts` -- Modify: `packages/blog/src/infrastructure/repositories/articles.repository.mock.ts` -- Modify: `packages/blog/src/di/bind-production.ts` -- Modify: `packages/blog/src/di/bind-dev-seed.ts` -- Modify: `apps/web-next/src/server/bind-production.ts` (update calls to bindProductionBlog / bindDevSeedBlog) -- Modify (existing): blog repo + use-case + controller test files (direct-injection pattern updated for tracer/logger) - -**Repository methods to wrap (5 each):** - -| Method | name | attributes | -|---|---|---| -| `getArticle` | `articles.getArticle` | `{ id }` | -| `getArticleBySlug` | `articles.getArticleBySlug` | `{ slug }` | -| `getArticles` | `articles.getArticles` | `{ status, authorId, limit, offset }` (only present keys) | -| `createArticle` | `articles.createArticle` | `{ slug }` | -| `updateArticle` | `articles.updateArticle` | `{ id }` | - -**Use cases to wrap (3 — name, op="use-case"):** -- `blog.getArticles` -- `blog.getArticleBySlug` -- `blog.createArticle` - -**Controllers to wrap (3 — name, op="controller"):** same names. - -- [ ] **Step 1: Update articles.repository.ts (real)** - -```ts -// packages/blog/src/infrastructure/repositories/articles.repository.ts -import "reflect-metadata"; -import { injectable } from "inversify"; -import { getPayload } from "payload"; -import type { SanitizedConfig } from "payload"; -import { - NoopTracer, - NoopLogger, - type ITracer, - type ILogger, -} from "@repo/core-shared/instrumentation"; - -import type { IArticlesRepository } from "../../application/repositories/articles.repository.interface"; -import type { Article } from "../../entities/models/article"; - -type PayloadArticleDoc = { - id: string | number; - title?: string | null; - slug?: string | null; - content?: unknown; - status?: string | null; - author?: string | number | { id: string | number } | null; - createdAt?: string | null; - updatedAt?: string | null; -}; - -function mapDoc(doc: PayloadArticleDoc): Article { - const authorId = - typeof doc.author === "object" && doc.author !== null - ? String(doc.author.id) - : doc.author != null - ? String(doc.author) - : ""; - return { - id: String(doc.id), - title: doc.title ?? "", - slug: doc.slug ?? "", - content: doc.content ?? null, - status: doc.status === "published" ? "published" : "draft", - authorId, - createdAt: doc.createdAt ? new Date(doc.createdAt) : new Date(0), - updatedAt: doc.updatedAt ? new Date(doc.updatedAt) : new Date(0), - }; -} - -const FEATURE = "blog" as const; -const REPO = "articles" as const; - -@injectable() -export class ArticlesRepository implements IArticlesRepository { - private config: SanitizedConfig; - private tracer: ITracer; - private logger: ILogger; - - constructor( - config: SanitizedConfig, - tracer: ITracer = new NoopTracer(), - logger: ILogger = new NoopLogger(), - ) { - this.config = config; - this.tracer = tracer; - this.logger = logger; - } - - async getArticle(id: string): Promise
{ - return this.tracer.startSpan( - { name: "articles.getArticle", op: "repository", attributes: { id } }, - async (span) => { - try { - const payload = await getPayload({ config: this.config }); - const doc = await payload.findByID({ - collection: "articles", - id, - overrideAccess: true, - }); - span.setAttribute("found", true); - return mapDoc(doc as PayloadArticleDoc); - } catch (err) { - // Payload throws on not-found; treat as undefined per existing semantics - if (err && typeof err === "object" && "status" in err && (err as any).status === 404) { - span.setAttribute("found", false); - return undefined; - } - this.logger.captureException(err, { - tags: { feature: FEATURE, repo: REPO, method: "getArticle" }, - }); - span.setStatus("error", err instanceof Error ? err.message : String(err)); - throw err; - } - }, - ); - } - - async getArticleBySlug(slug: string): Promise
{ - return this.tracer.startSpan( - { name: "articles.getArticleBySlug", op: "repository", attributes: { slug } }, - async (span) => { - try { - const payload = await getPayload({ config: this.config }); - const result = await payload.find({ - collection: "articles", - where: { slug: { equals: slug } }, - limit: 1, - overrideAccess: true, - }); - const doc = result.docs[0] as PayloadArticleDoc | undefined; - span.setAttribute("found", Boolean(doc)); - return doc ? mapDoc(doc) : undefined; - } catch (err) { - this.logger.captureException(err, { - tags: { feature: FEATURE, repo: REPO, method: "getArticleBySlug" }, - }); - span.setStatus("error", err instanceof Error ? err.message : String(err)); - throw err; - } - }, - ); - } - - async getArticles(options?: { - status?: string; - authorId?: string; - limit?: number; - offset?: number; - }): Promise { - return this.tracer.startSpan( - { - name: "articles.getArticles", - op: "repository", - attributes: { - status: options?.status ?? null, - authorId: options?.authorId ?? null, - limit: options?.limit ?? null, - offset: options?.offset ?? null, - }, - }, - async (span) => { - try { - const payload = await getPayload({ config: this.config }); - const where: Record = {}; - if (options?.status) where.status = { equals: options.status }; - if (options?.authorId) where.author = { equals: options.authorId }; - const result = await payload.find({ - collection: "articles", - where: where as never, - limit: options?.limit ?? 50, - page: options?.offset - ? Math.floor(options.offset / (options.limit ?? 50)) + 1 - : 1, - overrideAccess: true, - }); - span.setAttribute("count", result.docs.length); - return result.docs.map((d) => mapDoc(d as PayloadArticleDoc)); - } catch (err) { - this.logger.captureException(err, { - tags: { feature: FEATURE, repo: REPO, method: "getArticles" }, - }); - span.setStatus("error", err instanceof Error ? err.message : String(err)); - throw err; - } - }, - ); - } - - async createArticle(input: Article): Promise
{ - return this.tracer.startSpan( - { name: "articles.createArticle", op: "repository", attributes: { slug: input.slug } }, - async (span) => { - try { - const payload = await getPayload({ config: this.config }); - const created = await payload.create({ - collection: "articles", - data: { - title: input.title, - slug: input.slug, - content: input.content, - status: input.status, - author: input.authorId, - } as never, - overrideAccess: true, - }); - span.setAttribute("id", String((created as PayloadArticleDoc).id)); - return mapDoc(created as PayloadArticleDoc); - } catch (err) { - this.logger.captureException(err, { - tags: { feature: FEATURE, repo: REPO, method: "createArticle" }, - }); - span.setStatus("error", err instanceof Error ? err.message : String(err)); - throw err; - } - }, - ); - } - - async updateArticle( - id: string, - input: Partial
, - ): Promise
{ - return this.tracer.startSpan( - { name: "articles.updateArticle", op: "repository", attributes: { id } }, - async (span) => { - try { - const payload = await getPayload({ config: this.config }); - const updated = await payload.update({ - collection: "articles", - id, - data: { - ...(input.title !== undefined && { title: input.title }), - ...(input.slug !== undefined && { slug: input.slug }), - ...(input.content !== undefined && { content: input.content }), - ...(input.status !== undefined && { status: input.status }), - ...(input.authorId !== undefined && { author: input.authorId }), - } as never, - overrideAccess: true, - }); - span.setAttribute("found", true); - return mapDoc(updated as PayloadArticleDoc); - } catch (err) { - if (err && typeof err === "object" && "status" in err && (err as any).status === 404) { - span.setAttribute("found", false); - return undefined; - } - this.logger.captureException(err, { - tags: { feature: FEATURE, repo: REPO, method: "updateArticle" }, - }); - span.setStatus("error", err instanceof Error ? err.message : String(err)); - throw err; - } - }, - ); - } -} -``` - -- [ ] **Step 2: Update articles.repository.mock.ts** - -```ts -// packages/blog/src/infrastructure/repositories/articles.repository.mock.ts -import "reflect-metadata"; -import { injectable } from "inversify"; -import { - NoopTracer, - NoopLogger, - type ITracer, - type ILogger, -} from "@repo/core-shared/instrumentation"; - -import type { IArticlesRepository } from "../../application/repositories/articles.repository.interface"; -import type { Article } from "../../entities/models/article"; - -@injectable() -export class MockArticlesRepository implements IArticlesRepository { - private _articles: Article[] = []; - private tracer: ITracer; - private logger: ILogger; - - constructor( - tracer: ITracer = new NoopTracer(), - logger: ILogger = new NoopLogger(), - ) { - this.tracer = tracer; - this.logger = logger; - void this.logger; // currently unused; reserved for future mock-thrown captures - } - - async getArticle(id: string): Promise
{ - return this.tracer.startSpan( - { name: "articles.getArticle", op: "repository", attributes: { id } }, - async (span) => { - const found = this._articles.find((a) => a.id === id); - span.setAttribute("found", Boolean(found)); - return found; - }, - ); - } - - async getArticleBySlug(slug: string): Promise
{ - return this.tracer.startSpan( - { name: "articles.getArticleBySlug", op: "repository", attributes: { slug } }, - async (span) => { - const found = this._articles.find((a) => a.slug === slug); - span.setAttribute("found", Boolean(found)); - return found; - }, - ); - } - - async getArticles(options?: { - status?: string; - authorId?: string; - limit?: number; - offset?: number; - }): Promise { - return this.tracer.startSpan( - { - name: "articles.getArticles", - op: "repository", - attributes: { - status: options?.status ?? null, - authorId: options?.authorId ?? null, - limit: options?.limit ?? null, - offset: options?.offset ?? null, - }, - }, - async (span) => { - let result = [...this._articles]; - if (options?.status) { - result = result.filter((a) => a.status === options.status); - } - if (options?.authorId) { - result = result.filter((a) => a.authorId === options.authorId); - } - const offset = options?.offset ?? 0; - const limit = options?.limit ?? 50; - const sliced = result.slice(offset, offset + limit); - span.setAttribute("count", sliced.length); - return sliced; - }, - ); - } - - async createArticle(input: Article): Promise
{ - return this.tracer.startSpan( - { name: "articles.createArticle", op: "repository", attributes: { slug: input.slug } }, - async (span) => { - this._articles.push(input); - span.setAttribute("id", input.id); - return input; - }, - ); - } - - async updateArticle( - id: string, - input: Partial
, - ): Promise
{ - return this.tracer.startSpan( - { name: "articles.updateArticle", op: "repository", attributes: { id } }, - async (span) => { - const idx = this._articles.findIndex((a) => a.id === id); - if (idx === -1) { - span.setAttribute("found", false); - return undefined; - } - const merged = { ...this._articles[idx]!, ...input, id } as Article; - this._articles[idx] = merged; - span.setAttribute("found", true); - return merged; - }, - ); - } -} -``` - -- [ ] **Step 3: Update bind-production.ts** - -```ts -// packages/blog/src/di/bind-production.ts -import type { SanitizedConfig } from "payload"; -import { - withSpan, - INSTRUMENTATION_SYMBOLS, - type ITracer, - type ILogger, -} from "@repo/core-shared/instrumentation"; -import { blogContainer } from "./container"; -import { BLOG_SYMBOLS } from "./symbols"; -import { ArticlesRepository } from "../infrastructure/repositories/articles.repository"; -// Import every use-case + controller factory: -import { getArticlesUseCase } from "../application/use-cases/get-articles.use-case"; -import { getArticleBySlugUseCase } from "../application/use-cases/get-article-by-slug.use-case"; -import { createArticleUseCase } from "../application/use-cases/create-article.use-case"; -import { getArticlesController } from "../interface-adapters/controllers/get-articles.controller"; -import { getArticleBySlugController } from "../interface-adapters/controllers/get-article-by-slug.controller"; -import { createArticleController } from "../interface-adapters/controllers/create-article.controller"; - -export function bindProductionBlog( - config: SanitizedConfig, - tracer: ITracer, - logger: ILogger, -): void { - // Bind shared instrumentation into feature container (for any internal resolvers) - if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { - blogContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER); - } - if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) { - blogContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER); - } - blogContainer.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); - blogContainer.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); - - // Real repository - if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { - blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); - } - const repo = new ArticlesRepository(config, tracer, logger); - blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository).toConstantValue(repo); - - // Use cases — wrapped with span at bind time (R41) - const wrappedGetArticles = withSpan( - tracer, - { name: "blog.getArticles", op: "use-case" }, - getArticlesUseCase(repo), - ); - const wrappedGetArticleBySlug = withSpan( - tracer, - { name: "blog.getArticleBySlug", op: "use-case" }, - getArticleBySlugUseCase(repo), - ); - const wrappedCreateArticle = withSpan( - tracer, - { name: "blog.createArticle", op: "use-case" }, - createArticleUseCase(repo), - ); - - if (blogContainer.isBound(BLOG_SYMBOLS.GetArticlesUseCase)) { - blogContainer.unbind(BLOG_SYMBOLS.GetArticlesUseCase); - } - if (blogContainer.isBound(BLOG_SYMBOLS.GetArticleBySlugUseCase)) { - blogContainer.unbind(BLOG_SYMBOLS.GetArticleBySlugUseCase); - } - if (blogContainer.isBound(BLOG_SYMBOLS.CreateArticleUseCase)) { - blogContainer.unbind(BLOG_SYMBOLS.CreateArticleUseCase); - } - blogContainer.bind(BLOG_SYMBOLS.GetArticlesUseCase).toConstantValue(wrappedGetArticles); - blogContainer - .bind(BLOG_SYMBOLS.GetArticleBySlugUseCase) - .toConstantValue(wrappedGetArticleBySlug); - blogContainer.bind(BLOG_SYMBOLS.CreateArticleUseCase).toConstantValue(wrappedCreateArticle); - - // Controllers — wrapped with span at bind time - const wrappedGetArticlesCtrl = withSpan( - tracer, - { name: "blog.getArticles", op: "controller" }, - getArticlesController(wrappedGetArticles), - ); - const wrappedGetArticleBySlugCtrl = withSpan( - tracer, - { name: "blog.getArticleBySlug", op: "controller" }, - getArticleBySlugController(wrappedGetArticleBySlug), - ); - const wrappedCreateArticleCtrl = withSpan( - tracer, - { name: "blog.createArticle", op: "controller" }, - createArticleController(wrappedCreateArticle), - ); - - if (blogContainer.isBound(BLOG_SYMBOLS.GetArticlesController)) { - blogContainer.unbind(BLOG_SYMBOLS.GetArticlesController); - } - if (blogContainer.isBound(BLOG_SYMBOLS.GetArticleBySlugController)) { - blogContainer.unbind(BLOG_SYMBOLS.GetArticleBySlugController); - } - if (blogContainer.isBound(BLOG_SYMBOLS.CreateArticleController)) { - blogContainer.unbind(BLOG_SYMBOLS.CreateArticleController); - } - blogContainer - .bind(BLOG_SYMBOLS.GetArticlesController) - .toConstantValue(wrappedGetArticlesCtrl); - blogContainer - .bind(BLOG_SYMBOLS.GetArticleBySlugController) - .toConstantValue(wrappedGetArticleBySlugCtrl); - blogContainer - .bind(BLOG_SYMBOLS.CreateArticleController) - .toConstantValue(wrappedCreateArticleCtrl); -} -``` - -> **Note:** Replace exact use-case / controller symbol names if `BLOG_SYMBOLS` differs (read `packages/blog/src/di/symbols.ts` first). The pattern is identical regardless of names. - -- [ ] **Step 4: Update bind-dev-seed.ts** - -```ts -// packages/blog/src/di/bind-dev-seed.ts -import { - withSpan, - INSTRUMENTATION_SYMBOLS, - type ITracer, - type ILogger, -} from "@repo/core-shared/instrumentation"; -import { blogContainer } from "./container.js"; -import { BLOG_SYMBOLS } from "./symbols.js"; -import { MockArticlesRepository } from "../infrastructure/repositories/articles.repository.mock.js"; -import { buildDevArticles } from "../__seeds__/dev.js"; -import { getArticlesUseCase } from "../application/use-cases/get-articles.use-case.js"; -import { getArticleBySlugUseCase } from "../application/use-cases/get-article-by-slug.use-case.js"; -import { createArticleUseCase } from "../application/use-cases/create-article.use-case.js"; -import { getArticlesController } from "../interface-adapters/controllers/get-articles.controller.js"; -import { getArticleBySlugController } from "../interface-adapters/controllers/get-article-by-slug.controller.js"; -import { createArticleController } from "../interface-adapters/controllers/create-article.controller.js"; -import type { IArticlesRepository } from "../application/repositories/articles.repository.interface.js"; - -export async function bindDevSeedBlog(tracer: ITracer, logger: ILogger): Promise { - if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { - blogContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER); - } - if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) { - blogContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER); - } - blogContainer.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); - blogContainer.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); - - if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { - blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); - } - const repo = new MockArticlesRepository(tracer, logger); - for (const article of buildDevArticles()) { - await repo.createArticle(article); - } - blogContainer - .bind(BLOG_SYMBOLS.IArticlesRepository) - .toConstantValue(repo); - - // Wrap use cases + controllers identically to bind-production - const wrappedGetArticles = withSpan( - tracer, - { name: "blog.getArticles", op: "use-case" }, - getArticlesUseCase(repo), - ); - const wrappedGetArticleBySlug = withSpan( - tracer, - { name: "blog.getArticleBySlug", op: "use-case" }, - getArticleBySlugUseCase(repo), - ); - const wrappedCreateArticle = withSpan( - tracer, - { name: "blog.createArticle", op: "use-case" }, - createArticleUseCase(repo), - ); - - for (const sym of [ - BLOG_SYMBOLS.GetArticlesUseCase, - BLOG_SYMBOLS.GetArticleBySlugUseCase, - BLOG_SYMBOLS.CreateArticleUseCase, - BLOG_SYMBOLS.GetArticlesController, - BLOG_SYMBOLS.GetArticleBySlugController, - BLOG_SYMBOLS.CreateArticleController, - ]) { - if (blogContainer.isBound(sym)) blogContainer.unbind(sym); - } - blogContainer.bind(BLOG_SYMBOLS.GetArticlesUseCase).toConstantValue(wrappedGetArticles); - blogContainer - .bind(BLOG_SYMBOLS.GetArticleBySlugUseCase) - .toConstantValue(wrappedGetArticleBySlug); - blogContainer.bind(BLOG_SYMBOLS.CreateArticleUseCase).toConstantValue(wrappedCreateArticle); - - blogContainer - .bind(BLOG_SYMBOLS.GetArticlesController) - .toConstantValue( - withSpan( - tracer, - { name: "blog.getArticles", op: "controller" }, - getArticlesController(wrappedGetArticles), - ), - ); - blogContainer - .bind(BLOG_SYMBOLS.GetArticleBySlugController) - .toConstantValue( - withSpan( - tracer, - { name: "blog.getArticleBySlug", op: "controller" }, - getArticleBySlugController(wrappedGetArticleBySlug), - ), - ); - blogContainer - .bind(BLOG_SYMBOLS.CreateArticleController) - .toConstantValue( - withSpan( - tracer, - { name: "blog.createArticle", op: "controller" }, - createArticleController(wrappedCreateArticle), - ), - ); -} -``` - -- [ ] **Step 5: Update apps/web-next/src/server/bind-production.ts to thread tracer + logger to blog** - -In `bindAllProduction()` and `bindAllDevSeed()`, change the calls to blog binders: - -```ts -// apps/web-next/src/server/bind-production.ts (excerpt) -export async function bindAllProduction(): Promise { - if (bound) return; - bound = true; - const { tracer, logger } = resolveInstrumentation(); // Rule 0 - const resolvedConfig = await config; - bindProductionAuth(resolvedConfig); // Phase E task 19 will update - bindProductionBlog(resolvedConfig, tracer, logger); // ← updated this task - bindProductionMarketingPages(resolvedConfig); // Phase E task 20 will update - bindProductionNavigation(resolvedConfig); // Phase E task 21 will update - bindProductionMedia(resolvedConfig); // Phase E task 22 will update -} - -export async function bindAllDevSeed(): Promise { - if (bound) return; - bound = true; - const { tracer, logger } = resolveInstrumentation(); // Rule 0 - await bindDevSeedAuth(); // task 19 - await bindDevSeedBlog(tracer, logger); // ← updated this task - await bindDevSeedMarketingPages(); // task 20 - await bindDevSeedNavigation(); // task 21 - await bindDevSeedMedia(); // task 22 -} -``` - -- [ ] **Step 6: Update existing tests to pass tracer/logger or use defaults** - -Fix any blog test that constructs `new MockArticlesRepository()` — it still works (Noop defaults), no edit needed unless the test asserts on span shape (those land in Task 24's contract suite update). - -Run existing test suite: - -Run: `pnpm --filter @repo/blog test` -Expected: PASS — existing tests still pass with Noop defaults. - -Run: `pnpm --filter web-next test` -Expected: PASS. - -- [ ] **Step 7: Add a span-shape test for the real repo (sanity)** - -Create `packages/blog/src/infrastructure/repositories/articles.repository.span.test.ts`: - -```ts -import { describe, it, expect } from "vitest"; -import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation"; -import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock"; - -// Mock repo also wraps in spans (R42); easier to assert without booting Payload. -describe("MockArticlesRepository emits spans (R42)", () => { - it("getArticles emits one span with op='repository'", async () => { - const tracer = new RecordingTracer(); - const logger = new RecordingLogger(); - const repo = new MockArticlesRepository(tracer, logger); - await repo.getArticles({ limit: 10 }); - expect(tracer.spans).toHaveLength(1); - expect(tracer.spans[0]).toMatchObject({ - name: "articles.getArticles", - op: "repository", - }); - expect(tracer.spans[0]!.attributes).toMatchObject({ limit: 10 }); - }); - - it("createArticle emits a span with slug attribute", async () => { - const tracer = new RecordingTracer(); - const repo = new MockArticlesRepository(tracer); - await repo.createArticle({ - id: "a1", - title: "T", - slug: "t", - content: null, - status: "draft", - authorId: "u1", - createdAt: new Date(), - updatedAt: new Date(), - }); - expect(tracer.findSpan("articles.createArticle")).toBeDefined(); - expect(tracer.findSpan("articles.createArticle")!.attributes.slug).toBe("t"); - }); - - it("getArticle records found=false for missing id", async () => { - const tracer = new RecordingTracer(); - const repo = new MockArticlesRepository(tracer); - await repo.getArticle("missing"); - expect(tracer.spans[0]!.attributes.found).toBe(false); - }); -}); -``` - -Run: `pnpm --filter @repo/blog test articles.repository.span` -Expected: PASS — 3 tests. - -- [ ] **Step 8: Commit** - -```bash -git add packages/blog/src/infrastructure/repositories/articles.repository.ts \ - packages/blog/src/infrastructure/repositories/articles.repository.mock.ts \ - packages/blog/src/infrastructure/repositories/articles.repository.span.test.ts \ - packages/blog/src/di/bind-production.ts \ - packages/blog/src/di/bind-dev-seed.ts \ - apps/web-next/src/server/bind-production.ts -git commit -m "feat(blog): wire instrumentation — repo spans + use-case/controller withSpan + logger capture" -``` - ---- - -### Task 19: Auth feature wiring - -**Files:** -- Modify: `packages/auth/src/infrastructure/repositories/users.repository.ts` -- Modify: `packages/auth/src/infrastructure/repositories/users.repository.mock.ts` -- Modify: `packages/auth/src/di/bind-production.ts` -- Modify: `packages/auth/src/di/bind-dev-seed.ts` -- Modify: `apps/web-next/src/server/bind-production.ts` (thread tracer/logger to auth binders) - -**Use cases:** `auth.signIn`, `auth.signUp`, `auth.signOut` (all 3, `op: "use-case"` and `op: "controller"`). - -**Repository methods:** read the actual file first (`packages/auth/src/infrastructure/repositories/users.repository.ts`) and apply the wrapping pattern from Phase E preamble to every public async method. Span name format: `users.`. Standard attributes: `id`, `email` (note — `email` here is *only* used as a span attribute for lookup; it is NOT sent to Sentry as PII because span attributes are scrubbed by the same R32 scrubber). To be safe, hash or truncate emails in attributes: - -```ts -attributes: { emailDomain: email.split("@")[1] ?? "(invalid)" } -``` - -This avoids putting raw email in trace data even though scrubbers would catch it downstream. - -- [ ] **Step 1: Read existing files** - -Open each file and note the method signatures + bodies: -- `packages/auth/src/infrastructure/repositories/users.repository.ts` -- `packages/auth/src/infrastructure/repositories/users.repository.mock.ts` -- `packages/auth/src/di/bind-production.ts` -- `packages/auth/src/di/bind-dev-seed.ts` - -- [ ] **Step 2: Apply Phase E preamble pattern to real users.repository.ts** - -For every public async method, wrap the body in `tracer.startSpan({ name: "users.", op: "repository", attributes: {...} }, ...)`. Add catch block calling `this.logger.captureException(err, { tags: { feature: "auth", repo: "users", method: "" } })`. Constructor signature change: add `tracer: ITracer = new NoopTracer()`, `logger: ILogger = new NoopLogger()` parameters. Apply identical mock wrapping (omitting catch block). - -(Use the blog real-repo + mock-repo code from Task 18 as the structural template; only the actual Payload calls and the method names change.) - -- [ ] **Step 3: Update bind-production.ts** - -```ts -// packages/auth/src/di/bind-production.ts -import type { SanitizedConfig } from "payload"; -import { - withSpan, - INSTRUMENTATION_SYMBOLS, - type ITracer, - type ILogger, -} from "@repo/core-shared/instrumentation"; -import { authContainer } from "./container"; -import { AUTH_SYMBOLS } from "./symbols"; -import { UsersRepository } from "../infrastructure/repositories/users.repository"; -import { signInUseCase } from "../application/use-cases/sign-in.use-case"; -import { signUpUseCase } from "../application/use-cases/sign-up.use-case"; -import { signOutUseCase } from "../application/use-cases/sign-out.use-case"; -import { signInController } from "../interface-adapters/controllers/sign-in.controller"; -import { signUpController } from "../interface-adapters/controllers/sign-up.controller"; -import { signOutController } from "../interface-adapters/controllers/sign-out.controller"; - -export function bindProductionAuth( - config: SanitizedConfig, - tracer: ITracer, - logger: ILogger, -): void { - // Bind shared instrumentation - for (const sym of [INSTRUMENTATION_SYMBOLS.TRACER, INSTRUMENTATION_SYMBOLS.LOGGER]) { - if (authContainer.isBound(sym)) authContainer.unbind(sym); - } - authContainer.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); - authContainer.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); - - // Repository - if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { - authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); - } - const repo = new UsersRepository(config, tracer, logger); - authContainer.bind(AUTH_SYMBOLS.IUsersRepository).toConstantValue(repo); - - // The auth feature also has IAuthenticationService — read existing bind-production to - // understand what additional services exist; bind them similarly. - // (If auth's bind-production binds an authentication service, follow the same pattern.) - - // Use cases - const wrappedSignIn = withSpan( - tracer, - { name: "auth.signIn", op: "use-case" }, - signInUseCase(repo /*, ...other deps */), - ); - const wrappedSignUp = withSpan( - tracer, - { name: "auth.signUp", op: "use-case" }, - signUpUseCase(repo /*, ...other deps */), - ); - const wrappedSignOut = withSpan( - tracer, - { name: "auth.signOut", op: "use-case" }, - signOutUseCase(/* deps */), - ); - - for (const sym of [ - AUTH_SYMBOLS.SignInUseCase, - AUTH_SYMBOLS.SignUpUseCase, - AUTH_SYMBOLS.SignOutUseCase, - AUTH_SYMBOLS.SignInController, - AUTH_SYMBOLS.SignUpController, - AUTH_SYMBOLS.SignOutController, - ]) { - if (authContainer.isBound(sym)) authContainer.unbind(sym); - } - authContainer.bind(AUTH_SYMBOLS.SignInUseCase).toConstantValue(wrappedSignIn); - authContainer.bind(AUTH_SYMBOLS.SignUpUseCase).toConstantValue(wrappedSignUp); - authContainer.bind(AUTH_SYMBOLS.SignOutUseCase).toConstantValue(wrappedSignOut); - - // Controllers - authContainer - .bind(AUTH_SYMBOLS.SignInController) - .toConstantValue( - withSpan( - tracer, - { name: "auth.signIn", op: "controller" }, - signInController(wrappedSignIn), - ), - ); - authContainer - .bind(AUTH_SYMBOLS.SignUpController) - .toConstantValue( - withSpan( - tracer, - { name: "auth.signUp", op: "controller" }, - signUpController(wrappedSignUp), - ), - ); - authContainer - .bind(AUTH_SYMBOLS.SignOutController) - .toConstantValue( - withSpan( - tracer, - { name: "auth.signOut", op: "controller" }, - signOutController(wrappedSignOut), - ), - ); -} -``` - -> **Note:** The existing `bind-production.ts` may bind additional dependencies (e.g., `IAuthenticationService`). Read the current file first and preserve any bindings beyond the repository — apply the same `tracer`/`logger` constructor extension to those service classes too if they're feature-owned implementations. - -- [ ] **Step 4: Update bind-dev-seed.ts** - -Apply Phase E preamble pattern: signature `(tracer, logger)`, mock repo gets tracer/logger, every use case + controller is wrapped via `withSpan`. Use Task 18's `bind-dev-seed.ts` as structural reference. - -- [ ] **Step 5: Update apps/web-next/src/server/bind-production.ts** - -```ts -// In bindAllProduction: -bindProductionAuth(resolvedConfig, tracer, logger); - -// In bindAllDevSeed: -await bindDevSeedAuth(tracer, logger); -``` - -- [ ] **Step 6: Span-shape test for the auth mock repo** - -Create `packages/auth/src/infrastructure/repositories/users.repository.span.test.ts` matching Task 18 Step 7 — assert at least one method emits a `users.` span with `op: "repository"` and the documented attributes. - -- [ ] **Step 7: Run feature + app tests** - -Run: `pnpm --filter @repo/auth test && pnpm --filter web-next test` -Expected: PASS — existing tests with Noop defaults; new span-shape test passes. - -- [ ] **Step 8: Commit** - -```bash -git add packages/auth/src/ apps/web-next/src/server/bind-production.ts -git commit -m "feat(auth): wire instrumentation — users repo spans + sign-in/up/out withSpan" -``` - ---- - -### Task 20: Marketing-pages feature wiring - -**Files:** -- Modify: `packages/marketing-pages/src/infrastructure/repositories/site-settings.repository.ts` -- Modify: `packages/marketing-pages/src/infrastructure/repositories/site-settings.repository.mock.ts` -- Modify: `packages/marketing-pages/src/infrastructure/repositories/pages.repository.ts` -- Modify: `packages/marketing-pages/src/infrastructure/repositories/pages.repository.mock.ts` -- Modify: `packages/marketing-pages/src/di/bind-production.ts` -- Modify: `packages/marketing-pages/src/di/bind-dev-seed.ts` -- Modify: `apps/web-next/src/server/bind-production.ts` (thread tracer/logger) - -**Two repos:** site-settings + pages. -**Use cases:** `marketing-pages.getSiteSettings`, `marketing-pages.getPageBySlug`. - -- [ ] **Step 1: Read existing files** (the 6 listed above + use-case + controller files). - -- [ ] **Step 2: Apply Phase E preamble pattern to both repositories** (real + mock for each), spans named `site-settings.` and `pages.`. - -- [ ] **Step 3: Update bind-production.ts** - -```ts -// packages/marketing-pages/src/di/bind-production.ts (skeleton) -import type { SanitizedConfig } from "payload"; -import { withSpan, INSTRUMENTATION_SYMBOLS, type ITracer, type ILogger } from "@repo/core-shared/instrumentation"; -import { marketingPagesContainer } from "./container"; -import { MARKETING_PAGES_SYMBOLS } from "./symbols"; -import { SiteSettingsRepository } from "../infrastructure/repositories/site-settings.repository"; -import { PagesRepository } from "../infrastructure/repositories/pages.repository"; -import { getSiteSettingsUseCase } from "../application/use-cases/get-site-settings.use-case"; -import { getPageBySlugUseCase } from "../application/use-cases/get-page-by-slug.use-case"; -import { getSiteSettingsController } from "../interface-adapters/controllers/get-site-settings.controller"; -import { getPageBySlugController } from "../interface-adapters/controllers/get-page-by-slug.controller"; - -export function bindProductionMarketingPages( - config: SanitizedConfig, - tracer: ITracer, - logger: ILogger, -): void { - for (const sym of [INSTRUMENTATION_SYMBOLS.TRACER, INSTRUMENTATION_SYMBOLS.LOGGER]) { - if (marketingPagesContainer.isBound(sym)) marketingPagesContainer.unbind(sym); - } - marketingPagesContainer.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); - marketingPagesContainer.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); - - // Two repos - for (const sym of [MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository, MARKETING_PAGES_SYMBOLS.IPagesRepository]) { - if (marketingPagesContainer.isBound(sym)) marketingPagesContainer.unbind(sym); - } - const settingsRepo = new SiteSettingsRepository(config, tracer, logger); - const pagesRepo = new PagesRepository(config, tracer, logger); - marketingPagesContainer.bind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository).toConstantValue(settingsRepo); - marketingPagesContainer.bind(MARKETING_PAGES_SYMBOLS.IPagesRepository).toConstantValue(pagesRepo); - - const wrappedGetSettings = withSpan( - tracer, - { name: "marketing-pages.getSiteSettings", op: "use-case" }, - getSiteSettingsUseCase(settingsRepo), - ); - const wrappedGetPage = withSpan( - tracer, - { name: "marketing-pages.getPageBySlug", op: "use-case" }, - getPageBySlugUseCase(pagesRepo), - ); - - for (const sym of [ - MARKETING_PAGES_SYMBOLS.GetSiteSettingsUseCase, - MARKETING_PAGES_SYMBOLS.GetPageBySlugUseCase, - MARKETING_PAGES_SYMBOLS.GetSiteSettingsController, - MARKETING_PAGES_SYMBOLS.GetPageBySlugController, - ]) { - if (marketingPagesContainer.isBound(sym)) marketingPagesContainer.unbind(sym); - } - marketingPagesContainer.bind(MARKETING_PAGES_SYMBOLS.GetSiteSettingsUseCase).toConstantValue(wrappedGetSettings); - marketingPagesContainer.bind(MARKETING_PAGES_SYMBOLS.GetPageBySlugUseCase).toConstantValue(wrappedGetPage); - - marketingPagesContainer - .bind(MARKETING_PAGES_SYMBOLS.GetSiteSettingsController) - .toConstantValue( - withSpan( - tracer, - { name: "marketing-pages.getSiteSettings", op: "controller" }, - getSiteSettingsController(wrappedGetSettings), - ), - ); - marketingPagesContainer - .bind(MARKETING_PAGES_SYMBOLS.GetPageBySlugController) - .toConstantValue( - withSpan( - tracer, - { name: "marketing-pages.getPageBySlug", op: "controller" }, - getPageBySlugController(wrappedGetPage), - ), - ); -} -``` - -- [ ] **Step 4: Update bind-dev-seed.ts** — same pattern as Task 18 Step 4 with marketing-pages's use cases + controllers. - -- [ ] **Step 5: Update apps/web-next/src/server/bind-production.ts** - -```ts -// In bindAllProduction: -bindProductionMarketingPages(resolvedConfig, tracer, logger); - -// In bindAllDevSeed: -await bindDevSeedMarketingPages(tracer, logger); -``` - -- [ ] **Step 6: Span-shape tests** — one per repo (site-settings + pages). - -- [ ] **Step 7: Run tests** - -Run: `pnpm --filter @repo/marketing-pages test && pnpm --filter web-next test` -Expected: PASS. - -- [ ] **Step 8: Commit** - -```bash -git add packages/marketing-pages/src/ apps/web-next/src/server/bind-production.ts -git commit -m "feat(marketing-pages): wire instrumentation — site-settings + pages spans + use-case/controller withSpan" -``` - ---- - -### Task 21: Navigation feature wiring - -**Files:** -- Modify: `packages/navigation/src/infrastructure/repositories/header.repository.ts` -- Modify: `packages/navigation/src/infrastructure/repositories/header.repository.mock.ts` -- Modify: `packages/navigation/src/di/bind-production.ts` -- Modify: `packages/navigation/src/di/bind-dev-seed.ts` -- Modify: `apps/web-next/src/server/bind-production.ts` (thread tracer/logger) - -**Single repo:** header. **Single use case:** `navigation.getHeader`. - -- [ ] **Step 1: Read existing files.** - -- [ ] **Step 2: Apply Phase E preamble pattern to header.repository.ts (real + mock)** — spans `header.`. - -- [ ] **Step 3: Update bind-production.ts** — same pattern as Task 18 Step 3, scoped to one use case + controller (`getHeader`). - -- [ ] **Step 4: Update bind-dev-seed.ts** — same pattern as Task 18 Step 4. - -- [ ] **Step 5: Update apps/web-next/src/server/bind-production.ts** - -```ts -bindProductionNavigation(resolvedConfig, tracer, logger); -await bindDevSeedNavigation(tracer, logger); -``` - -- [ ] **Step 6: Span-shape test** for the header mock repo. - -- [ ] **Step 7: Run tests** - -Run: `pnpm --filter @repo/navigation test && pnpm --filter web-next test` -Expected: PASS. - -- [ ] **Step 8: Commit** - -```bash -git add packages/navigation/src/ apps/web-next/src/server/bind-production.ts -git commit -m "feat(navigation): wire instrumentation — header repo spans + getHeader withSpan" -``` - ---- - -### Task 22: Media feature wiring - -**Files:** -- Modify: `packages/media/src/infrastructure/repositories/media.repository.ts` -- Modify: `packages/media/src/infrastructure/repositories/media.repository.mock.ts` -- Modify: `packages/media/src/di/bind-production.ts` -- Modify: `packages/media/src/di/bind-dev-seed.ts` -- Modify: `apps/web-next/src/server/bind-production.ts` (thread tracer/logger) - -**Single repo:** media. **Use cases:** `media.getMedia`, `media.listMedia`, `media.deleteMedia`. - -- [ ] **Step 1: Read existing files.** - -- [ ] **Step 2: Apply Phase E preamble pattern to media.repository.ts (real + mock)** — spans `media.`. Note: `deleteMedia` is a side-effect operation; record `id` + `deleted: true|false` attributes. - -- [ ] **Step 3: Update bind-production.ts** — same pattern as Task 18 Step 3 with media's 3 use cases + controllers. - -- [ ] **Step 4: Update bind-dev-seed.ts** — same pattern. - -- [ ] **Step 5: Update apps/web-next/src/server/bind-production.ts** - -```ts -bindProductionMedia(resolvedConfig, tracer, logger); -await bindDevSeedMedia(tracer, logger); -``` - -- [ ] **Step 6: Span-shape test** for media mock repo. - -- [ ] **Step 7: Run tests** - -Run: `pnpm --filter @repo/media test && pnpm --filter web-next test` -Expected: PASS. - -- [ ] **Step 8: Commit** - -```bash -git add packages/media/src/ apps/web-next/src/server/bind-production.ts -git commit -m "feat(media): wire instrumentation — media repo spans + getMedia/listMedia/deleteMedia withSpan" -``` - ---- - -## Phase F — Contract suite span assertions - -### Task 23: `defineContractSuite` `expectSpan` helper - -**Files:** -- Modify: `packages/core-testing/src/contract/define-contract-suite.ts` -- Modify: `packages/core-testing/src/contract/define-contract-suite.test.ts` - -- [ ] **Step 1: Update define-contract-suite.ts** - -```ts -// packages/core-testing/src/contract/define-contract-suite.ts -import { describe } from "vitest"; -import type { RecordingTracer } from "../instrumentation/recording-tracer"; - -export interface ContractContext { - buildSubject: () => Promise | T; - /** - * R50 — when callers wire a RecordingTracer into the subject they can supply - * this accessor so contract suites can assert span shape per method. The - * accessor MUST return the SAME tracer instance every call (so suites can - * reset() and assert in sequence). - */ - getTracer?: () => RecordingTracer; -} - -export interface ContractSuite { - run( - buildSubject: () => Promise | T, - opts?: { tracer?: () => RecordingTracer }, - ): void; -} - -export function defineContractSuite( - name: string, - suite: (ctx: ContractContext) => void, -): ContractSuite { - return { - run(buildSubject, opts) { - describe(`Contract: ${name}`, () => { - suite({ buildSubject, getTracer: opts?.tracer }); - }); - }, - }; -} -``` - -- [ ] **Step 2: Update define-contract-suite.test.ts** - -Open existing tests and add coverage for `getTracer` plumbing: - -```ts -// Append to packages/core-testing/src/contract/define-contract-suite.test.ts -import { describe, it, expect } from "vitest"; -import { defineContractSuite } from "@/contract/define-contract-suite"; -import { RecordingTracer } from "@/instrumentation/recording-tracer"; - -describe("defineContractSuite — getTracer plumbing (R50)", () => { - it("passes the tracer accessor into the suite", () => { - let receivedTracer: RecordingTracer | undefined; - const tracer = new RecordingTracer(); - const suite = defineContractSuite<{ foo: string }>("Test", ({ buildSubject, getTracer }) => { - it("can read tracer", async () => { - const subject = await buildSubject(); - expect(subject.foo).toBe("bar"); - receivedTracer = getTracer?.(); - }); - }); - suite.run(() => ({ foo: "bar" }), { tracer: () => tracer }); - // Vitest defers actual assertion to the `it`; we verify the wiring by re-reading after. - // (This is a meta-test of plumbing only — the inner it() runs as a child describe.) - expect(typeof tracer.startSpan).toBe("function"); - }); - - it("getTracer is undefined when opts.tracer not provided (backward compat)", () => { - let receivedAccessor: unknown = undefined; - const suite = defineContractSuite<{ x: number }>("Test", ({ buildSubject, getTracer }) => { - it("accessor undefined", async () => { - await buildSubject(); - receivedAccessor = getTracer; - }); - }); - suite.run(() => ({ x: 1 })); - // No tracer opts → accessor is undefined inside the suite body. - // (Exact assertion happens via type, not runtime — typecheck gates this.) - void receivedAccessor; - }); -}); -``` - -Run: `pnpm --filter @repo/core-testing test define-contract-suite` -Expected: PASS — existing tests + 2 new tests. - -- [ ] **Step 3: Commit** - -```bash -git add packages/core-testing/src/contract/define-contract-suite.ts \ - packages/core-testing/src/contract/define-contract-suite.test.ts -git commit -m "feat(core-testing): R50 — contract context gains optional getTracer accessor" -``` - ---- - -### Task 24: Update every repo contract suite to assert span shape - -**Files (all 6 contracts):** -- `packages/blog/src/__contracts__/articles-repository.contract.ts` -- `packages/auth/src/__contracts__/users-repository.contract.ts` -- `packages/marketing-pages/src/__contracts__/site-settings-repository.contract.ts` -- `packages/marketing-pages/src/__contracts__/pages-repository.contract.ts` -- `packages/navigation/src/__contracts__/header-repository.contract.ts` -- `packages/media/src/__contracts__/media-repository.contract.ts` - -Plus the call sites that run them (the per-feature `*.repository.mock.test.ts` and any real-Payload contract integration test) — update to pass `{ tracer: () => recordingTracer }`. - -> **Pattern (applies to every contract):** add a final `describe.skipIf(!getTracer)("span emission (R50)")` block enumerating one `it()` per repo method, each invoking the method and asserting `tracer.findSpan(".")` returns a span with `op: "repository"` and the expected attributes. - -- [ ] **Step 1: Update blog contract** (`packages/blog/src/__contracts__/articles-repository.contract.ts`) - -Append to the suite body, after the existing tests: - -```ts -import { RecordingTracer } from "@repo/core-testing/instrumentation"; // add at top - -// At the end of the suite body, before the closing `});`: - -describe("span emission (R50)", () => { - it("getArticles emits articles.getArticles span with op=repository", async () => { - if (!getTracer) return; - const tracer = getTracer(); - tracer.reset(); - await repo.getArticles({ limit: 5 }); - const span = tracer.findSpan("articles.getArticles"); - expect(span).toBeDefined(); - expect(span!.op).toBe("repository"); - expect(span!.attributes.limit).toBe(5); - }); - - it("getArticle emits articles.getArticle span with id attribute", async () => { - if (!getTracer) return; - const tracer = getTracer(); - tracer.reset(); - await repo.getArticle("nonexistent"); - const span = tracer.findSpan("articles.getArticle"); - expect(span).toBeDefined(); - expect(span!.attributes.id).toBe("nonexistent"); - }); - - it("getArticleBySlug emits articles.getArticleBySlug span with slug attribute", async () => { - if (!getTracer) return; - const tracer = getTracer(); - tracer.reset(); - await repo.getArticleBySlug("nonexistent"); - const span = tracer.findSpan("articles.getArticleBySlug"); - expect(span).toBeDefined(); - expect(span!.attributes.slug).toBe("nonexistent"); - }); - - it("createArticle emits articles.createArticle span", async () => { - if (!getTracer) return; - const tracer = getTracer(); - tracer.reset(); - const seed = articleFactory.build(); - await repo.createArticle(seed); - const span = tracer.findSpan("articles.createArticle"); - expect(span).toBeDefined(); - expect(span!.attributes.slug).toBe(seed.slug); - }); - - it("updateArticle emits articles.updateArticle span", async () => { - if (!getTracer) return; - const tracer = getTracer(); - tracer.reset(); - const seed = articleFactory.build(); - const created = await repo.createArticle(seed); - await repo.updateArticle(created.id, { title: "Updated" }); - const span = tracer.findSpan("articles.updateArticle"); - expect(span).toBeDefined(); - expect(span!.attributes.id).toBe(created.id); - }); -}); -``` - -> **Note:** the suite's `getTracer` and `repo` references must already be in scope. If the existing suite doesn't accept `getTracer` from `ContractContext`, update the destructuring at the top: -> `({ buildSubject, getTracer }) => { ... }`. -> Also import `describe` if it isn't already imported at the top. - -- [ ] **Step 2: Update the blog mock contract caller** - -The contract suite is consumed by mock-side tests like `articles.repository.mock.test.ts`. Update the consumer to pass `tracer`: - -```ts -// packages/blog/src/infrastructure/repositories/articles.repository.mock.test.ts (excerpt) -import { RecordingTracer } from "@repo/core-testing/instrumentation"; -import { MockArticlesRepository } from "./articles.repository.mock"; -import { articlesRepositoryContract } from "../../__contracts__/articles-repository.contract"; - -const tracer = new RecordingTracer(); -articlesRepositoryContract.run( - () => new MockArticlesRepository(tracer), - { tracer: () => tracer }, -); -``` - -- [ ] **Step 3: Update the blog real-payload contract caller (if present)** - -If the codebase has an integration test that runs the contract against the real Payload-backed repository (e.g., `articles.repository.test.ts`), thread the tracer through identically: - -```ts -const tracer = new RecordingTracer(); -articlesRepositoryContract.run( - async () => { - const config = await stubConfig(); // existing test helper - return new ArticlesRepository(config, tracer); - }, - { tracer: () => tracer }, -); -``` - -- [ ] **Step 4: Repeat Steps 1–3 for the other five contracts** - -For each of: -- `auth/src/__contracts__/users-repository.contract.ts` — methods enumerated by reading the file (typically `getUserByEmail`, `getUser`, `createUser`, etc.). Spans `users.`. -- `marketing-pages/src/__contracts__/site-settings-repository.contract.ts` — typically a single `getSiteSettings` method. Spans `site-settings.`. -- `marketing-pages/src/__contracts__/pages-repository.contract.ts` — typically `getPageBySlug`. Spans `pages.`. -- `navigation/src/__contracts__/header-repository.contract.ts` — typically `getHeader`. Spans `header.`. -- `media/src/__contracts__/media-repository.contract.ts` — typically `getMedia`, `listMedia`, `deleteMedia`. Spans `media.`. - -For each contract: add the `span emission (R50)` describe block enumerating one `it` per method. Update each contract's mock caller and real caller to pass `{ tracer: () => recordingTracer }`. - -- [ ] **Step 5: Run all feature tests** - -Run: `pnpm test` -Expected: PASS across the monorepo. Each feature now has additional span-shape assertions running as part of the contract. - -- [ ] **Step 6: Commit** - -```bash -git add packages/blog/src/__contracts__/articles-repository.contract.ts \ - packages/blog/src/infrastructure/repositories/articles.repository.mock.test.ts \ - packages/blog/src/infrastructure/repositories/articles.repository.test.ts \ - packages/auth/src/__contracts__/users-repository.contract.ts \ - packages/auth/src/infrastructure/repositories/ \ - packages/marketing-pages/src/__contracts__/ \ - packages/marketing-pages/src/infrastructure/repositories/ \ - packages/navigation/src/__contracts__/header-repository.contract.ts \ - packages/navigation/src/infrastructure/repositories/ \ - packages/media/src/__contracts__/media-repository.contract.ts \ - packages/media/src/infrastructure/repositories/ -git commit -m "test(features): R50 — repo contract suites assert span shape per method" -``` - ---- - -## Phase G — App integration - -### Task 25: apps/web-next instrumentation files + PII scrubber test - -**Files:** -- Create: `apps/web-next/instrumentation.ts` -- Create: `apps/web-next/instrumentation-client.ts` -- Modify: `apps/web-next/next.config.mjs` -- Modify: `apps/web-next/package.json` (add `@sentry/nextjs` dep) -- Create: `apps/web-next/src/__tests__/sentry-pii-scrubber.test.ts` - -- [ ] **Step 1: Add the dep** - -```bash -pnpm --filter web-next add @sentry/nextjs -``` - -- [ ] **Step 2: Write apps/web-next/instrumentation.ts** - -```ts -// apps/web-next/instrumentation.ts -// Next.js convention: this module runs once on server boot. -// We delegate to the centralized init helper in core-shared. - -export async function register() { - if ( - process.env.NEXT_RUNTIME === "nodejs" || - process.env.NEXT_RUNTIME === "edge" - ) { - const { initSentryServer } = await import( - "@repo/core-shared/instrumentation/sentry/init-server" - ); - initSentryServer({ - dsn: process.env.WEB_NEXT_SENTRY_DSN, - app: "web-next", - release: process.env.VERCEL_GIT_COMMIT_SHA, - }); - } -} -``` - -- [ ] **Step 3: Write apps/web-next/instrumentation-client.ts** - -```ts -// apps/web-next/instrumentation-client.ts -// Next.js 15+ browser hook: runs in the client bundle on app start. - -import { initSentryClient } from "@repo/core-shared/instrumentation/sentry/init-client"; - -initSentryClient({ - dsn: process.env.NEXT_PUBLIC_WEB_NEXT_SENTRY_DSN, - app: "web-next", - release: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA, -}); -``` - -- [ ] **Step 4: Wrap next.config.mjs with `withSentryConfig`** - -Open `apps/web-next/next.config.mjs`. Wrap the default export: - -```js -// apps/web-next/next.config.mjs (excerpt — preserve existing config object) -import { withSentryConfig } from "@sentry/nextjs"; - -const nextConfig = { - // ... existing config (unchanged) ... -}; - -export default withSentryConfig(nextConfig, { - // R52 — token is build-time only; CI sets SENTRY_AUTH_TOKEN - silent: process.env.CI !== "true", - authToken: process.env.SENTRY_AUTH_TOKEN, - org: process.env.SENTRY_ORG, - project: process.env.SENTRY_PROJECT_WEB_NEXT, - // Don't tunnel through the app server in dev — use direct uploads - hideSourceMaps: true, - disableLogger: true, -}); -``` - -- [ ] **Step 5: Write the PII scrubber smoke test (R38)** - -```ts -// apps/web-next/src/__tests__/sentry-pii-scrubber.test.ts -import { describe, it, expect } from "vitest"; -import { - beforeSend, - beforeSendTransaction, -} from "@repo/core-shared/instrumentation/sentry/scrub"; - -describe("R38 — apps/web-next PII scrubber", () => { - it("strips email/password/cookie/auth/IP from event payload", () => { - const event = { - extra: { - userEmail: "alice@example.com", - password: "p4$$w0rd", - ipAddress: "192.168.1.10", - note: "request from 10.0.0.1", - }, - request: { - headers: { - Authorization: "Bearer secret", - "Set-Cookie": "session=abc", - "User-Agent": "Mozilla", - }, - }, - } as any; - const result = beforeSend(event, {} as any) as any; - expect(result.extra.userEmail).toBe("[redacted]"); - expect(result.extra.password).toBe("[redacted]"); - expect(result.extra.ipAddress).toBe("[redacted]"); - expect(result.extra.note).toContain("[redacted-ip]"); - expect(result.request.headers.Authorization).toBe("[redacted]"); - expect(result.request.headers["Set-Cookie"]).toBe("[redacted]"); - expect(result.request.headers["User-Agent"]).toBe("Mozilla"); - }); - - it("strips ?token / ?email / ?password / ?secret / ?signature from URLs", () => { - const event = { - request: { - url: "https://app/api/x?token=abc&email=a@b.c&password=p&secret=z&signature=s&safe=1", - }, - transaction: "/foo?accessToken=t", - } as any; - const result = beforeSendTransaction(event, {} as any) as any; - const url = decodeURIComponent(result.request.url); - const txn = decodeURIComponent(result.transaction); - for (const key of ["token", "email", "password", "secret", "signature"]) { - expect(url).toContain(`${key}=[redacted]`); - } - expect(url).toContain("safe=1"); - expect(txn).toContain("accessToken=[redacted]"); - }); -}); -``` - -Run: `pnpm --filter web-next test sentry-pii-scrubber` -Expected: PASS — 2 tests. - -- [ ] **Step 6: Commit** - -```bash -git add apps/web-next/instrumentation.ts \ - apps/web-next/instrumentation-client.ts \ - apps/web-next/next.config.mjs \ - apps/web-next/package.json \ - apps/web-next/src/__tests__/sentry-pii-scrubber.test.ts \ - pnpm-lock.yaml -git commit -m "feat(web-next): Sentry instrumentation hooks + withSentryConfig + R38 PII test" -``` - ---- - -### Task 26: apps/cms instrumentation files + PII scrubber test - -**Files:** -- Create: `apps/cms/instrumentation.ts` -- Modify: `apps/cms/next.config.mjs` -- Modify: `apps/cms/package.json` (add `@sentry/nextjs`) -- Create: `apps/cms/src/__tests__/sentry-pii-scrubber.test.ts` - -> **Difference from web-next:** CMS is server-only (Payload admin UI); no `instrumentation-client.ts` needed (Payload's admin runs in a browser but its bundling/build is opinionated and the public DSN flow is Payload-specific — defer client-side CMS instrumentation as out-of-scope per spec §8). Server side is identical. - -- [ ] **Step 1: Add the dep** - -```bash -pnpm --filter cms add @sentry/nextjs -``` - -- [ ] **Step 2: Write apps/cms/instrumentation.ts** - -```ts -// apps/cms/instrumentation.ts -export async function register() { - if ( - process.env.NEXT_RUNTIME === "nodejs" || - process.env.NEXT_RUNTIME === "edge" - ) { - const { initSentryServer } = await import( - "@repo/core-shared/instrumentation/sentry/init-server" - ); - initSentryServer({ - dsn: process.env.CMS_SENTRY_DSN, - app: "cms", - release: process.env.VERCEL_GIT_COMMIT_SHA, - }); - } -} -``` - -- [ ] **Step 3: Wrap next.config.mjs** - -Same pattern as Task 25 Step 4, but the project env var is `SENTRY_PROJECT_CMS`: - -```js -import { withSentryConfig } from "@sentry/nextjs"; - -const nextConfig = { /* existing */ }; - -export default withSentryConfig(nextConfig, { - silent: process.env.CI !== "true", - authToken: process.env.SENTRY_AUTH_TOKEN, - org: process.env.SENTRY_ORG, - project: process.env.SENTRY_PROJECT_CMS, - hideSourceMaps: true, - disableLogger: true, -}); -``` - -- [ ] **Step 4: PII scrubber smoke test (R38)** - -Create `apps/cms/src/__tests__/sentry-pii-scrubber.test.ts` with the same content as Task 25 Step 5. (Copy verbatim — the tests are app-agnostic but R38 requires one per app to ensure each app's vitest config is wired correctly.) - -Run: `pnpm --filter cms test sentry-pii-scrubber` -Expected: PASS — 2 tests. - -- [ ] **Step 5: Commit** - -```bash -git add apps/cms/instrumentation.ts \ - apps/cms/next.config.mjs \ - apps/cms/package.json \ - apps/cms/src/__tests__/sentry-pii-scrubber.test.ts \ - pnpm-lock.yaml -git commit -m "feat(cms): Sentry server instrumentation + withSentryConfig + R38 PII test" -``` - ---- - -### Task 27: apps/web-tanstack instrumentation files + PII scrubber test - -**Files:** -- Create: `apps/web-tanstack/src/instrumentation.ts` (server entry hook) -- Create: `apps/web-tanstack/src/instrumentation-client.ts` (client entry hook) -- Modify: `apps/web-tanstack/vite.config.ts` (add `@sentry/vite-plugin`) -- Modify: `apps/web-tanstack/package.json` (add `@sentry/node` + `@sentry/react` + `@sentry/vite-plugin`) -- Create: `apps/web-tanstack/src/__tests__/sentry-pii-scrubber.test.ts` - -> **Difference from web-next/cms:** TanStack Start uses Vite, not Next.js. The `@sentry/nextjs` package is wrong here — use `@sentry/node` (server) and `@sentry/react` (client). The init helpers in `core-shared/instrumentation/sentry/` need a Vite/Vanilla flavor. To minimize complexity, this task adds two thin wrappers in core-shared that re-export the same scrubbers but call `@sentry/node`/`@sentry/react` instead. (If preferred for v1, mark this task as **deferred** and skip web-tanstack — but the spec requires three-app coverage.) - -- [ ] **Step 1: Add deps** - -```bash -pnpm --filter web-tanstack add @sentry/node @sentry/react -pnpm --filter web-tanstack add -D @sentry/vite-plugin -``` - -- [ ] **Step 2: Add `@sentry/node` + `@sentry/react` core-shared adapters** - -Create `packages/core-shared/src/instrumentation/sentry/init-server-node.ts`: - -```ts -// packages/core-shared/src/instrumentation/sentry/init-server-node.ts -import * as SentryNode from "@sentry/node"; -import { beforeSend, beforeSendTransaction } from "./scrub"; -import type { InitServerOpts } from "./init-server"; - -/** - * Server-side init for non-Next.js runtimes (TanStack Start). Mirrors - * init-server.ts but uses @sentry/node directly. R31, R32, R33 still apply. - */ -export function initSentryServerNode(opts: InitServerOpts): void { - if (!opts.dsn) return; - - const isProd = process.env.NODE_ENV === "production"; - const tracesSampleRate = - process.env.SENTRY_TRACES_SAMPLE_RATE !== undefined - ? Number(process.env.SENTRY_TRACES_SAMPLE_RATE) - : isProd - ? 0.1 - : 1.0; - - SentryNode.init({ - dsn: opts.dsn, - environment: - process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV ?? "development", - release: opts.release ?? process.env.VITE_GIT_COMMIT_SHA ?? "unknown", - tracesSampleRate, - sendDefaultPii: false, - beforeSend: beforeSend as any, - beforeSendTransaction: beforeSendTransaction as any, - initialScope: { tags: { app: opts.app } }, - }); -} -``` - -Create `packages/core-shared/src/instrumentation/sentry/init-client-react.ts`: - -```ts -// packages/core-shared/src/instrumentation/sentry/init-client-react.ts -import * as SentryReact from "@sentry/react"; -import { beforeSend, beforeSendTransaction } from "./scrub"; -import type { InitClientOpts } from "./init-client"; - -export function initSentryClientReact(opts: InitClientOpts): void { - if (!opts.dsn) return; - - const isProd = process.env.NODE_ENV === "production"; - const tracesSampleRate = - process.env.SENTRY_TRACES_SAMPLE_RATE !== undefined - ? Number(process.env.SENTRY_TRACES_SAMPLE_RATE) - : isProd - ? 0.1 - : 1.0; - - SentryReact.init({ - dsn: opts.dsn, - environment: - process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV ?? "development", - release: opts.release ?? "unknown", - tracesSampleRate, - sendDefaultPii: false, - beforeSend: beforeSend as any, - beforeSendTransaction: beforeSendTransaction as any, - replaysSessionSampleRate: 0.0, - replaysOnErrorSampleRate: 1.0, - integrations: [ - SentryReact.replayIntegration({ - maskAllText: true, - maskAllInputs: true, - blockAllMedia: true, - }), - ], - initialScope: { tags: { app: opts.app } }, - }); -} -``` - -Add the two new files to `packages/core-shared/src/instrumentation/index.ts`: - -```ts -export { initSentryServerNode } from "./sentry/init-server-node"; -export { initSentryClientReact } from "./sentry/init-client-react"; -``` - -Add `@sentry/node` + `@sentry/react` to `packages/core-shared/package.json` `peerDependencies` (optional) so apps that use them install them; the core-shared test process gets them via the test guard mock if needed (extend `setup/no-sentry.ts` to also mock `@sentry/node` and `@sentry/react`). - -- [ ] **Step 3: Extend setup/no-sentry.ts to mock @sentry/node + @sentry/react** - -Edit `packages/core-testing/src/setup/no-sentry.ts` and add: - -```ts -vi.mock("@sentry/node", () => ({ - init: vi.fn(), - startSpan: vi.fn((_opts: unknown, fn: any) => - fn({ setAttribute: vi.fn(), setStatus: vi.fn() }), - ), - captureException: vi.fn(), - captureMessage: vi.fn(), - addBreadcrumb: vi.fn(), - setUser: vi.fn(), -})); - -vi.mock("@sentry/react", () => ({ - init: vi.fn(), - captureException: vi.fn(), - captureMessage: vi.fn(), - addBreadcrumb: vi.fn(), - setUser: vi.fn(), - replayIntegration: vi.fn(() => ({ name: "Replay" })), -})); -``` - -- [ ] **Step 4: Write tests for the new init helpers** - -Create `packages/core-shared/src/instrumentation/sentry/init-server-node.test.ts` and `init-client-react.test.ts` mirroring Tasks 10 & 11 (assert sendDefaultPii: false, scrubbers attached, mask flags, no-op on missing DSN). - -Run: `pnpm --filter @repo/core-shared test init-server-node init-client-react` -Expected: PASS. - -- [ ] **Step 5: Write web-tanstack instrumentation files** - -```ts -// apps/web-tanstack/src/instrumentation.ts -import { initSentryServerNode } from "@repo/core-shared/instrumentation/sentry/init-server-node"; - -initSentryServerNode({ - dsn: process.env.WEB_TANSTACK_SENTRY_DSN, - app: "web-tanstack", - release: process.env.VITE_GIT_COMMIT_SHA, -}); -``` - -```ts -// apps/web-tanstack/src/instrumentation-client.ts -import { initSentryClientReact } from "@repo/core-shared/instrumentation/sentry/init-client-react"; - -initSentryClientReact({ - dsn: import.meta.env.VITE_WEB_TANSTACK_SENTRY_DSN, - app: "web-tanstack", - release: import.meta.env.VITE_GIT_COMMIT_SHA, -}); -``` - -> **Wiring:** `apps/web-tanstack/src/server.ts` (or wherever the server entry boots) MUST `import "./instrumentation"` at the top. The client entry (`src/main.tsx` or `src/client.tsx`) MUST `import "./instrumentation-client"` at the top. - -- [ ] **Step 6: Update vite.config.ts to upload source maps** - -```ts -// apps/web-tanstack/vite.config.ts (excerpt) -import { sentryVitePlugin } from "@sentry/vite-plugin"; -// ... existing imports ... - -export default defineConfig({ - // ... existing config ... - build: { - // ... existing build config ... - sourcemap: true, // required by sentryVitePlugin - }, - plugins: [ - // ... existing plugins ... - sentryVitePlugin({ - authToken: process.env.SENTRY_AUTH_TOKEN, - org: process.env.SENTRY_ORG, - project: process.env.SENTRY_PROJECT_WEB_TANSTACK, - silent: process.env.CI !== "true", - disable: !process.env.SENTRY_AUTH_TOKEN, // skip in non-CI builds - }), - ], -}); -``` - -- [ ] **Step 7: PII scrubber smoke test (R38)** - -Create `apps/web-tanstack/src/__tests__/sentry-pii-scrubber.test.ts` with content identical to Task 25 Step 5. - -Run: `pnpm --filter web-tanstack test sentry-pii-scrubber` -Expected: PASS — 2 tests. - -- [ ] **Step 8: Commit** - -```bash -git add apps/web-tanstack/src/instrumentation.ts \ - apps/web-tanstack/src/instrumentation-client.ts \ - apps/web-tanstack/vite.config.ts \ - apps/web-tanstack/package.json \ - apps/web-tanstack/src/__tests__/sentry-pii-scrubber.test.ts \ - packages/core-shared/src/instrumentation/sentry/init-server-node.ts \ - packages/core-shared/src/instrumentation/sentry/init-server-node.test.ts \ - packages/core-shared/src/instrumentation/sentry/init-client-react.ts \ - packages/core-shared/src/instrumentation/sentry/init-client-react.test.ts \ - packages/core-shared/src/instrumentation/index.ts \ - packages/core-shared/package.json \ - packages/core-testing/src/setup/no-sentry.ts \ - pnpm-lock.yaml -git commit -m "feat(web-tanstack): Sentry instrumentation via @sentry/node + @sentry/react + R38 PII test" -``` - ---- - -## Phase H — Boundary enforcement + config - -### Task 28: ESLint boundary rule (R40) + CI grep for `sendDefaultPii: true` (R31) - -**Files:** -- Modify: `packages/core-eslint/base.js` -- Create: `.github/workflows/sentry-pii-guard.yml` (or extend existing CI workflow) - -- [ ] **Step 1: Add `no-restricted-imports` to core-eslint/base.js** - -Open `packages/core-eslint/base.js`. Add a new flat-config block (preserve existing entries): - -```js -// packages/core-eslint/base.js (excerpt — append a new config block) -{ - files: ["**/*.{ts,tsx,mjs,cjs,js}"], - ignores: [ - // R40 — only these paths may import from @sentry/* - "packages/core-shared/src/instrumentation/sentry/**", - "packages/core-testing/src/setup/no-sentry.{ts,js}", - "apps/*/instrumentation.{ts,js,mjs}", - "apps/*/instrumentation-client.{ts,js,mjs}", - "apps/*/src/instrumentation.{ts,js,mjs}", - "apps/*/src/instrumentation-client.{ts,js,mjs}", - "apps/*/next.config.{mjs,ts,js}", - "apps/*/vite.config.{ts,mjs,js}", - "apps/*/sentry.*.config.{ts,mjs,js}", - ], - rules: { - "no-restricted-imports": [ - "error", - { - patterns: [ - { - group: ["@sentry/*"], - message: - "Import from @repo/core-shared/instrumentation instead — feature packages must not depend on Sentry directly (R40).", - }, - ], - }, - ], - }, -}, -``` - -> **Note:** the `ignores` array on a flat-config block is the **opposite** of allowlist — these are paths that DO get the rule applied. To make the allowlist work, invert: apply the rule to *everything* and override (turn off) for the allowlisted paths in a follow-up block. Use this two-block pattern instead: - -```js -// Block 1 — apply restriction repo-wide -{ - files: ["**/*.{ts,tsx,mjs,cjs,js}"], - rules: { - "no-restricted-imports": [ - "error", - { - patterns: [ - { - group: ["@sentry/*"], - message: - "Import from @repo/core-shared/instrumentation instead — feature packages must not depend on Sentry directly (R40).", - }, - ], - }, - ], - }, -}, -// Block 2 — override (allow Sentry imports) for the explicit allowlist -{ - files: [ - "packages/core-shared/src/instrumentation/sentry/**", - "packages/core-testing/src/setup/no-sentry.{ts,js}", - "apps/*/instrumentation.{ts,js,mjs}", - "apps/*/instrumentation-client.{ts,js,mjs}", - "apps/*/src/instrumentation.{ts,js,mjs}", - "apps/*/src/instrumentation-client.{ts,js,mjs}", - "apps/*/next.config.{mjs,ts,js}", - "apps/*/vite.config.{ts,mjs,js}", - "apps/*/sentry.*.config.{ts,mjs,js}", - ], - rules: { - "no-restricted-imports": "off", - }, -}, -``` - -- [ ] **Step 2: Verify the rule fires** - -Create a temporary throwaway file to test, then delete: - -```bash -mkdir -p /tmp/sentry-rule-check -cat <<'EOF' > /tmp/sentry-rule-check/violator.ts -import * as Sentry from "@sentry/nextjs"; -console.log(Sentry); -EOF -# Place inside a feature path to actually exercise the rule: -cp /tmp/sentry-rule-check/violator.ts packages/blog/src/__violator__.ts -pnpm --filter @repo/blog lint || echo "EXPECTED: lint should fail with R40 message" -rm packages/blog/src/__violator__.ts -``` - -Expected: lint fails with the R40 message. - -Repeat the check at an allowlisted path: - -```bash -cp /tmp/sentry-rule-check/violator.ts apps/web-next/instrumentation-temp.ts -# (instrumentation*.ts is allowlisted) -pnpm --filter web-next lint && echo "EXPECTED: lint should PASS at allowlisted path" -rm apps/web-next/instrumentation-temp.ts -rm -rf /tmp/sentry-rule-check -``` - -Expected: lint passes (the allowlist correctly turns off the rule there). - -- [ ] **Step 3: Add a CI grep step for R31 (`sendDefaultPii: true`)** - -If a GitHub Actions workflow already exists, append a step. Otherwise create `.github/workflows/sentry-pii-guard.yml`: - -```yaml -# .github/workflows/sentry-pii-guard.yml -name: Sentry PII guard (R31) - -on: - pull_request: - push: - branches: [main] - -jobs: - pii-guard: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Verify sendDefaultPii is never true - run: | - if grep -RIn --include='*.ts' --include='*.tsx' --include='*.mjs' --include='*.cjs' --include='*.js' \ - -E 'sendDefaultPii\s*:\s*true' \ - packages/ apps/; then - echo "::error::R31 violation — sendDefaultPii: true is forbidden anywhere in the repo." - exit 1 - fi - echo "OK — no sendDefaultPii: true detected." -``` - -- [ ] **Step 4: Run lint repo-wide** - -Run: `pnpm lint` -Expected: PASS — no current violation (instrumentation files in apps/ are allowlisted; core-shared/instrumentation/sentry/ is allowlisted; everything else doesn't import @sentry/*). - -- [ ] **Step 5: Commit** - -```bash -git add packages/core-eslint/base.js .github/workflows/sentry-pii-guard.yml -git commit -m "feat(eslint+ci): R40 boundary rule for @sentry/* + R31 sendDefaultPii grep gate" -``` - ---- - -### Task 29: turbo.json globalEnv updates - -**Files:** -- Modify: `turbo.json` - -- [ ] **Step 1: Read current turbo.json** - -Run: `cat turbo.json | jq .globalEnv` - -Note the existing entries (e.g., `USE_DEV_SEED`, `NODE_ENV`). - -- [ ] **Step 2: Append the 8 instrumentation env vars** - -Edit `turbo.json` and add to `globalEnv` (preserving existing entries): - -```json -{ - "globalEnv": [ - "USE_DEV_SEED", - "NODE_ENV", - "WEB_NEXT_SENTRY_DSN", - "NEXT_PUBLIC_WEB_NEXT_SENTRY_DSN", - "CMS_SENTRY_DSN", - "WEB_TANSTACK_SENTRY_DSN", - "VITE_WEB_TANSTACK_SENTRY_DSN", - "SENTRY_AUTH_TOKEN", - "SENTRY_ORG", - "SENTRY_PROJECT_WEB_NEXT", - "SENTRY_PROJECT_CMS", - "SENTRY_PROJECT_WEB_TANSTACK", - "SENTRY_TRACES_SAMPLE_RATE", - "SENTRY_ENVIRONMENT", - "VERCEL_GIT_COMMIT_SHA", - "NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA", - "VERCEL_ENV" - ] -} -``` - -- [ ] **Step 3: Verify turbo accepts the config** - -Run: `pnpm turbo build --dry` -Expected: no warnings about undeclared env vars. - -- [ ] **Step 4: Commit** - -```bash -git add turbo.json -git commit -m "chore(turbo): declare instrumentation env vars in globalEnv" -``` - ---- - -## Phase I — Docs + HTML - -### Task 30: Doc updates — CLAUDE.md, AGENTS.md, vertical-feature-spec.md - -**Files:** -- Modify: `CLAUDE.md` -- Modify: `AGENTS.md` -- Modify: `docs/architecture/vertical-feature-spec.md` - -- [ ] **Step 1: Update CLAUDE.md** - -Open `CLAUDE.md` and append to the "Key Conventions" section: - -```markdown -- **Instrumentation lives in `core-shared/instrumentation/`** — Two interfaces (`ITracer`, `ILogger`), three implementations (`NoopTracer`/`NoopLogger`, `SentryTracer`/`SentryLogger`, and `RecordingTracer`/`RecordingLogger` from `core-testing`). Feature packages MUST NOT import `@sentry/*` directly (R40, eslint-enforced). -- **Spans applied at DI bind time** — Use cases + controllers wrapped via `withSpan(tracer, { name: ".", op: "use-case" }, factory(...))` inside `bind-production` / `bind-dev-seed`. Repository methods emit explicit `tracer.startSpan({ name: ".", op: "repository", attributes: {...} }, ...)` (R41, R42). -- **Capture at throw sites only** — Repository catch blocks call `this.logger.captureException(err, { tags: { feature, repo, method } })`; use cases capture errors they originate; the tRPC error middleware does NOT capture (R43, R44). -- **PII handling is non-negotiable** — `sendDefaultPii: false` everywhere (R31, CI grep gate); replay default-masks all text/inputs/media (R34, R35, allowlist starts empty); `Sentry.setUser({ id })` only — no email/username (R36); `beforeSend` + `beforeSendTransaction` scrubbers strip emails/passwords/tokens/cookies/auth/IPs (R32, R33). -- **Three apps, three Sentry projects** — `WEB_NEXT_SENTRY_DSN`, `CMS_SENTRY_DSN`, `WEB_TANSTACK_SENTRY_DSN`. Browser DSNs use `NEXT_PUBLIC_` (web-next) and `VITE_` (web-tanstack) prefixes. -- **Instrumentation binding is orthogonal to repo binding** — `bindAll()`'s Rule 0 (DSN → Sentry vs Noop) is independent of `USE_DEV_SEED` / `NODE_ENV`. Run `pnpm dev` with `WEB_NEXT_SENTRY_DSN` set to test the integration locally. -``` - -- [ ] **Step 2: Update root AGENTS.md** - -Open `AGENTS.md`. Add a new section after the existing per-feature conventions: - -```markdown -## Instrumentation conventions - -**Symbols (in `core-shared/instrumentation/symbols.ts`):** -- `INSTRUMENTATION_SYMBOLS.TRACER` — bound to `ITracer` (NoopTracer / SentryTracer) -- `INSTRUMENTATION_SYMBOLS.LOGGER` — bound to `ILogger` (NoopLogger / SentryLogger) - -**Repository constructor signature (every feature):** - -```ts -constructor( - config: SanitizedConfig, - tracer: ITracer = new NoopTracer(), - logger: ILogger = new NoopLogger(), -) -``` - -**Repository method body (every public async method):** - -```ts -return this.tracer.startSpan( - { name: ".", op: "repository", attributes: {...} }, - async (span) => { - try { - const result = await /* payload op */; - span.setAttribute("count", /* ... */); - return result; - } catch (err) { - this.logger.captureException(err, { - tags: { feature: "", repo: "", method: "" }, - }); - span.setStatus("error", err instanceof Error ? err.message : String(err)); - throw err; - } - }, -); -``` - -**Use case + controller spans (applied at DI bind time):** - -```ts -const wrappedUC = withSpan(tracer, { name: "blog.getArticles", op: "use-case" }, getArticlesUseCase(repo)); -const wrappedCtrl = withSpan(tracer, { name: "blog.getArticles", op: "controller" }, getArticlesController(wrappedUC)); -``` - -**Capture rules:** - -| Layer | Captures | Doesn't capture | -|---|---|---| -| Repository | Infra/Payload errors that originate here | Bubbled errors | -| Use case | Business-rule violations originated in this body | Errors from repos | -| Controller | InputParseError from safeParse failure | Anything else | -| `defineErrorMiddleware` | Nothing — maps domain → TRPCError only | — | - -**Boundary rule (eslint-enforced):** -Feature packages MUST NOT `import "@sentry/*"`. Allowlist: -- `packages/core-shared/src/instrumentation/sentry/**` -- `apps/*/instrumentation*.{ts,mjs,js}` -- `apps/*/next.config.{mjs,ts,js}` -- `apps/*/vite.config.{ts,mjs,js}` - -**Test rules:** -- Default to `NoopTracer` / `NoopLogger` (constructor defaults). -- Assert spans/captures by injecting `RecordingTracer` / `RecordingLogger` from `@repo/core-testing/instrumentation`. -- Real `@sentry/*` SDK MUST NOT initialize during tests (guarded by `core-testing/setup/no-sentry.ts`). -``` - -- [ ] **Step 3: Update vertical-feature-spec.md** - -Open `docs/architecture/vertical-feature-spec.md` and append a new section: - -```markdown -## §10 — Instrumentation & error capture - -**Spec:** `docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md` (Plan 10, R31–R55). - -**File additions per feature:** - -- `infrastructure/repositories/.repository.ts` — constructor takes `(config, tracer, logger)` with Noop defaults; every public method's body is wrapped in `tracer.startSpan(...)` and any `catch` block calls `logger.captureException(err, { tags: { feature, repo, method } })` before re-throwing. -- `infrastructure/repositories/.repository.mock.ts` — same constructor/wrapping shape (no catch — mocks don't originate infra errors). -- `di/bind-production.ts` — signature `(config, tracer, logger)`. Binds TRACER + LOGGER to the feature container; constructs the real repo with tracer/logger; wraps every use case + controller via `withSpan` at bind time. -- `di/bind-dev-seed.ts` — signature `(tracer, logger)`. Same wrapping as bind-production but with the populated mock. - -**Required exports (per feature root):** unchanged. - -**Public surface impact:** none for `./` (contracts) and `./ui`. The `./di/bind-production` and `./di/bind-dev-seed` subpaths now have new signatures — any consumer outside the app dispatcher is unaffected (the dispatcher is the only consumer per ADR-008). - -**Test patterns:** -- **Direct injection** of `RecordingTracer` / `RecordingLogger` from `@repo/core-testing/instrumentation` for span/capture assertions. -- **Contract suite span assertions** — every repo's contract suite (`__contracts__/*-repository.contract.ts`) includes a `span emission (R50)` describe block enumerating one assertion per method. - -**Tradeoff:** every public repo method gains ~6 lines of `tracer.startSpan(...)` boilerplate. Worth the per-method visibility in production traces; if it ever proves excessive, a `withRepoSpan` helper can collapse the wrapping. -``` - -- [ ] **Step 4: Commit** - -```bash -git add CLAUDE.md AGENTS.md docs/architecture/vertical-feature-spec.md -git commit -m "docs: instrumentation conventions in CLAUDE.md / AGENTS.md / vertical-feature-spec.md" -``` - ---- - -### Task 31: Doc updates — tdd-workflow.md, testing-strategy.md, dependency-flow.md, core-shared/AGENTS.md - -**Files:** -- Modify: `docs/guides/tdd-workflow.md` -- Modify: `docs/guides/testing-strategy.md` -- Modify: `docs/architecture/dependency-flow.md` -- Create or Modify: `packages/core-shared/AGENTS.md` - -- [ ] **Step 1: Update tdd-workflow.md** - -Append a section "Asserting spans and captures": - -```markdown -## Asserting spans and captures - -Use cases, controllers, and repositories emit OpenTelemetry-style spans through the `ITracer` interface. Tests that need to assert span shape inject a `RecordingTracer`: - -```ts -import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation"; -import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock"; -import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case"; - -describe("blog.getArticles use case", () => { - it("emits a use-case span when invoked", async () => { - const tracer = new RecordingTracer(); - const logger = new RecordingLogger(); - const repo = new MockArticlesRepository(tracer, logger); - // Use cases are wrapped at DI bind time; for direct-injection tests, - // wrap inline: - const wrapped = withSpan(tracer, { name: "blog.getArticles", op: "use-case" }, getArticlesUseCase(repo)); - await wrapped({ limit: 10 }); - expect(tracer.findSpan("blog.getArticles")?.op).toBe("use-case"); - expect(tracer.findSpan("articles.getArticles")?.op).toBe("repository"); - }); -}); -``` - -**Capture assertions** use `RecordingLogger`: - -```ts -const logger = new RecordingLogger(); -const repo = new MockArticlesRepository(tracer, logger); -// Force an infra error in your test setup, then: -expect(logger.captures).toHaveLength(1); -expect(logger.captures[0]).toMatchObject({ - kind: "exception", - ctx: { tags: { feature: "blog", repo: "articles", method: "getArticles" } }, -}); -``` - -**Default mocks** (when you don't need assertions): construct `new MockArticlesRepository()` with no args — constructor defaults bind `NoopTracer` + `NoopLogger`. -``` - -- [ ] **Step 2: Update testing-strategy.md** - -Append a section after the existing factory/contract content: - -```markdown -## R49 / R50 — Instrumentation testing - -**R49 — No real Sentry in tests.** The `core-testing/setup/no-sentry.ts` guard mocks `@sentry/nextjs`, `@sentry/node`, and `@sentry/react` at the module level, so any code that imports them gets a no-op surface during vitest runs. Tests that want to assert Sentry SDK calls add their own `vi.mock(...)` per file. - -**R50 — Repository contracts assert span shape.** Every `__contracts__/-repository.contract.ts` includes a `span emission (R50)` describe block enumerating one assertion per public method. Suites run against both mock and real (Payload-backed) implementations, ensuring span emission stays in sync. Wire the recording tracer at the call site: - -```ts -const tracer = new RecordingTracer(); -articlesRepositoryContract.run( - () => new MockArticlesRepository(tracer), - { tracer: () => tracer }, -); -``` - -**Capture vs span assertions:** -- `RecordingTracer.spans` — every span emitted with `{ name, op, attributes, status, durationMs }`. -- `RecordingLogger.captures` — every `captureException` / `captureMessage` call. -- `RecordingLogger.breadcrumbs` — every breadcrumb added. -- `RecordingLogger.users` — every `setUser` call (history). - -**Test cleanup:** call `tracer.reset()` and `logger.reset()` in `beforeEach` if the test creates one shared instance across multiple cases. -``` - -- [ ] **Step 3: Update dependency-flow.md** - -Add a new "TRACER / LOGGER" subsection after the existing per-feature DI graph, with this content: - -```markdown -### TRACER / LOGGER (Plan 10) - -The instrumentation layer is **per-feature container** but **app-wide instance**: each feature container binds `INSTRUMENTATION_SYMBOLS.TRACER` and `INSTRUMENTATION_SYMBOLS.LOGGER` to the SAME instance, constructed once by the app's `bindAll()` dispatcher (Rule 0). - -``` -apps/web-next/src/server/bind-production.ts (bindAll) - │ - ├─ Rule 0: WEB_NEXT_SENTRY_DSN set? - │ yes → bindSentryInstrumentation(sharedContainer, { dsn, app: "web-next" }) - │ no → bindNoopInstrumentation(sharedContainer) - │ ↓ - │ tracer + logger instances - │ ↓ - ├─ bindProductionBlog(config, tracer, logger) - │ │ - │ ├─ blogContainer.bind(TRACER).toConstantValue(tracer) - │ ├─ blogContainer.bind(LOGGER).toConstantValue(logger) - │ ├─ ArticlesRepository(config, tracer, logger) → bound to IArticlesRepository - │ ├─ withSpan(tracer, ...) wraps every use case → bound to UseCase symbol - │ └─ withSpan(tracer, ...) wraps every controller → bound to Controller symbol - │ - └─ (same for auth, marketing-pages, navigation, media) -``` - -**Why per-feature container also gets the binding:** lets internal DI-resolved code in a feature pull TRACER/LOGGER without going through the app dispatcher. In practice, only repository classes and feature-internal services would ever use this — controllers and use cases receive instrumentation via the bind-time wrapper. - -**Why the shared container exists at all:** isolates Rule 0 resolution from feature containers. Feature containers don't need to know if Sentry is on or off — they just receive an `ITracer` instance. -``` - -- [ ] **Step 4: Update or create core-shared/AGENTS.md** - -Open `packages/core-shared/AGENTS.md` (create if missing). Add a new section: - -```markdown -## src/instrumentation/ - -**Two interfaces:** `ITracer` (in `tracer.interface.ts`) and `ILogger` (in `logger.interface.ts`). - -**Three implementation pairs:** -- `NoopTracer` / `NoopLogger` — pass-through. Default everywhere. -- `SentryTracer` / `SentryLogger` — adapters over `@sentry/nextjs`. Live in `sentry/` subfolder. **The `sentry/` subfolder is the only path in `packages/` permitted to import `@sentry/*`** (R40). -- `RecordingTracer` / `RecordingLogger` — in `@repo/core-testing/instrumentation`, not here. - -**`with-span.ts`:** higher-order helper used at DI binding time to wrap use case + controller factory results in a span. Pattern: - -```ts -const wrapped = withSpan(tracer, { name: "blog.getArticles", op: "use-case" }, factory(deps)); -``` - -**Symbols:** `INSTRUMENTATION_SYMBOLS.TRACER`, `INSTRUMENTATION_SYMBOLS.LOGGER` (both `Symbol.for(...)` so cross-realm equality holds). - -**`sentry/scrub.ts`:** PII scrubbers used by every `Sentry.init()` call across the monorepo. Substring-based key matching catches derived names (`userEmail`, `accessToken`, `apiKey`). IPv4/IPv6 are redacted from string values. - -**`sentry/init-server.ts` + `init-client.ts`:** centralized init helpers that hard-code R31 (`sendDefaultPii: false`), R32/R33 (scrubbers), R34/R35 (replay mask flags), R37 (sample-rate defaults). Apps call these from `instrumentation.ts` / `instrumentation-client.ts`. - -**`sentry/init-server-node.ts` + `init-client-react.ts`:** Vite/non-Next variants used by `apps/web-tanstack`. - -**`di/bind-noop-instrumentation.ts` + `bind-sentry-instrumentation.ts`:** bind TRACER + LOGGER symbols to a Container. Returns the resolved instances so callers can use them without container lookup. - -**Boundaries:** -- `core-shared/instrumentation/sentry/**` MAY import from `@sentry/*`. -- Everything else in `packages/core-shared/src/` MUST NOT. -- The eslint rule in `core-eslint/base.js` enforces the broader monorepo boundary (R40). -``` - -- [ ] **Step 5: Commit** - -```bash -git add docs/guides/tdd-workflow.md \ - docs/guides/testing-strategy.md \ - docs/architecture/dependency-flow.md \ - packages/core-shared/AGENTS.md -git commit -m "docs: instrumentation testing patterns + dependency-flow + core-shared AGENTS update" -``` - ---- - -### Task 32: HTML updates — data-flow-explainer §07 + di-explainer additions - -**Files:** -- Modify: `docs/architecture/data-flow-explainer.html` -- Modify: `docs/architecture/di-explainer.html` - -> **Aesthetic constraint:** match the existing editorial-cream-paper / oxblood aesthetic. Don't introduce new fonts. Reuse the section-num + grid + tag patterns already in the page. - -- [ ] **Step 1: Add §07 to data-flow-explainer.html** - -Open `docs/architecture/data-flow-explainer.html`. Find the contents nav (the 6-column grid with §01-§06) and: - -a) Update the grid template to 7 columns and add the new entry: -```html -07. Tracing & error capture -``` - -b) Renumber the verdict from §06 to §07. - -c) Insert a new section between §05 (Tradeoffs by part) and the renumbered §07 (verdict): - -```html - -
-
§ 06
-

Tracing & error capture

-

- Every request produces a nested span tree: tRPC procedure → controller → - use case → repository → Payload op. Errors are captured at the throw site - closest to the cause, never at the boundary that translates them. -

- -

The trace tree (one tRPC request)

-
HTTP transaction               (auto, @sentry/nextjs)
-└── tRPC procedure span        (auto, sentry trpc integration)
-    └── controller span         (op="controller", DI-wrapped)
-        └── use-case span       (op="use-case", DI-wrapped)
-            └── repository span (op="repository", explicit startSpan)
-                └── Payload Local API call (auto, @sentry/node http)
- -

Capture rules (where Sentry.captureException fires)

- - - - - - - - - - - - - - - - - - - - - - - - - - -
LayerCapturesDoesn't capture
RepositoryInfra / Payload errors that originate hereBubbled errors
Use caseBusiness-rule violations originated in this bodyErrors from repos (already captured)
ControllerInputParseError from safeParse failureAnything else
defineErrorMiddlewareNothing — maps domain → TRPCError only
- -

Double-report guard

-

- Every error captured by SentryLogger.captureException gets a - non-enumerable __sentryReported = true property. A second - capture call for the same error returns early. This means each error - surfaces in Sentry exactly once, regardless of how many layers it passes - through. -

- -

PII rules (R31–R38, non-negotiable)

-
    -
  • sendDefaultPii: false — every Sentry.init(). Build-time grep gate.
  • -
  • Replay default-masks all text + inputs + media. Allowlist starts empty.
  • -
  • beforeSend scrubber strips email / password / token / cookie / authorization keys (substring match).
  • -
  • beforeSendTransaction scrubber strips PII query params from URLs.
  • -
  • setUser accepts only { id }. Stripping wrapper warns in dev when other keys passed.
  • -
  • IPv4/IPv6 in event payload values redacted to [redacted-ip].
  • -
-
-``` - -> **Note:** also renumber the existing verdict section's `
§ 06
` → `§ 07`. - -d) Add minimal CSS to support the new elements (insert in the existing `