docs(dev-seed): canonical doc updates + refactor-log entry
- 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.
This commit is contained in:
24
AGENTS.md
24
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/<feature>` (root) and React Query builders from `@repo/<feature>/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<void> {
|
||||
if (process.env.USE_DEV_SEED === "true") return bindAllDevSeed();
|
||||
if (process.env.NODE_ENV === "production") return bindAllProduction();
|
||||
return bindAllDevSeed();
|
||||
}
|
||||
|
||||
export async function bindAllProduction(): Promise<void> {
|
||||
const resolvedConfig = await config;
|
||||
bindProductionAuth(resolvedConfig);
|
||||
|
||||
@@ -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/<feature>/ui`, schemas/types from `@repo/<feature>`
|
||||
- **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<Entities>()` 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
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1338,7 +1338,8 @@ footer .colophon {
|
||||
<span class="tree-row" data-layer="di"> <span class="dim">├─</span> symbols.ts<span class="dim"> ← inversify Symbol.for(...) keys</span></span>
|
||||
<span class="tree-row" data-layer="di"> <span class="dim">├─</span> module.ts<span class="dim"> ← ContainerModule with .toDynamicValue</span></span>
|
||||
<span class="tree-row" data-layer="di"> <span class="dim">├─</span> container.ts<span class="dim"> ← Container + .load(Module)</span></span>
|
||||
<span class="tree-row" data-layer="di"> <span class="dim">└─</span> bind-production.ts<span class="dim"> ← swaps mocks → real impls at boot</span></span>
|
||||
<span class="tree-row" data-layer="di"> <span class="dim">├─</span> bind-production.ts<span class="dim"> ← swaps mocks → real impls at boot</span></span>
|
||||
<span class="tree-row" data-layer="di"> <span class="dim">└─</span> bind-dev-seed.ts<span class="dim"> ← swaps empty mocks → populated mocks</span></span>
|
||||
<span class="tree-row" data-layer="integrations"><span class="dim">├─</span> integrations/</span>
|
||||
<span class="tree-row" data-layer="integrations"> <span class="dim">├─</span> api/</span>
|
||||
<span class="tree-row" data-layer="integrations"> <span class="dim">│ ├─</span> procedures.ts<span class="dim"> ← xProcedure + defineErrorMiddleware</span></span>
|
||||
@@ -1349,6 +1350,7 @@ footer .colophon {
|
||||
<span class="tree-row" data-layer="ui"> <span class="dim">└─</span> query.ts<span class="dim"> ← React Query option builders</span></span>
|
||||
<span class="tree-row" data-layer="testing"><span class="dim">├─</span> __factories__/<span class="dim"> ← defineFactory<Entity>((seq)=>{...})</span></span>
|
||||
<span class="tree-row" data-layer="testing"><span class="dim">├─</span> __contracts__/<span class="dim"> ← defineContractSuite<IRepo>(...)</span></span>
|
||||
<span class="tree-row" data-layer="seeds"><span class="dim">├─</span> __seeds__/<span class="dim"> ← buildDev<Entities>() — dev-mode realistic data</span></span>
|
||||
<span class="tree-row" data-layer="public"><span class="dim">└─</span> index.ts<span class="dim"> ← root: contracts only</span></span>
|
||||
</div>
|
||||
|
||||
@@ -1854,8 +1856,14 @@ const LAYERS = {
|
||||
di: {
|
||||
tag: "wiring",
|
||||
title: "DI — <em>per-feature</em> container",
|
||||
body: "Symbols are <code>Symbol.for(\"<feature>:I<X>\")</code> keys. The <code>ContainerModule</code> wires them: the repository to its mock by default; use cases and controllers via <code>.toDynamicValue</code> so their factory functions are constructed on every <code>container.get()</code>. <code>bind-production.ts</code> swaps the mock for the real impl at app boot using <code>.toConstantValue</code>.",
|
||||
meta: "Files · symbols.ts · module.ts · container.ts · bind-production.ts"
|
||||
body: "Symbols are <code>Symbol.for(\"<feature>:I<X>\")</code> keys. The <code>ContainerModule</code> wires them: the repository to its mock by default; use cases and controllers via <code>.toDynamicValue</code> so their factory functions are constructed on every <code>container.get()</code>. Three binders sit alongside: <code>bind-production.ts</code> swaps the mock for the real Payload impl, <code>bind-dev-seed.ts</code> swaps the empty mock for a populated one. App boot's <code>bindAll()</code> picks one based on <code>USE_DEV_SEED</code> + <code>NODE_ENV</code>. See the dedicated <a href=\"di-explainer.html\">DI explainer page</a> for the full lifecycle.",
|
||||
meta: "Files · symbols.ts · module.ts · container.ts · bind-production.ts · bind-dev-seed.ts"
|
||||
},
|
||||
seeds: {
|
||||
tag: "dev ergonomics",
|
||||
title: "<em>__seeds__/</em> — dev-mode data",
|
||||
body: "A lazy <code>buildDev<Entities>()</code> function per feature, built on top of the feature's existing factory. The dev-seed binder calls it to populate a <code>MockXRepository</code> at app boot when <code>USE_DEV_SEED=true</code> or <code>NODE_ENV ≠ 'production'</code>. 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: "<em>index.ts</em> — the public surface",
|
||||
body: "Five subpaths per feature: <code>.</code> (contracts: types, errors, schemas, IUseCase / IController aliases, router type, constants), <code>./ui</code> (queries + components), <code>./api</code> (the tRPC router — only <code>core-api</code> consumes it), <code>./cms</code> (Payload collections — only <code>core-cms</code> consumes), <code>./di/bind-production</code> (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: <code>.</code> (contracts: types, errors, schemas, IUseCase / IController aliases, router type, constants), <code>./ui</code> (queries + components), <code>./api</code> (the tRPC router — only <code>core-api</code> consumes it), <code>./cms</code> (Payload collections — only <code>core-cms</code> consumes), <code>./di/bind-production</code> (production binder, called by app boot), <code>./di/bind-dev-seed</code> (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"
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<Entities>() — 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"]
|
||||
```
|
||||
|
||||
@@ -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<Entities>()` 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<Feature>()` 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).
|
||||
|
||||
Reference in New Issue
Block a user