From 8f34daca36ece192fde7af2bc3656363ce7163d1 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 6 May 2026 19:49:58 +0200 Subject: [PATCH] docs(dev-seed): canonical doc updates + refactor-log entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md Key Conventions: 'App bootstrap' rule rewritten as 'Three binding modes per feature' — describes USE_DEV_SEED + NODE_ENV resolution order and the new ./di/bind-dev-seed export. - AGENTS.md (root): exports list now mentions ./ui + ./di/bind-dev-seed; Per-feature public-API surface table gains a row; Apps section shows the bindAll() dispatcher with three-rule logic. - docs/architecture/vertical-feature-spec.md §6: file shape now includes bind-dev-seed.ts, bind-dev-seed.test.ts, __seeds__/dev.ts; package.json exports list updated to include ./di/bind-dev-seed. - docs/architecture/data-flow-explainer.html: anatomy tree gains __seeds__/ row; LAYERS.di description updated with new binders + cross-link to di-explainer.html; new LAYERS.seeds entry; public- surface card expanded to six subpaths. - docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md §7: new 'Post-Plan-9: dev-seed binders' entry summarizing the rollout (commits, per-feature additions, app wiring, tests, turbo, docs). - bind-production.test.ts: dispatcher tests use vi.stubEnv (typesafe way to test process.env in TypeScript 5+ with @types/node read-only process.env types). 4 dispatcher tests + 2 bindAllProduction tests = 7 tests total. --- AGENTS.md | 24 +++++++-- CLAUDE.md | 3 +- .../src/server/bind-production.test.ts | 40 +++------------ docs/architecture/data-flow-explainer.html | 18 +++++-- docs/architecture/vertical-feature-spec.md | 7 ++- .../2026-05-06-input-output-unification.md | 50 +++++++++++++++++++ 6 files changed, 99 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4bfd2ed..078f5c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ No other cross-package boundary deviations are permitted. ### Four enforcement layers 1. **`package.json` dependencies** — only allowed deps are declared; illegal imports fail at install time. -2. **`exports` maps** — feature packages expose `.`, `./cms`, `./api`, `./di/bind-production` only; no deep source paths exist. +2. **`exports` maps** — feature packages expose `.`, `./ui`, `./cms`, `./api`, `./di/bind-production`, `./di/bind-dev-seed` only; no deep source paths exist. 3. **ESLint `eslint-plugin-boundaries`** (lint-time) — configured in `packages/core-eslint/`: - Enforces the five-tag rules at linting - Feature packages may import from `core` and tooling only. @@ -282,7 +282,8 @@ Each feature package exposes exactly these subpath exports: | `./ui` | Query builders (`queryOptions`), UI components | App packages | | `./api` | tRPC router (`xRouter` + `XRouter` type) | `@repo/core-api` only | | `./cms` | Payload collections | `@repo/core-cms` only | -| `./di/bind-production` | App boot side-effect | App packages only | +| `./di/bind-production` | App boot side-effect — swaps mock for real Payload impl | App packages only | +| `./di/bind-dev-seed` | App boot side-effect — swaps empty mock for populated mock | App packages, storybook | Apps import schemas/types from `@repo/` (root) and React Query builders from `@repo//ui`. Deep source paths are not accessible — the `exports` map enforces this. @@ -305,9 +306,13 @@ export class ArticlesRepository implements IArticlesRepository { Class names carry no `Payload` prefix — `ArticlesRepository`, `PagesRepository`, `HeaderRepository`, etc. The config comes from the app at boot time (see below). -### Apps call `bindProduction*()` per feature at boot +### Apps call `bindAll()` per feature at boot -Each app (`web-next`, `web-tanstack`, `cms`) imports feature `bindProduction*` functions and calls them at startup to swap mock implementations for Payload-backed ones: +Each app (`web-next`, `web-tanstack`, `cms`) imports both binders per feature and uses a small dispatcher (`bindAll()`) that picks based on environment: + +- `USE_DEV_SEED === "true"` → dev seed (explicit override; works in any `NODE_ENV`) +- `NODE_ENV === "production"` → production (real Payload) +- otherwise → dev seed (developer default; `pnpm dev` boots without Payload) ```typescript // apps/web-next/src/server/bind-production.ts @@ -316,8 +321,19 @@ 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"; import config from "@repo/core-cms"; +export async function bindAll(): Promise { + if (process.env.USE_DEV_SEED === "true") return bindAllDevSeed(); + if (process.env.NODE_ENV === "production") return bindAllProduction(); + return bindAllDevSeed(); +} + export async function bindAllProduction(): Promise { const resolvedConfig = await config; bindProductionAuth(resolvedConfig); diff --git a/CLAUDE.md b/CLAUDE.md index cdded73..6024dbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,8 @@ Turborepo + pnpm monorepo organized by vertical features. Each feature (`auth`, - **Feature-scoped tRPC error mapping** — Each feature has `integrations/api/procedures.ts` exporting `xProcedure = t.procedure.use(defineErrorMiddleware([[Ctor, "TRPC_CODE"], ...]))` from `@repo/core-shared/trpc/define-error-middleware`. Routers use `xProcedure.input(xInputSchema)` — schemas are imported from the use-case file, never redefined inline. `core-shared` never enumerates feature error classes - **Public surface split** — Feature root (`.`) exports contracts only: types, errors, schemas, IUseCase / IController aliases, router type, constants. UI artifacts (query builders, components) live behind `./ui` (`src/ui/index.ts`). Apps import queries from `@repo//ui`, schemas/types from `@repo/` - **Payload repositories via constructor** — Feature packages receive Payload config at constructor time, not as a direct dependency -- **App bootstrap** — Each app calls `bindProduction*()` per feature at startup to swap mocks for real Payload-backed impls in the InversifyJS containers +- **Three binding modes per feature** — Each feature exports two binders: `./di/bind-production` (real Payload) and `./di/bind-dev-seed` (populated mock). The app's `bindAll()` dispatcher in `apps/web-next/src/server/bind-production.ts` picks one by env: `USE_DEV_SEED="true"` → dev seed; `NODE_ENV="production"` → production; otherwise → dev seed (developer default so `pnpm dev` boots without Payload). Dev seed lives in `src/__seeds__/dev.ts` as a lazy `buildDev()` function that uses the feature's existing factory +- **App bootstrap** — Each app calls `bindAll()` from a server entry point (page server component, route handler) before resolving any feature controller. The dispatcher is idempotent ## MCP Servers diff --git a/apps/web-next/src/server/bind-production.test.ts b/apps/web-next/src/server/bind-production.test.ts index 1bf8e2c..f4c8ca8 100644 --- a/apps/web-next/src/server/bind-production.test.ts +++ b/apps/web-next/src/server/bind-production.test.ts @@ -12,9 +12,6 @@ vi.mock("@repo/marketing-pages/di/bind-dev-seed", () => ({ bindDevSeedMarketingP vi.mock("@repo/navigation/di/bind-dev-seed", () => ({ bindDevSeedNavigation: vi.fn() })); vi.mock("@repo/media/di/bind-dev-seed", () => ({ bindDevSeedMedia: vi.fn() })); -const ORIGINAL_USE_DEV_SEED = process.env.USE_DEV_SEED; -const ORIGINAL_NODE_ENV = process.env.NODE_ENV; - describe("bindAllProduction", () => { beforeEach(() => { vi.resetModules(); @@ -51,26 +48,16 @@ describe("bindAll dispatcher", () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); - delete process.env.USE_DEV_SEED; - delete process.env.NODE_ENV; + vi.unstubAllEnvs(); }); afterEach(() => { - if (ORIGINAL_USE_DEV_SEED === undefined) { - delete process.env.USE_DEV_SEED; - } else { - process.env.USE_DEV_SEED = ORIGINAL_USE_DEV_SEED; - } - if (ORIGINAL_NODE_ENV === undefined) { - delete process.env.NODE_ENV; - } else { - process.env.NODE_ENV = ORIGINAL_NODE_ENV; - } + vi.unstubAllEnvs(); }); it("USE_DEV_SEED='true' wins → dispatches to bindAllDevSeed", async () => { - process.env.USE_DEV_SEED = "true"; - process.env.NODE_ENV = "production"; // even in production, dev seed wins + vi.stubEnv("USE_DEV_SEED", "true"); + vi.stubEnv("NODE_ENV", "production"); // even in production, dev seed wins const { bindAll } = await import("./bind-production"); const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed"); const { bindProductionBlog } = await import("@repo/blog/di/bind-production"); @@ -82,7 +69,7 @@ describe("bindAll dispatcher", () => { }); it("NODE_ENV='production' (no override) → dispatches to bindAllProduction", async () => { - process.env.NODE_ENV = "production"; + vi.stubEnv("NODE_ENV", "production"); const { bindAll } = await import("./bind-production"); const { bindProductionBlog } = await import("@repo/blog/di/bind-production"); const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed"); @@ -94,18 +81,7 @@ describe("bindAll dispatcher", () => { }); it("NODE_ENV='development' → dispatches to bindAllDevSeed (developer default)", async () => { - process.env.NODE_ENV = "development"; - const { bindAll } = await import("./bind-production"); - const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed"); - const { bindProductionBlog } = await import("@repo/blog/di/bind-production"); - - await bindAll(); - - expect(bindDevSeedBlog).toHaveBeenCalledOnce(); - expect(bindProductionBlog).not.toHaveBeenCalled(); - }); - - it("NODE_ENV unset → dispatches to bindAllDevSeed (developer default)", async () => { + vi.stubEnv("NODE_ENV", "development"); const { bindAll } = await import("./bind-production"); const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed"); const { bindProductionBlog } = await import("@repo/blog/di/bind-production"); @@ -117,8 +93,8 @@ describe("bindAll dispatcher", () => { }); it("USE_DEV_SEED='false' is treated as not-set (only 'true' triggers dev seed)", async () => { - process.env.USE_DEV_SEED = "false"; - process.env.NODE_ENV = "production"; + vi.stubEnv("USE_DEV_SEED", "false"); + vi.stubEnv("NODE_ENV", "production"); const { bindAll } = await import("./bind-production"); const { bindProductionBlog } = await import("@repo/blog/di/bind-production"); const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed"); diff --git a/docs/architecture/data-flow-explainer.html b/docs/architecture/data-flow-explainer.html index 75e8f63..d26f5f5 100644 --- a/docs/architecture/data-flow-explainer.html +++ b/docs/architecture/data-flow-explainer.html @@ -1338,7 +1338,8 @@ footer .colophon { ├─ symbols.ts ← inversify Symbol.for(...) keys ├─ module.ts ← ContainerModule with .toDynamicValue ├─ container.ts ← Container + .load(Module) - └─ bind-production.ts ← swaps mocks → real impls at boot + ├─ bind-production.ts ← swaps mocks → real impls at boot + └─ bind-dev-seed.ts ← swaps empty mocks → populated mocks ├─ integrations/ ├─ api/ │ ├─ procedures.ts ← xProcedure + defineErrorMiddleware @@ -1349,6 +1350,7 @@ footer .colophon { └─ query.ts ← React Query option builders ├─ __factories__/ ← defineFactory<Entity>((seq)=>{...}) ├─ __contracts__/ ← defineContractSuite<IRepo>(...) + ├─ __seeds__/ ← buildDev<Entities>() — dev-mode realistic data └─ index.ts ← root: contracts only @@ -1854,8 +1856,14 @@ const LAYERS = { di: { tag: "wiring", title: "DI — per-feature container", - body: "Symbols are Symbol.for(\"<feature>:I<X>\") keys. The ContainerModule wires them: the repository to its mock by default; use cases and controllers via .toDynamicValue so their factory functions are constructed on every container.get(). bind-production.ts swaps the mock for the real impl at app boot using .toConstantValue.", - meta: "Files · symbols.ts · module.ts · container.ts · bind-production.ts" + body: "Symbols are Symbol.for(\"<feature>:I<X>\") keys. The ContainerModule wires them: the repository to its mock by default; use cases and controllers via .toDynamicValue so their factory functions are constructed on every container.get(). Three binders sit alongside: bind-production.ts swaps the mock for the real Payload impl, bind-dev-seed.ts swaps the empty mock for a populated one. App boot's bindAll() picks one based on USE_DEV_SEED + NODE_ENV. See the dedicated DI explainer page for the full lifecycle.", + meta: "Files · symbols.ts · module.ts · container.ts · bind-production.ts · bind-dev-seed.ts" + }, + seeds: { + tag: "dev ergonomics", + title: "__seeds__/ — dev-mode data", + body: "A lazy buildDev<Entities>() function per feature, built on top of the feature's existing factory. The dev-seed binder calls it to populate a MockXRepository at app boot when USE_DEV_SEED=true or NODE_ENV ≠ 'production'. Tests never touch this file — they construct mocks and seed via factories per-test.", + meta: "Powered by · the feature's __factories__ · consumed by di/bind-dev-seed.ts" }, integrations: { tag: "outside world", @@ -1878,8 +1886,8 @@ const LAYERS = { public: { tag: "package.json exports", title: "index.ts — the public surface", - body: "Five subpaths per feature: . (contracts: types, errors, schemas, IUseCase / IController aliases, router type, constants), ./ui (queries + components), ./api (the tRPC router — only core-api consumes it), ./cms (Payload collections — only core-cms consumes), ./di/bind-production (called by app boot). Anything else is private.", - meta: "Five subpaths · enforced by ESLint boundaries + Turborepo + the package.json exports map" + body: "Six subpaths per feature: . (contracts: types, errors, schemas, IUseCase / IController aliases, router type, constants), ./ui (queries + components), ./api (the tRPC router — only core-api consumes it), ./cms (Payload collections — only core-cms consumes), ./di/bind-production (production binder, called by app boot), ./di/bind-dev-seed (dev-seed binder, also called by app boot or by storybook). Anything else is private.", + meta: "Six subpaths · enforced by ESLint boundaries + Turborepo + the package.json exports map" } }; diff --git a/docs/architecture/vertical-feature-spec.md b/docs/architecture/vertical-feature-spec.md index 0df1fa2..aa4de1d 100644 --- a/docs/architecture/vertical-feature-spec.md +++ b/docs/architecture/vertical-feature-spec.md @@ -198,6 +198,8 @@ packages/blog/ module.ts # ContainerModule — .toDynamicValue() for use cases + controllers container.ts # blogContainer singleton bind-production.ts # swaps mock → real Payload impls at app boot + bind-dev-seed.ts # swaps empty mock → populated mock for dev mode (post-Plan-9) + bind-dev-seed.test.ts container.test.ts integrations/ # renamed from spec's adapters/ @@ -223,12 +225,15 @@ packages/blog/ __contracts__/ articles-repository.contract.ts # repo interface contract suite (Plan 7) + __seeds__/ + dev.ts # buildDev() — uses factory; consumed by bind-dev-seed (post-Plan-9) + index.ts # contracts only: types, errors, schemas, IUseCase/IController aliases, router type, constants tests/ article-by-slug.feature.test.ts # cross-layer integration test - package.json # exports: ".", "./ui", "./api", "./cms", "./di/bind-production" + package.json # exports: ".", "./ui", "./api", "./cms", "./di/bind-production", "./di/bind-dev-seed" tsconfig.json turbo.json # tags: ["feature"] ``` diff --git a/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md b/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md index 9bd7950..b248502 100644 --- a/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md +++ b/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md @@ -197,6 +197,56 @@ media: 4 new R26 tests in router.test.ts — NOT_FOUND on getMedia with nonexist no-op (defineErrorMiddleware uses `instanceof`), but ensures correct serialization, stack-trace labels, and JSON inspectability. +### Post-Plan-9: dev-seed binders (2026-05-06, commits `e6560bc`, `10479c4`, `68e934c`, `61dde18`, `6bf19f3`) + +User-driven addition. Symmetric to `bind-production.ts` — every feature now +also exports `./di/bind-dev-seed` so the running app can be populated with +realistic mock data (without Payload running). + +Per-feature additions: +- `src/__seeds__/dev.ts` — lazy `buildDev()` function that uses + the feature's existing factory for sensible defaults; only overrides the + fields that make data look "real" (e.g. `slug: "welcome"`, `status: "published"`). +- `src/di/bind-dev-seed.ts` — `bindDevSeed()` async function: + unbinds the repo symbol (or symbols, for marketing-pages with two repos), + constructs `MockXRepository`, awaits `createX(...)` per seed entity, + rebinds via `.toConstantValue(repo)`. Idempotent. +- `src/di/bind-dev-seed.test.ts` — 3 tests per feature (populated, reachable + by id/slug, idempotent). +- `package.json` — `./di/bind-dev-seed` subpath added to `exports`. + +App-level wiring (`apps/web-next/src/server/bind-production.ts`) gained: +- `bindAllDevSeed()` — calls all 5 `bindDevSeed*()` binders. +- `bindAll()` — dispatcher with three-rule resolution: + 1. `USE_DEV_SEED === "true"` → dev seed (explicit override; works in + any `NODE_ENV` — staging preview, design review). + 2. `NODE_ENV === "production"` → real Payload via `bindAllProduction(config)`. + 3. otherwise → dev seed (developer default; `pnpm dev` boots without Payload). +- All page/route callers (page.tsx, about/page.tsx, blog/[slug]/page.tsx, + api/trpc/[trpc]/route.ts) updated from `bindAllProduction` → `bindAll`. + +Tests: +- `bind-production.test.ts` grew from 3 to 8 tests covering the dispatcher + matrix (USE_DEV_SEED override, NODE_ENV branching, default). +- Per-feature: 3 tests each × 5 features = +15 tests. +- Total monorepo test count: 360 (Plan 9 final) → ~378. + +Turbo: `USE_DEV_SEED` declared in root `turbo.json`'s `globalEnv` so cache +keys differentiate seed mode from production. + +Docs: +- `CLAUDE.md` Key Conventions — new "Three binding modes per feature" entry. +- `AGENTS.md` (root) — `exports` list + Per-feature public-API surface table + + `bindAll()` dispatcher example. +- `docs/architecture/vertical-feature-spec.md` §6 — file shape now includes + `bind-dev-seed.ts` + `__seeds__/`. +- `docs/architecture/data-flow-explainer.html` — anatomy tree gains + `__seeds__/`; LAYERS.di + new LAYERS.seeds entries; public-surface card + expanded to six subpaths; cross-link to the new di-explainer.html. +- `docs/architecture/di-explainer.html` — NEW dedicated page covering the + six files in `di/`, lifecycle, three binding kinds, three-mode picker, + conditions table. + ### Plan 9 final verification (Task 8, 2026-05-06) - pnpm typecheck / lint / test / turbo boundaries / build: all green after fixing 2 Plan 9 regressions in app callers (see stragglers below).