From 019d4866a017316b9b32bdabfabab2219bacf9d6 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Fri, 8 May 2026 01:09:22 +0200 Subject: [PATCH] feat(turbo): turbo gen feature generator (Phase 1, single-entity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `pnpm turbo gen feature` to scaffold a Lazar-conformant feature package matching the navigation reference shape: entity + Zod schema, single use case (`get`), controller, mock + Payload-stub real repository (with span + capture), DI module/container/symbols, and tRPC router with full BAD_REQUEST/NOT_FOUND error mapping. The generated `bind-production.ts` and `bind-dev-seed.ts` compose the post-R44 `withSpan(tracer, opts, withCapture(logger, tags, factory(deps)))` sandwich at bind time. Verified by generating a sample `packages/example/` feature and running `pnpm --filter @repo/example lint typecheck test` — all three pass (9 test files, 25 tests). Cleaned up after verification so no example package is committed. Phase-1 limitations (documented in `docs/guides/scaffolding-a-feature.md` and printed by the generator on success): no Payload CMS templates, no React Query helpers, faker-driven factories left as stubs, single entity / single use case, and aggregator wiring (core-api/root, apps/web-next bindAll) is left as a manual checklist. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/guides/scaffolding-a-feature.md | 101 ++++++ package.json | 2 + pnpm-lock.yaml | 307 ++++++++++++++++ turbo/generators/config.ts | 342 ++++++++++++++++++ .../templates/feature/AGENTS.md.hbs | 42 +++ .../templates/feature/eslint.config.js.hbs | 3 + .../templates/feature/package.json.hbs | 35 ++ .../entity-repository.contract.ts.hbs | 55 +++ .../src/__factories__/entity.factory.ts.hbs | 4 + .../feature/src/__factories__/index.ts.hbs | 3 + .../feature/src/__seeds__/dev.ts.hbs | 16 + .../entity.repository.interface.ts.hbs | 5 + .../use-cases/get-entity.use-case.test.ts.hbs | 31 ++ .../use-cases/get-entity.use-case.ts.hbs | 30 ++ .../feature/src/di/bind-dev-seed.test.ts.hbs | 56 +++ .../feature/src/di/bind-dev-seed.ts.hbs | 82 +++++ .../feature/src/di/bind-production.ts.hbs | 74 ++++ .../feature/src/di/container.test.ts.hbs | 40 ++ .../templates/feature/src/di/container.ts.hbs | 6 + .../templates/feature/src/di/module.ts.hbs | 39 ++ .../templates/feature/src/di/symbols.ts.hbs | 7 + .../feature/src/entities/errors/common.ts.hbs | 6 + .../feature/src/entities/errors/entity.ts.hbs | 6 + .../src/entities/models/entity.test.ts.hbs | 24 ++ .../feature/src/entities/models/entity.ts.hbs | 8 + .../templates/feature/src/index.ts.hbs | 16 + .../entity.repository.mock.test.ts.hbs | 15 + .../entity.repository.mock.ts.hbs | 45 +++ .../entity.repository.span.test.ts.hbs | 19 + .../entity.repository.test.ts.hbs | 34 ++ .../repositories/entity.repository.ts.hbs | 62 ++++ .../src/integrations/api/procedures.ts.hbs | 12 + .../src/integrations/api/router.test.ts.hbs | 93 +++++ .../src/integrations/api/router.ts.hbs | 22 ++ .../get-entity.controller.test.ts.hbs | 35 ++ .../controllers/get-entity.controller.ts.hbs | 23 ++ .../templates/feature/src/ui/index.ts.hbs | 4 + .../templates/feature/tsconfig.json.hbs | 14 + .../templates/feature/turbo.json.hbs | 4 + .../templates/feature/vitest.config.ts.hbs | 47 +++ 40 files changed, 1769 insertions(+) create mode 100644 docs/guides/scaffolding-a-feature.md create mode 100644 turbo/generators/config.ts create mode 100644 turbo/generators/templates/feature/AGENTS.md.hbs create mode 100644 turbo/generators/templates/feature/eslint.config.js.hbs create mode 100644 turbo/generators/templates/feature/package.json.hbs create mode 100644 turbo/generators/templates/feature/src/__contracts__/entity-repository.contract.ts.hbs create mode 100644 turbo/generators/templates/feature/src/__factories__/entity.factory.ts.hbs create mode 100644 turbo/generators/templates/feature/src/__factories__/index.ts.hbs create mode 100644 turbo/generators/templates/feature/src/__seeds__/dev.ts.hbs create mode 100644 turbo/generators/templates/feature/src/application/repositories/entity.repository.interface.ts.hbs create mode 100644 turbo/generators/templates/feature/src/application/use-cases/get-entity.use-case.test.ts.hbs create mode 100644 turbo/generators/templates/feature/src/application/use-cases/get-entity.use-case.ts.hbs create mode 100644 turbo/generators/templates/feature/src/di/bind-dev-seed.test.ts.hbs create mode 100644 turbo/generators/templates/feature/src/di/bind-dev-seed.ts.hbs create mode 100644 turbo/generators/templates/feature/src/di/bind-production.ts.hbs create mode 100644 turbo/generators/templates/feature/src/di/container.test.ts.hbs create mode 100644 turbo/generators/templates/feature/src/di/container.ts.hbs create mode 100644 turbo/generators/templates/feature/src/di/module.ts.hbs create mode 100644 turbo/generators/templates/feature/src/di/symbols.ts.hbs create mode 100644 turbo/generators/templates/feature/src/entities/errors/common.ts.hbs create mode 100644 turbo/generators/templates/feature/src/entities/errors/entity.ts.hbs create mode 100644 turbo/generators/templates/feature/src/entities/models/entity.test.ts.hbs create mode 100644 turbo/generators/templates/feature/src/entities/models/entity.ts.hbs create mode 100644 turbo/generators/templates/feature/src/index.ts.hbs create mode 100644 turbo/generators/templates/feature/src/infrastructure/repositories/entity.repository.mock.test.ts.hbs create mode 100644 turbo/generators/templates/feature/src/infrastructure/repositories/entity.repository.mock.ts.hbs create mode 100644 turbo/generators/templates/feature/src/infrastructure/repositories/entity.repository.span.test.ts.hbs create mode 100644 turbo/generators/templates/feature/src/infrastructure/repositories/entity.repository.test.ts.hbs create mode 100644 turbo/generators/templates/feature/src/infrastructure/repositories/entity.repository.ts.hbs create mode 100644 turbo/generators/templates/feature/src/integrations/api/procedures.ts.hbs create mode 100644 turbo/generators/templates/feature/src/integrations/api/router.test.ts.hbs create mode 100644 turbo/generators/templates/feature/src/integrations/api/router.ts.hbs create mode 100644 turbo/generators/templates/feature/src/interface-adapters/controllers/get-entity.controller.test.ts.hbs create mode 100644 turbo/generators/templates/feature/src/interface-adapters/controllers/get-entity.controller.ts.hbs create mode 100644 turbo/generators/templates/feature/src/ui/index.ts.hbs create mode 100644 turbo/generators/templates/feature/tsconfig.json.hbs create mode 100644 turbo/generators/templates/feature/turbo.json.hbs create mode 100644 turbo/generators/templates/feature/vitest.config.ts.hbs diff --git a/docs/guides/scaffolding-a-feature.md b/docs/guides/scaffolding-a-feature.md new file mode 100644 index 0000000..25c5856 --- /dev/null +++ b/docs/guides/scaffolding-a-feature.md @@ -0,0 +1,101 @@ +# Scaffolding a feature + +`turbo gen feature` produces a Lazar-conformant feature package under +`packages//` matching the shape of the reference `navigation` feature. + +## Invoking the generator + +Interactive (recommended for first runs — Plop will prompt for each value): + +```bash +pnpm turbo gen feature +``` + +Non-interactive (positional bypass — order matches the prompts in +`turbo/generators/config.ts`): + +```bash +pnpm turbo gen feature --args +``` + +| Position | Prompt | Example | Conventions | +|---|---|---|---| +| `` | Feature package name | `widgets` | `kebab-case`, becomes `@repo/` and `packages//` | +| `` | Entity name | `Widget` | `PascalCase` singular, drives class/symbol/use-case names | +| `` | Entity plural slug | `widgets` | `kebab-case`, used for the future Payload collection slug | + +Example end-to-end: + +```bash +pnpm turbo gen feature --args widgets Widget widgets +pnpm install # link the new workspace package +pnpm --filter @repo/widgets lint typecheck test +``` + +## What it generates + +- Package files: `package.json`, `tsconfig.json`, `vitest.config.ts`, + `eslint.config.js`, `turbo.json`, `AGENTS.md` +- One entity (`src/entities/models/.ts`) with a Zod schema + + unit test +- One use case (`src/application/use-cases/get-.use-case.ts`) with + exported input/output schemas, factory function, and tests +- One controller (`src/interface-adapters/controllers/get-.controller.ts`) + with the canonical safeParse → presenter shape +- Mock + real repository (`src/infrastructure/repositories/.repository{,.mock}.ts`). + Both wrap calls in `tracer.startSpan`; the real repo also calls + `logger.captureException` on errors. The real repo body is a Phase-1 + stub that returns `null` until you wire a Payload collection. +- DI: `symbols.ts`, `module.ts`, `container.ts`, plus + `bind-production.ts` and `bind-dev-seed.ts` that compose + `withSpan(tracer, opts, withCapture(logger, tags, factory(deps)))` at + bind time (post-R44 sandwich pattern) +- tRPC integration: `procedures.ts` (feature-scoped error middleware) and + `router.ts` exposing `get` with full router tests including + `BAD_REQUEST` / `NOT_FOUND` mapping +- Contract suite (`__contracts__/`), dev seed (`__seeds__/dev.ts`), and + empty stubs for `__factories__/` and `ui/` + +## Phase-1 scope (intentionally limited) + +The generator does NOT yet emit: + +- Payload CMS collection / global templates (`integrations/cms/**`) +- React Query option builders (`ui/query.ts`) +- Faker-driven `defineFactory` factories (only stubs) +- Multi-entity / multi-use-case (one `get` only) +- Aggregator wiring across the monorepo + +Add these by hand once the entity stabilises. The generator's stub files +clearly mark each `TODO`. + +## Manual aggregator wiring (printed on success) + +After running the generator, hand-edit these files to mount the new +feature on the app's runtime composition graph: + +1. **`apps/web-next/src/server/bind-production.ts`** — import + `bindProduction` and `bindDevSeed` and call them from the + `bindAll()` dispatcher (production branch + dev-seed branch). +2. **`packages/core-api/src/root.ts`** — import `Router` from + `@repo//api` and mount it on the app router. +3. **`packages/core-api/package.json`** — add `"@repo/": "workspace:*"` + to dependencies. +4. **`apps/web-next/package.json`** — add `"@repo/": "workspace:*"` + to dependencies. +5. **(Later) Payload CMS** — add a collection at + `packages//src/integrations/cms/collections/.ts` + and register it in `packages/core-cms/...`. +6. **Verify**: `pnpm --filter @repo/ lint typecheck test` + +The same checklist is printed by the generator when it finishes. + +## Cross-links + +- `CLAUDE.md` — Key Conventions (factory-style use cases, `.toDynamicValue()`, + schemas-in-use-case, three binding modes per feature, span + capture sandwich) +- `packages/navigation/AGENTS.md` — canonical reference shape the templates mirror +- `docs/architecture/vertical-feature-spec.md` — design rationale for the layout +- `docs/decisions/adr-012-lazar-conformance.md` — file naming + factory pattern +- `docs/decisions/adr-013-input-output-unification.md` — schemas-in-use-case + presenter +- `docs/decisions/adr-014-instrumentation-sentry.md` — span + capture wiring (R41–R44) diff --git a/package.json b/package.json index 049eb2f..853067d 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"" }, "devDependencies": { + "@turbo/gen": "^2.4.0", + "@types/node": "^22.0.0", "prettier": "^3.5.0", "turbo": "^2.4.0", "typescript": "^5.8.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7216b6b..1a6aafb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: devDependencies: + '@turbo/gen': + specifier: ^2.4.0 + version: 2.9.10(@types/node@22.19.17) + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 prettier: specifier: ^3.5.0 version: 3.8.1 @@ -1951,6 +1957,140 @@ packages: cpu: [x64] os: [win32] + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inversifyjs/common@1.4.0': resolution: {integrity: sha512-qfRJ/3iOlCL/VfJq8+4o5X4oA14cZSBbpAmHsYj8EsIit1xDndoOl0xKOyglKtQD4u4gdNVxMHx4RWARk/I4QA==} @@ -3487,6 +3627,10 @@ packages: cpu: [arm64] os: [darwin] + '@turbo/gen@2.9.10': + resolution: {integrity: sha512-o3bUtbZwgZvum1iWIjEE6pxtfKspb9T/3BXpPOXAkIf8Zvh1GTGfS5qk8K3ChOK1BrU4QAtmUtKG02ZykABO3g==} + hasBin: true + '@turbo/linux-64@2.9.4': resolution: {integrity: sha512-Cl1GjxqBXQ+r9KKowmXG+lhD1gclLp48/SE7NxL//66iaMytRw0uiphWGOkccD92iPiRjHLRUaA9lOTtgr5OCA==} cpu: [x64] @@ -4107,6 +4251,9 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + charenc@0.0.2: resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} @@ -4144,6 +4291,10 @@ packages: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -5082,6 +5233,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -5889,6 +6044,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -7355,6 +7514,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -8240,6 +8403,131 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@22.19.17)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.19.17) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.17) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/confirm@5.1.21(@types/node@22.19.17)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.17) + '@inquirer/type': 3.0.10(@types/node@22.19.17) + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/core@10.3.2(@types/node@22.19.17)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.17) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/editor@4.2.23(@types/node@22.19.17)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.17) + '@inquirer/external-editor': 1.0.3(@types/node@22.19.17) + '@inquirer/type': 3.0.10(@types/node@22.19.17) + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/expand@4.0.23(@types/node@22.19.17)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.17) + '@inquirer/type': 3.0.10(@types/node@22.19.17) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/external-editor@1.0.3(@types/node@22.19.17)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@22.19.17)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.17) + '@inquirer/type': 3.0.10(@types/node@22.19.17) + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/number@3.0.23(@types/node@22.19.17)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.17) + '@inquirer/type': 3.0.10(@types/node@22.19.17) + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/password@4.0.23(@types/node@22.19.17)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.19.17) + '@inquirer/type': 3.0.10(@types/node@22.19.17) + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/prompts@7.10.1(@types/node@22.19.17)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@22.19.17) + '@inquirer/confirm': 5.1.21(@types/node@22.19.17) + '@inquirer/editor': 4.2.23(@types/node@22.19.17) + '@inquirer/expand': 4.0.23(@types/node@22.19.17) + '@inquirer/input': 4.3.1(@types/node@22.19.17) + '@inquirer/number': 3.0.23(@types/node@22.19.17) + '@inquirer/password': 4.0.23(@types/node@22.19.17) + '@inquirer/rawlist': 4.1.11(@types/node@22.19.17) + '@inquirer/search': 3.2.2(@types/node@22.19.17) + '@inquirer/select': 4.4.2(@types/node@22.19.17) + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/rawlist@4.1.11(@types/node@22.19.17)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.17) + '@inquirer/type': 3.0.10(@types/node@22.19.17) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/search@3.2.2(@types/node@22.19.17)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.17) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.17) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/select@4.4.2(@types/node@22.19.17)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.19.17) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.17) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/type@3.0.10(@types/node@22.19.17)': + optionalDependencies: + '@types/node': 22.19.17 + '@inversifyjs/common@1.4.0': {} '@inversifyjs/core@1.3.5(reflect-metadata@0.2.2)': @@ -10298,6 +10586,13 @@ snapshots: '@turbo/darwin-arm64@2.9.4': optional: true + '@turbo/gen@2.9.10(@types/node@22.19.17)': + dependencies: + '@inquirer/prompts': 7.10.1(@types/node@22.19.17) + esbuild: 0.25.12 + transitivePeerDependencies: + - '@types/node' + '@turbo/linux-64@2.9.4': optional: true @@ -11053,6 +11348,8 @@ snapshots: character-reference-invalid@2.0.1: {} + chardet@2.1.1: {} + charenc@0.0.2: {} check-error@2.1.3: {} @@ -11085,6 +11382,8 @@ snapshots: clean-stack@2.2.0: {} + cli-width@4.1.0: {} + client-only@0.0.1: {} cliui@6.0.0: @@ -12015,6 +12314,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -13131,6 +13434,8 @@ snapshots: ms@2.1.3: {} + mute-stream@2.0.0: {} + nanoid@3.3.11: {} natural-compare@1.4.0: {} @@ -14807,6 +15112,8 @@ snapshots: yocto-queue@0.1.0: {} + yoctocolors-cjs@2.1.3: {} + zod@3.25.76: {} zwitch@2.0.4: {} diff --git a/turbo/generators/config.ts b/turbo/generators/config.ts new file mode 100644 index 0000000..397f269 --- /dev/null +++ b/turbo/generators/config.ts @@ -0,0 +1,342 @@ +import type { PlopTypes } from "@turbo/gen"; + +/** + * Turbo generator: `feature` + * + * Scaffolds a Lazar-conformant feature package under `packages//` + * matching the shape of the existing `navigation` reference feature. + * + * Phase 1 scope (intentionally limited): + * - Single entity, single use case (`getX`) + * - Skips Payload CMS collection/global templates (integrations/cms/**) + * - Skips UI query helpers (ui/query.ts) — emits an empty barrel + * - Skips faker-driven factories — emits empty stubs + * - Skips multi-entity / multi-use-case + * - Skips aggregator wiring (core-api/root.ts, core-cms/**, apps/web-next/server/bind-production.ts) + * + * After running, the developer must hand-wire the new feature into: + * - apps/web-next/src/server/bind-production.ts (bindAll dispatcher) + * - packages/core-api/src/root.ts (mount on app router) + * - packages/core-cms/... (when CMS templates are added later) + * + * The generator prints these manual steps when it finishes. + */ +export default function generator(plop: PlopTypes.NodePlopAPI): void { + plop.setGenerator("feature", { + description: + "Scaffold a Lazar-conformant feature package (single entity / single use case)", + prompts: [ + { + type: "input", + name: "name", + message: + "Feature package name (kebab-case, becomes @repo/ and packages//):", + validate: (input: string) => { + if (!input) return "Required"; + if (!/^[a-z][a-z0-9-]*$/.test(input)) { + return "Must be kebab-case (lowercase letters, digits, hyphens; must start with a letter)"; + } + return true; + }, + }, + { + type: "input", + name: "entity", + message: + "Entity name (PascalCase singular, e.g. 'Widget' for a getWidget use case):", + validate: (input: string) => { + if (!input) return "Required"; + if (!/^[A-Z][A-Za-z0-9]*$/.test(input)) { + return "Must be PascalCase (e.g. Widget, BlogPost)"; + } + return true; + }, + }, + { + type: "input", + name: "entityPlural", + message: + "Entity plural slug (kebab-case, used for Payload collection slug; e.g. 'widgets'):", + validate: (input: string) => { + if (!input) return "Required"; + if (!/^[a-z][a-z0-9-]*$/.test(input)) { + return "Must be kebab-case"; + } + return true; + }, + }, + ], + actions: [ + // Top-level package files + { + type: "add", + path: "packages/{{kebabCase name}}/package.json", + templateFile: "templates/feature/package.json.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/tsconfig.json", + templateFile: "templates/feature/tsconfig.json.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/vitest.config.ts", + templateFile: "templates/feature/vitest.config.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/eslint.config.js", + templateFile: "templates/feature/eslint.config.js.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/turbo.json", + templateFile: "templates/feature/turbo.json.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/AGENTS.md", + templateFile: "templates/feature/AGENTS.md.hbs", + }, + + // Public surface + { + type: "add", + path: "packages/{{kebabCase name}}/src/index.ts", + templateFile: "templates/feature/src/index.ts.hbs", + }, + + // Entities — models + errors + { + type: "add", + path: "packages/{{kebabCase name}}/src/entities/models/{{kebabCase entity}}.ts", + templateFile: "templates/feature/src/entities/models/entity.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/entities/models/{{kebabCase entity}}.test.ts", + templateFile: "templates/feature/src/entities/models/entity.test.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/entities/errors/common.ts", + templateFile: "templates/feature/src/entities/errors/common.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/entities/errors/{{kebabCase entity}}.ts", + templateFile: "templates/feature/src/entities/errors/entity.ts.hbs", + }, + + // Application layer + { + type: "add", + path: "packages/{{kebabCase name}}/src/application/repositories/{{kebabCase entity}}.repository.interface.ts", + templateFile: + "templates/feature/src/application/repositories/entity.repository.interface.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/application/use-cases/get-{{kebabCase entity}}.use-case.ts", + templateFile: + "templates/feature/src/application/use-cases/get-entity.use-case.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/application/use-cases/get-{{kebabCase entity}}.use-case.test.ts", + templateFile: + "templates/feature/src/application/use-cases/get-entity.use-case.test.ts.hbs", + }, + + // Infrastructure + { + type: "add", + path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.ts", + templateFile: + "templates/feature/src/infrastructure/repositories/entity.repository.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.test.ts", + templateFile: + "templates/feature/src/infrastructure/repositories/entity.repository.test.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.mock.ts", + templateFile: + "templates/feature/src/infrastructure/repositories/entity.repository.mock.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.mock.test.ts", + templateFile: + "templates/feature/src/infrastructure/repositories/entity.repository.mock.test.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.span.test.ts", + templateFile: + "templates/feature/src/infrastructure/repositories/entity.repository.span.test.ts.hbs", + }, + + // Interface adapters + { + type: "add", + path: "packages/{{kebabCase name}}/src/interface-adapters/controllers/get-{{kebabCase entity}}.controller.ts", + templateFile: + "templates/feature/src/interface-adapters/controllers/get-entity.controller.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/interface-adapters/controllers/get-{{kebabCase entity}}.controller.test.ts", + templateFile: + "templates/feature/src/interface-adapters/controllers/get-entity.controller.test.ts.hbs", + }, + + // DI + { + type: "add", + path: "packages/{{kebabCase name}}/src/di/symbols.ts", + templateFile: "templates/feature/src/di/symbols.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/di/module.ts", + templateFile: "templates/feature/src/di/module.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/di/container.ts", + templateFile: "templates/feature/src/di/container.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/di/container.test.ts", + templateFile: "templates/feature/src/di/container.test.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/di/bind-production.ts", + templateFile: "templates/feature/src/di/bind-production.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/di/bind-dev-seed.ts", + templateFile: "templates/feature/src/di/bind-dev-seed.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/di/bind-dev-seed.test.ts", + templateFile: "templates/feature/src/di/bind-dev-seed.test.ts.hbs", + }, + + // Integrations: api + { + type: "add", + path: "packages/{{kebabCase name}}/src/integrations/api/procedures.ts", + templateFile: "templates/feature/src/integrations/api/procedures.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/integrations/api/router.ts", + templateFile: "templates/feature/src/integrations/api/router.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/integrations/api/router.test.ts", + templateFile: "templates/feature/src/integrations/api/router.test.ts.hbs", + }, + + // Seeds + factories + contracts (Phase 1: minimal stubs that still typecheck/test) + { + type: "add", + path: "packages/{{kebabCase name}}/src/__seeds__/dev.ts", + templateFile: "templates/feature/src/__seeds__/dev.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/__factories__/index.ts", + templateFile: "templates/feature/src/__factories__/index.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/__factories__/{{kebabCase entity}}.factory.ts", + templateFile: "templates/feature/src/__factories__/entity.factory.ts.hbs", + }, + { + type: "add", + path: "packages/{{kebabCase name}}/src/__contracts__/{{kebabCase entity}}-repository.contract.ts", + templateFile: + "templates/feature/src/__contracts__/entity-repository.contract.ts.hbs", + }, + + // ui — empty barrel for Phase 1 (./ui subpath kept reserved) + { + type: "add", + path: "packages/{{kebabCase name}}/src/ui/index.ts", + templateFile: "templates/feature/src/ui/index.ts.hbs", + }, + + // Final manual-wiring instructions printed to the user + function printNextSteps(answers): string { + const a = answers as { name: string; entity: string; entityPlural: string }; + const kebab = a.name; + const constSym = a.name.toUpperCase().replace(/-/g, "_"); + const pkg = `@repo/${kebab}`; + return [ + "", + "─────────────────────────────────────────────────────────────", + `Feature package ${pkg} scaffolded.`, + "", + "Next steps (manual aggregator wiring — not generated):", + "", + ` 1. pnpm install # link the new workspace package`, + "", + ` 2. apps/web-next/src/server/bind-production.ts:`, + ` - import { bindProduction${cap(a.name)} } from "${pkg}/di/bind-production";`, + ` - import { bindDevSeed${cap(a.name)} } from "${pkg}/di/bind-dev-seed";`, + ` - call bindProduction${cap(a.name)}(config, tracer, logger) (production branch)`, + ` - call await bindDevSeed${cap(a.name)}(tracer, logger) (dev-seed branch)`, + "", + ` 3. packages/core-api/src/root.ts:`, + ` - import { ${camel(a.name)}Router } from "${pkg}/api";`, + ` - mount it on the app router (e.g. ${camel(a.name)}: ${camel(a.name)}Router)`, + "", + ` 4. packages/core-api/package.json:`, + ` - add "${pkg}": "workspace:*" to dependencies`, + "", + ` 5. apps/web-next/package.json:`, + ` - add "${pkg}": "workspace:*" to dependencies`, + "", + ` 6. (Later) Add Payload CMS collection/global at:`, + ` packages/${kebab}/src/integrations/cms/collections/${a.entityPlural}.ts`, + ` and register in packages/core-cms/...`, + "", + ` 7. Verify: pnpm --filter ${pkg} lint typecheck test`, + "", + `Reference symbols exported by this package:`, + ` - ${constSym}_SYMBOLS (from ${pkg}/* internal)`, + ` - bindProduction${cap(a.name)}, bindDevSeed${cap(a.name)}`, + ` - ${camel(a.name)}Router (tRPC)`, + "─────────────────────────────────────────────────────────────", + "", + ].join("\n"); + }, + ], + }); +} + +// Local helpers used inside the printNextSteps action — Plop's helpers aren't +// available outside template strings, so we replicate the bits we need. +function cap(input: string): string { + return input + .split(/[-_\s]+/) + .filter(Boolean) + .map((s) => s[0]!.toUpperCase() + s.slice(1)) + .join(""); +} +function camel(input: string): string { + const p = cap(input); + return p ? p[0]!.toLowerCase() + p.slice(1) : ""; +} diff --git a/turbo/generators/templates/feature/AGENTS.md.hbs b/turbo/generators/templates/feature/AGENTS.md.hbs new file mode 100644 index 0000000..051e69a --- /dev/null +++ b/turbo/generators/templates/feature/AGENTS.md.hbs @@ -0,0 +1,42 @@ +# AGENTS.md — {{kebabCase name}} + +Feature package scaffolded by `turbo gen feature`. Provides domain logic, repositories, use cases, controllers and the tRPC router for `{{pascalCase entity}}`. + +## Overview + +`@repo/{{kebabCase name}}` owns: the `{{pascalCase entity}}` domain model, feature-scoped errors, the `I{{pascalCase entity}}Repository` interface, one use case (`get{{pascalCase entity}}UseCase`), one controller, a real Payload-backed repository (stub body — fill in once the Payload collection is added), an in-memory mock repository, and the tRPC `{{camelCase name}}Router`. + +## Layer responsibilities + +| Layer | Key files | +|---|---| +| **entities/models** | `{{kebabCase entity}}.ts` — `{{pascalCase entity}}` Zod schema + type | +| **entities/errors** | `{{kebabCase entity}}.ts` ({{pascalCase entity}}NotFoundError), `common.ts` (InputParseError) | +| **application/use-cases** | `get-{{kebabCase entity}}.use-case.ts` — factory function + exported schemas | +| **application/repositories** | `{{kebabCase entity}}.repository.interface.ts` — `I{{pascalCase entity}}Repository` | +| **infrastructure/repositories** | `{{kebabCase entity}}.repository.ts` (real Payload-backed; stub body), `{{kebabCase entity}}.repository.mock.ts` (in-memory) | +| **interface-adapters/controllers** | `get-{{kebabCase entity}}.controller.ts` — one file per use case | +| **di** | `symbols.ts`, `module.ts`, `container.ts`, `bind-production.ts`, `bind-dev-seed.ts` | +| **integrations/api** | `procedures.ts` ({{camelCase name}}Procedure), `router.ts` ({{camelCase name}}Router) | + +## Public exports + +| Subpath | Contents | +|---|---| +| `.` | `{{pascalCase entity}}` type; `{{pascalCase entity}}NotFoundError`, `InputParseError`; `get{{pascalCase entity}}InputSchema`, `get{{pascalCase entity}}OutputSchema`, `Get{{pascalCase entity}}Input`, `Get{{pascalCase entity}}Output`, `IGet{{pascalCase entity}}UseCase`; `IGet{{pascalCase entity}}Controller` type alias; `{{pascalCase name}}Router` type | +| `./api` | `{{camelCase name}}Router` (tRPC router) | +| `./di/bind-production` | `bindProduction{{pascalCase name}}(config, tracer, logger)` | +| `./di/bind-dev-seed` | `bindDevSeed{{pascalCase name}}(tracer, logger)` | +| `./ui` | (reserved — Phase 1 is empty) | + +## Tests + +```bash +pnpm test --filter @repo/{{kebabCase name}} +``` + +## What it must NOT import + +- Any other feature package +- Any app package +- `@repo/core-api`, `@repo/core-cms`, `@repo/core-trpc`, `@repo/core-ui` directly; only `@repo/core-shared` diff --git a/turbo/generators/templates/feature/eslint.config.js.hbs b/turbo/generators/templates/feature/eslint.config.js.hbs new file mode 100644 index 0000000..7440d8f --- /dev/null +++ b/turbo/generators/templates/feature/eslint.config.js.hbs @@ -0,0 +1,3 @@ +import baseConfig from "@repo/core-eslint/base"; + +export default baseConfig; diff --git a/turbo/generators/templates/feature/package.json.hbs b/turbo/generators/templates/feature/package.json.hbs new file mode 100644 index 0000000..a7e8784 --- /dev/null +++ b/turbo/generators/templates/feature/package.json.hbs @@ -0,0 +1,35 @@ +{ + "name": "@repo/{{kebabCase name}}", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./ui": "./src/ui/index.ts", + "./api": "./src/integrations/api/router.ts", + "./di/bind-production": "./src/di/bind-production.ts", + "./di/bind-dev-seed": "./src/di/bind-dev-seed.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-testing": "workspace:*", + "@repo/core-typescript": "workspace:*", + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^3.2.4", + "vitest": "^3.1.0" + } +} diff --git a/turbo/generators/templates/feature/src/__contracts__/entity-repository.contract.ts.hbs b/turbo/generators/templates/feature/src/__contracts__/entity-repository.contract.ts.hbs new file mode 100644 index 0000000..7c3b4c4 --- /dev/null +++ b/turbo/generators/templates/feature/src/__contracts__/entity-repository.contract.ts.hbs @@ -0,0 +1,55 @@ +import { it, expect, beforeEach, describe } from "vitest"; +import { defineContractSuite } from "@repo/core-testing/contract"; +import type { I{{pascalCase entity}}Repository } from "../application/repositories/{{kebabCase entity}}.repository.interface.js"; +import type { {{pascalCase entity}} } from "../entities/models/{{kebabCase entity}}.js"; + +/** + * Known fixtures every implementation's `buildSubject` must pre-seed. + * Exported so test files can pass them to `Mock{{pascalCase entity}}Repository` or the + * Payload stub without duplicating definitions. + */ +export const CONTRACT_{{constantCase entity}}_SEED: ReadonlyArray = [ + ["seed-1", { id: "seed-1", name: "Seed One" }], + ["seed-2", { id: "seed-2", name: "Seed Two" }], +]; + +/** + * Contract for I{{pascalCase entity}}Repository. + * + * The interface exposes only `get{{pascalCase entity}}(id)`. The contract verifies + * found vs missing behaviour and span emission. + */ +export const {{camelCase entity}}RepositoryContract = defineContractSuite( + "I{{pascalCase entity}}Repository", + ({ buildSubject, getTracer }) => { + let repo: I{{pascalCase entity}}Repository; + + beforeEach(async () => { + repo = await buildSubject(); + }); + + it("get{{pascalCase entity}} returns the seeded {{camelCase entity}} when id exists", async () => { + const result = await repo.get{{pascalCase entity}}("seed-1"); + expect(result).not.toBeNull(); + expect(result?.id).toBe("seed-1"); + expect(typeof result?.name).toBe("string"); + }); + + it("get{{pascalCase entity}} returns null for an unknown id", async () => { + const result = await repo.get{{pascalCase entity}}("does-not-exist"); + expect(result).toBeNull(); + }); + + describe("span emission", () => { + it("get{{pascalCase entity}} emits span '{{camelCase entity}}.get{{pascalCase entity}}' with op=repository", async () => { + if (!getTracer) return; + const tracer = getTracer(); + tracer.reset(); + await repo.get{{pascalCase entity}}("seed-1"); + const span = tracer.findSpan("{{camelCase entity}}.get{{pascalCase entity}}"); + expect(span).toBeDefined(); + expect(span!.op).toBe("repository"); + }); + }); + }, +); diff --git a/turbo/generators/templates/feature/src/__factories__/entity.factory.ts.hbs b/turbo/generators/templates/feature/src/__factories__/entity.factory.ts.hbs new file mode 100644 index 0000000..1bc144b --- /dev/null +++ b/turbo/generators/templates/feature/src/__factories__/entity.factory.ts.hbs @@ -0,0 +1,4 @@ +// Phase-1 stub. Replace with `defineFactory<{{pascalCase entity}}>` once the +// entity shape stabilises. See packages/navigation/src/__factories__/ for an +// example. +export {}; diff --git a/turbo/generators/templates/feature/src/__factories__/index.ts.hbs b/turbo/generators/templates/feature/src/__factories__/index.ts.hbs new file mode 100644 index 0000000..db178e6 --- /dev/null +++ b/turbo/generators/templates/feature/src/__factories__/index.ts.hbs @@ -0,0 +1,3 @@ +// Phase-1 placeholder. Add faker-driven factories here once the entity +// shape stabilises. See packages/blog/src/__factories__/ for the pattern. +export {}; diff --git a/turbo/generators/templates/feature/src/__seeds__/dev.ts.hbs b/turbo/generators/templates/feature/src/__seeds__/dev.ts.hbs new file mode 100644 index 0000000..af5abc4 --- /dev/null +++ b/turbo/generators/templates/feature/src/__seeds__/dev.ts.hbs @@ -0,0 +1,16 @@ +import type { {{pascalCase entity}} } from "../entities/models/{{kebabCase entity}}.js"; + +/** + * Realistic dev seed for `bindDevSeed{{pascalCase name}}`. + * + * Phase-1: returns a small hand-rolled Map. Replace with a faker-driven + * `defineFactory` (see `packages/blog/src/__factories__/`) once the entity + * shape stabilises. + */ +export function buildDev{{pascalCase entity}}Map(): Map { + return new Map([ + ["dev-1", { id: "dev-1", name: "Dev One" }], + ["dev-2", { id: "dev-2", name: "Dev Two" }], + ["dev-3", { id: "dev-3", name: "Dev Three" }], + ]); +} diff --git a/turbo/generators/templates/feature/src/application/repositories/entity.repository.interface.ts.hbs b/turbo/generators/templates/feature/src/application/repositories/entity.repository.interface.ts.hbs new file mode 100644 index 0000000..cb08184 --- /dev/null +++ b/turbo/generators/templates/feature/src/application/repositories/entity.repository.interface.ts.hbs @@ -0,0 +1,5 @@ +import type { {{pascalCase entity}} } from "../../entities/models/{{kebabCase entity}}"; + +export interface I{{pascalCase entity}}Repository { + get{{pascalCase entity}}(id: string): Promise<{{pascalCase entity}} | null>; +} diff --git a/turbo/generators/templates/feature/src/application/use-cases/get-entity.use-case.test.ts.hbs b/turbo/generators/templates/feature/src/application/use-cases/get-entity.use-case.test.ts.hbs new file mode 100644 index 0000000..fc58e4b --- /dev/null +++ b/turbo/generators/templates/feature/src/application/use-cases/get-entity.use-case.test.ts.hbs @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { ZodError } from "zod"; +import { get{{pascalCase entity}}UseCase } from "@/application/use-cases/get-{{kebabCase entity}}.use-case"; +import { Mock{{pascalCase entity}}Repository } from "@/infrastructure/repositories/{{kebabCase entity}}.repository.mock"; +import { {{pascalCase entity}}NotFoundError } from "@/entities/errors/{{kebabCase entity}}"; + +describe("get{{pascalCase entity}}UseCase", () => { + it("returns the seeded {{camelCase entity}} by id", async () => { + const repo = new Mock{{pascalCase entity}}Repository(); + const useCase = get{{pascalCase entity}}UseCase(repo); + const result = await useCase({ id: "seed-1" }); + expect(result.id).toBe("seed-1"); + expect(result.name).toBeTypeOf("string"); + }); + + it("throws {{pascalCase entity}}NotFoundError when repository returns null", async () => { + const repo = new Mock{{pascalCase entity}}Repository(new Map()); + const useCase = get{{pascalCase entity}}UseCase(repo); + await expect(useCase({ id: "missing" })).rejects.toBeInstanceOf( + {{pascalCase entity}}NotFoundError, + ); + }); + + it("throws ZodError when repository returns malformed data", async () => { + const malformedRepo = { + get{{pascalCase entity}}: async () => ({ id: "", name: "x" }) as never, + }; + const useCase = get{{pascalCase entity}}UseCase(malformedRepo); + await expect(useCase({ id: "anything" })).rejects.toBeInstanceOf(ZodError); + }); +}); diff --git a/turbo/generators/templates/feature/src/application/use-cases/get-entity.use-case.ts.hbs b/turbo/generators/templates/feature/src/application/use-cases/get-entity.use-case.ts.hbs new file mode 100644 index 0000000..c6f1953 --- /dev/null +++ b/turbo/generators/templates/feature/src/application/use-cases/get-entity.use-case.ts.hbs @@ -0,0 +1,30 @@ +import { z } from "zod"; + +import { {{pascalCase entity}}NotFoundError } from "../../entities/errors/{{kebabCase entity}}"; +import { {{camelCase entity}}Schema } from "../../entities/models/{{kebabCase entity}}"; +import type { I{{pascalCase entity}}Repository } from "../repositories/{{kebabCase entity}}.repository.interface"; + +// ── Input ──────────────────────────────────────────────────────────────── +export const get{{pascalCase entity}}InputSchema = z + .object({ + id: z.string().min(1), + }) + .strict(); +export type Get{{pascalCase entity}}Input = z.infer; + +// ── Output ─────────────────────────────────────────────────────────────── +export const get{{pascalCase entity}}OutputSchema = {{camelCase entity}}Schema; +export type Get{{pascalCase entity}}Output = z.infer; + +// ── Use case ───────────────────────────────────────────────────────────── +export type IGet{{pascalCase entity}}UseCase = ReturnType; + +export const get{{pascalCase entity}}UseCase = + ({{camelCase entity}}Repository: I{{pascalCase entity}}Repository) => + async (input: Get{{pascalCase entity}}Input): Promise => { + const result = await {{camelCase entity}}Repository.get{{pascalCase entity}}(input.id); + if (!result) { + throw new {{pascalCase entity}}NotFoundError(`{{pascalCase entity}} not found: ${input.id}`); + } + return get{{pascalCase entity}}OutputSchema.parse(result); + }; diff --git a/turbo/generators/templates/feature/src/di/bind-dev-seed.test.ts.hbs b/turbo/generators/templates/feature/src/di/bind-dev-seed.test.ts.hbs new file mode 100644 index 0000000..bf69822 --- /dev/null +++ b/turbo/generators/templates/feature/src/di/bind-dev-seed.test.ts.hbs @@ -0,0 +1,56 @@ +import "reflect-metadata"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation"; +import { bindDevSeed{{pascalCase name}} } from "@/di/bind-dev-seed"; +import { {{camelCase name}}Container } from "@/di/container"; +import { {{constantCase name}}_SYMBOLS } from "@/di/symbols"; +import { Mock{{pascalCase entity}}Repository } from "@/infrastructure/repositories/{{kebabCase entity}}.repository.mock"; +import type { I{{pascalCase entity}}Repository } from "@/application/repositories/{{kebabCase entity}}.repository.interface"; + +const noop = { tracer: new NoopTracer(), logger: new NoopLogger() }; + +describe("bindDevSeed{{pascalCase name}}", () => { + beforeEach(() => { + if ({{camelCase name}}Container.isBound({{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository)) { + {{camelCase name}}Container.unbind({{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository); + } + {{camelCase name}}Container + .bind({{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository) + .to(Mock{{pascalCase entity}}Repository); + }); + + afterEach(() => { + if ({{camelCase name}}Container.isBound({{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository)) { + {{camelCase name}}Container.unbind({{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository); + } + {{camelCase name}}Container + .bind({{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository) + .to(Mock{{pascalCase entity}}Repository); + }); + + it("populates the repository with the dev seed", async () => { + await bindDevSeed{{pascalCase name}}(noop.tracer, noop.logger); + + const repo = {{camelCase name}}Container.get( + {{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository, + ); + const found = await repo.get{{pascalCase entity}}("dev-1"); + + expect(found).not.toBeNull(); + expect(found?.id).toBe("dev-1"); + }); + + it("is idempotent — calling twice rebuilds a fresh populated repo", async () => { + await bindDevSeed{{pascalCase name}}(noop.tracer, noop.logger); + const before = {{camelCase name}}Container.get( + {{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository, + ); + + await bindDevSeed{{pascalCase name}}(noop.tracer, noop.logger); + const after = {{camelCase name}}Container.get( + {{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository, + ); + + expect(after).not.toBe(before); + }); +}); diff --git a/turbo/generators/templates/feature/src/di/bind-dev-seed.ts.hbs b/turbo/generators/templates/feature/src/di/bind-dev-seed.ts.hbs new file mode 100644 index 0000000..f3d5495 --- /dev/null +++ b/turbo/generators/templates/feature/src/di/bind-dev-seed.ts.hbs @@ -0,0 +1,82 @@ +import { + withSpan, + withCapture, + INSTRUMENTATION_SYMBOLS, + type ITracer, + type ILogger, +} from "@repo/core-shared/instrumentation"; +import { {{camelCase name}}Container } from "./container.js"; +import { {{constantCase name}}_SYMBOLS } from "./symbols.js"; +import { Mock{{pascalCase entity}}Repository } from "../infrastructure/repositories/{{kebabCase entity}}.repository.mock.js"; +import { buildDev{{pascalCase entity}}Map } from "../__seeds__/dev.js"; +import { get{{pascalCase entity}}UseCase } from "../application/use-cases/get-{{kebabCase entity}}.use-case.js"; +import { get{{pascalCase entity}}Controller } from "../interface-adapters/controllers/get-{{kebabCase entity}}.controller.js"; +import type { I{{pascalCase entity}}Repository } from "../application/repositories/{{kebabCase entity}}.repository.interface.js"; + +/** + * Replace the default mock with a populated one for dev mode + storybook. + * + * Mutually exclusive with `bindProduction{{pascalCase name}}(config, tracer, logger)`. + * Tests must NOT call this — they construct `new Mock{{pascalCase entity}}Repository()` + * directly and seed via factories per-test. + * + * Idempotent: safe to call multiple times; each call rebuilds a fresh + * populated repo and rebinds the symbol. + */ +export async function bindDevSeed{{pascalCase name}}( + tracer: ITracer, + logger: ILogger, +): Promise { + // Bind shared instrumentation into feature container + if ({{camelCase name}}Container.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { + {{camelCase name}}Container.unbind(INSTRUMENTATION_SYMBOLS.TRACER); + } + if ({{camelCase name}}Container.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) { + {{camelCase name}}Container.unbind(INSTRUMENTATION_SYMBOLS.LOGGER); + } + {{camelCase name}}Container.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); + {{camelCase name}}Container.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); + + if ({{camelCase name}}Container.isBound({{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository)) { + {{camelCase name}}Container.unbind({{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository); + } + const repo = new Mock{{pascalCase entity}}Repository(buildDev{{pascalCase entity}}Map(), tracer, logger); + {{camelCase name}}Container + .bind({{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository) + .toConstantValue(repo); + + // Wrap use case + controller identically to bind-production + const wrappedGet{{pascalCase entity}} = withSpan( + tracer, + { name: "{{camelCase name}}.get{{pascalCase entity}}", op: "use-case" }, + withCapture( + logger, + { feature: "{{kebabCase name}}", layer: "use-case", name: "{{camelCase name}}.get{{pascalCase entity}}" }, + get{{pascalCase entity}}UseCase(repo), + ), + ); + + for (const sym of [ + {{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}UseCase, + {{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}Controller, + ]) { + if ({{camelCase name}}Container.isBound(sym)) {{camelCase name}}Container.unbind(sym); + } + {{camelCase name}}Container + .bind({{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}UseCase) + .toConstantValue(wrappedGet{{pascalCase entity}}); + + {{camelCase name}}Container + .bind({{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}Controller) + .toConstantValue( + withSpan( + tracer, + { name: "{{camelCase name}}.get{{pascalCase entity}}", op: "controller" }, + withCapture( + logger, + { feature: "{{kebabCase name}}", layer: "controller", name: "{{camelCase name}}.get{{pascalCase entity}}" }, + get{{pascalCase entity}}Controller(wrappedGet{{pascalCase entity}}), + ), + ), + ); +} diff --git a/turbo/generators/templates/feature/src/di/bind-production.ts.hbs b/turbo/generators/templates/feature/src/di/bind-production.ts.hbs new file mode 100644 index 0000000..35c2c21 --- /dev/null +++ b/turbo/generators/templates/feature/src/di/bind-production.ts.hbs @@ -0,0 +1,74 @@ +import type { SanitizedConfig } from "payload"; +import { + withSpan, + withCapture, + INSTRUMENTATION_SYMBOLS, + type ITracer, + type ILogger, +} from "@repo/core-shared/instrumentation"; +import { {{camelCase name}}Container } from "./container"; +import { {{constantCase name}}_SYMBOLS } from "./symbols"; +import { {{pascalCase entity}}Repository } from "../infrastructure/repositories/{{kebabCase entity}}.repository"; +import { get{{pascalCase entity}}UseCase } from "../application/use-cases/get-{{kebabCase entity}}.use-case"; +import { get{{pascalCase entity}}Controller } from "../interface-adapters/controllers/get-{{kebabCase entity}}.controller"; + +export function bindProduction{{pascalCase name}}( + config: SanitizedConfig, + tracer: ITracer, + logger: ILogger, +): void { + // Bind shared instrumentation into feature container + if ({{camelCase name}}Container.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { + {{camelCase name}}Container.unbind(INSTRUMENTATION_SYMBOLS.TRACER); + } + if ({{camelCase name}}Container.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) { + {{camelCase name}}Container.unbind(INSTRUMENTATION_SYMBOLS.LOGGER); + } + {{camelCase name}}Container.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); + {{camelCase name}}Container.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); + + // Real repository + if ({{camelCase name}}Container.isBound({{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository)) { + {{camelCase name}}Container.unbind({{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository); + } + const repo = new {{pascalCase entity}}Repository(config, tracer, logger); + {{camelCase name}}Container + .bind({{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository) + .toConstantValue(repo); + + // Use case — wrapped with span + capture at bind time + const wrappedGet{{pascalCase entity}} = withSpan( + tracer, + { name: "{{camelCase name}}.get{{pascalCase entity}}", op: "use-case" }, + withCapture( + logger, + { feature: "{{kebabCase name}}", layer: "use-case", name: "{{camelCase name}}.get{{pascalCase entity}}" }, + get{{pascalCase entity}}UseCase(repo), + ), + ); + + if ({{camelCase name}}Container.isBound({{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}UseCase)) { + {{camelCase name}}Container.unbind({{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}UseCase); + } + {{camelCase name}}Container + .bind({{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}UseCase) + .toConstantValue(wrappedGet{{pascalCase entity}}); + + // Controller — wrapped with span at bind time + if ({{camelCase name}}Container.isBound({{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}Controller)) { + {{camelCase name}}Container.unbind({{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}Controller); + } + {{camelCase name}}Container + .bind({{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}Controller) + .toConstantValue( + withSpan( + tracer, + { name: "{{camelCase name}}.get{{pascalCase entity}}", op: "controller" }, + withCapture( + logger, + { feature: "{{kebabCase name}}", layer: "controller", name: "{{camelCase name}}.get{{pascalCase entity}}" }, + get{{pascalCase entity}}Controller(wrappedGet{{pascalCase entity}}), + ), + ), + ); +} diff --git a/turbo/generators/templates/feature/src/di/container.test.ts.hbs b/turbo/generators/templates/feature/src/di/container.test.ts.hbs new file mode 100644 index 0000000..fc4684c --- /dev/null +++ b/turbo/generators/templates/feature/src/di/container.test.ts.hbs @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { {{camelCase name}}Container } from "./container"; +import { {{constantCase name}}_SYMBOLS } from "./symbols"; +import { {{pascalCase name}}Module } from "./module"; +import { Mock{{pascalCase entity}}Repository } from "@/infrastructure/repositories/{{kebabCase entity}}.repository.mock"; +import type { I{{pascalCase entity}}Repository } from "@/application/repositories/{{kebabCase entity}}.repository.interface"; +import type { IGet{{pascalCase entity}}UseCase } from "@/application/use-cases/get-{{kebabCase entity}}.use-case"; +import type { IGet{{pascalCase entity}}Controller } from "@/interface-adapters/controllers/get-{{kebabCase entity}}.controller"; + +describe("{{camelCase name}}Container", () => { + beforeEach(() => { + {{camelCase name}}Container.unbindAll(); + {{camelCase name}}Container.load({{pascalCase name}}Module); + }); + + afterEach(() => { + {{camelCase name}}Container.unbindAll(); + }); + + it("resolves I{{pascalCase entity}}Repository to Mock{{pascalCase entity}}Repository", () => { + const repo = {{camelCase name}}Container.get( + {{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository, + ); + expect(repo).toBeInstanceOf(Mock{{pascalCase entity}}Repository); + }); + + it("resolves IGet{{pascalCase entity}}UseCase as a function", () => { + const useCase = {{camelCase name}}Container.get( + {{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}UseCase, + ); + expect(typeof useCase).toBe("function"); + }); + + it("resolves IGet{{pascalCase entity}}Controller as a function", () => { + const controller = {{camelCase name}}Container.get( + {{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}Controller, + ); + expect(typeof controller).toBe("function"); + }); +}); diff --git a/turbo/generators/templates/feature/src/di/container.ts.hbs b/turbo/generators/templates/feature/src/di/container.ts.hbs new file mode 100644 index 0000000..9dc8b95 --- /dev/null +++ b/turbo/generators/templates/feature/src/di/container.ts.hbs @@ -0,0 +1,6 @@ +import "reflect-metadata"; +import { Container } from "inversify"; +import { {{pascalCase name}}Module } from "./module"; + +export const {{camelCase name}}Container = new Container({ defaultScope: "Singleton" }); +{{camelCase name}}Container.load({{pascalCase name}}Module); diff --git a/turbo/generators/templates/feature/src/di/module.ts.hbs b/turbo/generators/templates/feature/src/di/module.ts.hbs new file mode 100644 index 0000000..3fb84d6 --- /dev/null +++ b/turbo/generators/templates/feature/src/di/module.ts.hbs @@ -0,0 +1,39 @@ +import { ContainerModule, type interfaces } from "inversify"; + +import type { I{{pascalCase entity}}Repository } from "../application/repositories/{{kebabCase entity}}.repository.interface"; +import { Mock{{pascalCase entity}}Repository } from "../infrastructure/repositories/{{kebabCase entity}}.repository.mock"; +import { + get{{pascalCase entity}}UseCase, + type IGet{{pascalCase entity}}UseCase, +} from "../application/use-cases/get-{{kebabCase entity}}.use-case"; +import { + get{{pascalCase entity}}Controller, + type IGet{{pascalCase entity}}Controller, +} from "../interface-adapters/controllers/get-{{kebabCase entity}}.controller"; +import { {{constantCase name}}_SYMBOLS } from "./symbols"; + +export const {{pascalCase name}}Module = new ContainerModule((bind: interfaces.Bind) => { + bind({{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository).to( + Mock{{pascalCase entity}}Repository, + ); + + bind( + {{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}UseCase, + ).toDynamicValue((ctx) => + get{{pascalCase entity}}UseCase( + ctx.container.get( + {{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository, + ), + ), + ); + + bind( + {{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}Controller, + ).toDynamicValue((ctx) => + get{{pascalCase entity}}Controller( + ctx.container.get( + {{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}UseCase, + ), + ), + ); +}); diff --git a/turbo/generators/templates/feature/src/di/symbols.ts.hbs b/turbo/generators/templates/feature/src/di/symbols.ts.hbs new file mode 100644 index 0000000..5f5008f --- /dev/null +++ b/turbo/generators/templates/feature/src/di/symbols.ts.hbs @@ -0,0 +1,7 @@ +export const {{constantCase name}}_SYMBOLS = { + I{{pascalCase entity}}Repository: Symbol.for("{{kebabCase name}}:I{{pascalCase entity}}Repository"), + // Use cases + IGet{{pascalCase entity}}UseCase: Symbol.for("{{kebabCase name}}:IGet{{pascalCase entity}}UseCase"), + // Controllers + IGet{{pascalCase entity}}Controller: Symbol.for("{{kebabCase name}}:IGet{{pascalCase entity}}Controller"), +} as const; diff --git a/turbo/generators/templates/feature/src/entities/errors/common.ts.hbs b/turbo/generators/templates/feature/src/entities/errors/common.ts.hbs new file mode 100644 index 0000000..40b2976 --- /dev/null +++ b/turbo/generators/templates/feature/src/entities/errors/common.ts.hbs @@ -0,0 +1,6 @@ +export class InputParseError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "InputParseError"; + } +} diff --git a/turbo/generators/templates/feature/src/entities/errors/entity.ts.hbs b/turbo/generators/templates/feature/src/entities/errors/entity.ts.hbs new file mode 100644 index 0000000..262f520 --- /dev/null +++ b/turbo/generators/templates/feature/src/entities/errors/entity.ts.hbs @@ -0,0 +1,6 @@ +export class {{pascalCase entity}}NotFoundError extends Error { + constructor(message = "{{pascalCase entity}} not found", options?: ErrorOptions) { + super(message, options); + this.name = "{{pascalCase entity}}NotFoundError"; + } +} diff --git a/turbo/generators/templates/feature/src/entities/models/entity.test.ts.hbs b/turbo/generators/templates/feature/src/entities/models/entity.test.ts.hbs new file mode 100644 index 0000000..c3e0c4f --- /dev/null +++ b/turbo/generators/templates/feature/src/entities/models/entity.test.ts.hbs @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { {{camelCase entity}}Schema } from "./{{kebabCase entity}}"; + +describe("{{camelCase entity}}Schema", () => { + it("accepts a valid {{camelCase entity}}", () => { + const result = {{camelCase entity}}Schema.parse({ id: "1", name: "Example" }); + expect(result.id).toBe("1"); + expect(result.name).toBe("Example"); + }); + + it("rejects an empty id", () => { + expect(() => {{camelCase entity}}Schema.parse({ id: "", name: "ok" })).toThrow(); + }); + + it("rejects an empty name", () => { + expect(() => {{camelCase entity}}Schema.parse({ id: "1", name: "" })).toThrow(); + }); + + it("rejects a name over 128 chars", () => { + expect(() => + {{camelCase entity}}Schema.parse({ id: "1", name: "x".repeat(129) }), + ).toThrow(); + }); +}); diff --git a/turbo/generators/templates/feature/src/entities/models/entity.ts.hbs b/turbo/generators/templates/feature/src/entities/models/entity.ts.hbs new file mode 100644 index 0000000..28bfe7e --- /dev/null +++ b/turbo/generators/templates/feature/src/entities/models/entity.ts.hbs @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const {{camelCase entity}}Schema = z.object({ + id: z.string().min(1), + name: z.string().min(1).max(128), +}); + +export type {{pascalCase entity}} = z.infer; diff --git a/turbo/generators/templates/feature/src/index.ts.hbs b/turbo/generators/templates/feature/src/index.ts.hbs new file mode 100644 index 0000000..70e2bc1 --- /dev/null +++ b/turbo/generators/templates/feature/src/index.ts.hbs @@ -0,0 +1,16 @@ +export type { {{pascalCase entity}} } from "./entities/models/{{kebabCase entity}}"; +export type { {{pascalCase name}}Router } from "./integrations/api/router"; +export { {{pascalCase entity}}NotFoundError } from "./entities/errors/{{kebabCase entity}}"; +export { InputParseError } from "./entities/errors/common"; + +// Use case schemas + types +export { + get{{pascalCase entity}}InputSchema, + get{{pascalCase entity}}OutputSchema, + type Get{{pascalCase entity}}Input, + type Get{{pascalCase entity}}Output, + type IGet{{pascalCase entity}}UseCase, +} from "./application/use-cases/get-{{kebabCase entity}}.use-case"; + +// Controller type aliases +export type { IGet{{pascalCase entity}}Controller } from "./interface-adapters/controllers/get-{{kebabCase entity}}.controller"; diff --git a/turbo/generators/templates/feature/src/infrastructure/repositories/entity.repository.mock.test.ts.hbs b/turbo/generators/templates/feature/src/infrastructure/repositories/entity.repository.mock.test.ts.hbs new file mode 100644 index 0000000..6c39321 --- /dev/null +++ b/turbo/generators/templates/feature/src/infrastructure/repositories/entity.repository.mock.test.ts.hbs @@ -0,0 +1,15 @@ +import { describe } from "vitest"; +import { RecordingTracer } from "@repo/core-testing/instrumentation"; +import { Mock{{pascalCase entity}}Repository } from "@/infrastructure/repositories/{{kebabCase entity}}.repository.mock"; +import { + {{camelCase entity}}RepositoryContract, + CONTRACT_{{constantCase entity}}_SEED, +} from "@/__contracts__/{{kebabCase entity}}-repository.contract"; + +describe("Mock{{pascalCase entity}}Repository", () => { + const tracer = new RecordingTracer(); + {{camelCase entity}}RepositoryContract.run( + () => new Mock{{pascalCase entity}}Repository(new Map(CONTRACT_{{constantCase entity}}_SEED), tracer), + { tracer: () => tracer }, + ); +}); diff --git a/turbo/generators/templates/feature/src/infrastructure/repositories/entity.repository.mock.ts.hbs b/turbo/generators/templates/feature/src/infrastructure/repositories/entity.repository.mock.ts.hbs new file mode 100644 index 0000000..72b5e6f --- /dev/null +++ b/turbo/generators/templates/feature/src/infrastructure/repositories/entity.repository.mock.ts.hbs @@ -0,0 +1,45 @@ +import "reflect-metadata"; +import { injectable } from "inversify"; +import { + NoopTracer, + NoopLogger, + type ITracer, + type ILogger, +} from "@repo/core-shared/instrumentation"; + +import type { I{{pascalCase entity}}Repository } from "../../application/repositories/{{kebabCase entity}}.repository.interface"; +import type { {{pascalCase entity}} } from "../../entities/models/{{kebabCase entity}}"; + +const DEFAULT_DATA = new Map([ + ["seed-1", { id: "seed-1", name: "Seed One" }], + ["seed-2", { id: "seed-2", name: "Seed Two" }], +]); + +@injectable() +export class Mock{{pascalCase entity}}Repository implements I{{pascalCase entity}}Repository { + private readonly data: Map; + private tracer: ITracer; + private logger: ILogger; + + constructor( + initialData?: Map, + tracer: ITracer = new NoopTracer(), + logger: ILogger = new NoopLogger(), + ) { + this.data = initialData ?? new Map(DEFAULT_DATA); + this.tracer = tracer; + this.logger = logger; + void this.logger; // currently unused; reserved for future mock-thrown captures + } + + async get{{pascalCase entity}}(id: string): Promise<{{pascalCase entity}} | null> { + return this.tracer.startSpan( + { name: "{{camelCase entity}}.get{{pascalCase entity}}", op: "repository", attributes: {} }, + async (span) => { + const found = this.data.get(id) ?? null; + span.setAttribute("found", found !== null); + return found; + }, + ); + } +} diff --git a/turbo/generators/templates/feature/src/infrastructure/repositories/entity.repository.span.test.ts.hbs b/turbo/generators/templates/feature/src/infrastructure/repositories/entity.repository.span.test.ts.hbs new file mode 100644 index 0000000..8137560 --- /dev/null +++ b/turbo/generators/templates/feature/src/infrastructure/repositories/entity.repository.span.test.ts.hbs @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation"; +import { Mock{{pascalCase entity}}Repository } from "@/infrastructure/repositories/{{kebabCase entity}}.repository.mock"; + +// Mock repo also wraps in spans (R42). +describe("Mock{{pascalCase entity}}Repository emits spans", () => { + it("get{{pascalCase entity}} emits one span with op='repository'", async () => { + const tracer = new RecordingTracer(); + const logger = new RecordingLogger(); + const repo = new Mock{{pascalCase entity}}Repository(undefined, tracer, logger); + await repo.get{{pascalCase entity}}("seed-1"); + expect(tracer.spans).toHaveLength(1); + expect(tracer.spans[0]).toMatchObject({ + name: "{{camelCase entity}}.get{{pascalCase entity}}", + op: "repository", + }); + expect(typeof tracer.spans[0]!.attributes.found).toBe("boolean"); + }); +}); diff --git a/turbo/generators/templates/feature/src/infrastructure/repositories/entity.repository.test.ts.hbs b/turbo/generators/templates/feature/src/infrastructure/repositories/entity.repository.test.ts.hbs new file mode 100644 index 0000000..2be312b --- /dev/null +++ b/turbo/generators/templates/feature/src/infrastructure/repositories/entity.repository.test.ts.hbs @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation"; +import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config"; +import { {{pascalCase entity}}Repository } from "@/infrastructure/repositories/{{kebabCase entity}}.repository"; + +// Phase-1 scaffold: the real repository returns null until the Payload +// collection is wired. These tests pin the span shape and the stub return +// value so that callers (use case + DI tests) keep working when the body is +// later replaced with a real `payload.find()` call. + +describe("{{pascalCase entity}}Repository (Phase-1 stub)", () => { + it("returns null and emits a span with op='repository'", async () => { + const tracer = new RecordingTracer(); + const logger = new RecordingLogger(); + const repo = new {{pascalCase entity}}Repository(stubPayloadConfig, tracer, logger); + + const result = await repo.get{{pascalCase entity}}("anything"); + + expect(result).toBeNull(); + expect(tracer.spans).toHaveLength(1); + expect(tracer.spans[0]).toMatchObject({ + name: "{{camelCase entity}}.get{{pascalCase entity}}", + op: "repository", + }); + }); + + it("records the requested id as a span attribute", async () => { + const tracer = new RecordingTracer(); + const repo = new {{pascalCase entity}}Repository(stubPayloadConfig, tracer); + await repo.get{{pascalCase entity}}("custom-id"); + expect(tracer.spans[0]!.attributes.id).toBe("custom-id"); + expect(tracer.spans[0]!.attributes.found).toBe(false); + }); +}); diff --git a/turbo/generators/templates/feature/src/infrastructure/repositories/entity.repository.ts.hbs b/turbo/generators/templates/feature/src/infrastructure/repositories/entity.repository.ts.hbs new file mode 100644 index 0000000..2336284 --- /dev/null +++ b/turbo/generators/templates/feature/src/infrastructure/repositories/entity.repository.ts.hbs @@ -0,0 +1,62 @@ +import "reflect-metadata"; +import { injectable } from "inversify"; +import type { SanitizedConfig } from "payload"; +import { + NoopTracer, + NoopLogger, + type ITracer, + type ILogger, +} from "@repo/core-shared/instrumentation"; + +import type { I{{pascalCase entity}}Repository } from "../../application/repositories/{{kebabCase entity}}.repository.interface"; +import type { {{pascalCase entity}} } from "../../entities/models/{{kebabCase entity}}"; + +const FEATURE = "{{kebabCase name}}" as const; +const REPO = "{{kebabCase entity}}" as const; + +/** + * Phase-1 scaffold — the Payload collection has not been added yet, so this + * repository emits a span but always resolves to `null`. Once you add the + * collection at `integrations/cms/collections/{{kebabCase entity}}.ts` and + * register it with Payload, replace the stub below with a real `payload.find` + * call (see `packages/blog/src/infrastructure/repositories/articles.repository.ts` + * for the canonical pattern). + */ +@injectable() +export class {{pascalCase entity}}Repository implements I{{pascalCase entity}}Repository { + 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; + void this.config; + } + + async get{{pascalCase entity}}(id: string): Promise<{{pascalCase entity}} | null> { + return this.tracer.startSpan( + { name: "{{camelCase entity}}.get{{pascalCase entity}}", op: "repository", attributes: {} }, + async (span) => { + try { + // TODO: replace with `payload.find({ collection: "{{entityPlural}}", where: { id: { equals: id } } })` + // once the Payload collection is registered. + span.setAttribute("id", id); + span.setAttribute("found", false); + return null; + } catch (err) { + this.logger.captureException(err, { + tags: { feature: FEATURE, repo: REPO, method: "get{{pascalCase entity}}" }, + }); + span.setStatus("error", err instanceof Error ? err.message : String(err)); + throw err; + } + }, + ); + } +} diff --git a/turbo/generators/templates/feature/src/integrations/api/procedures.ts.hbs b/turbo/generators/templates/feature/src/integrations/api/procedures.ts.hbs new file mode 100644 index 0000000..e9dcfb8 --- /dev/null +++ b/turbo/generators/templates/feature/src/integrations/api/procedures.ts.hbs @@ -0,0 +1,12 @@ +import { t } from "@repo/core-shared/trpc/init"; +import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; + +import { {{pascalCase entity}}NotFoundError } from "../../entities/errors/{{kebabCase entity}}"; +import { InputParseError } from "../../entities/errors/common"; + +export const {{camelCase name}}Procedure = t.procedure.use( + defineErrorMiddleware([ + [InputParseError, "BAD_REQUEST"], + [{{pascalCase entity}}NotFoundError, "NOT_FOUND"], + ]), +); diff --git a/turbo/generators/templates/feature/src/integrations/api/router.test.ts.hbs b/turbo/generators/templates/feature/src/integrations/api/router.test.ts.hbs new file mode 100644 index 0000000..884657e --- /dev/null +++ b/turbo/generators/templates/feature/src/integrations/api/router.test.ts.hbs @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { TRPCError } from "@trpc/server"; +import { injectable } from "inversify"; +import { {{camelCase name}}Container } from "@/di/container"; +import { {{pascalCase name}}Module } from "@/di/module"; +import { {{constantCase name}}_SYMBOLS } from "@/di/symbols"; +import { get{{pascalCase entity}}UseCase } from "@/application/use-cases/get-{{kebabCase entity}}.use-case"; +import { get{{pascalCase entity}}Controller } from "@/interface-adapters/controllers/get-{{kebabCase entity}}.controller"; +import { {{camelCase name}}Router } from "@/integrations/api/router"; + +describe("{{camelCase name}}Router", () => { + beforeEach(() => { + {{camelCase name}}Container.unbindAll(); + {{camelCase name}}Container.load({{pascalCase name}}Module); + }); + + afterEach(() => { + {{camelCase name}}Container.unbindAll(); + }); + + it("exposes the get{{pascalCase entity}} procedure", () => { + const names = Object.keys({{camelCase name}}Router._def.procedures); + expect(names).toContain("get{{pascalCase entity}}"); + }); + + it("get{{pascalCase entity}} returns the seeded {{camelCase entity}}", async () => { + const caller = {{camelCase name}}Router.createCaller({}); + const result = await caller.get{{pascalCase entity}}({ id: "seed-1" }); + expect(result.id).toBe("seed-1"); + }); +}); + +describe("{{camelCase name}}Router (error mapping)", () => { + beforeEach(() => { + {{camelCase name}}Container.unbindAll(); + {{camelCase name}}Container.load({{pascalCase name}}Module); + }); + + afterEach(() => { + {{camelCase name}}Container.unbindAll(); + }); + + it("translates InputParseError → BAD_REQUEST when extra fields are passed", async () => { + const caller = {{camelCase name}}Router.createCaller({}); + try { + await caller.get{{pascalCase entity}}({ + id: "seed-1", + unexpected: "field", + } as unknown as { id: string }); + throw new Error("expected throw"); + } catch (e) { + expect(e).toBeInstanceOf(TRPCError); + expect((e as TRPCError).code).toBe("BAD_REQUEST"); + } + }); + + it("translates {{pascalCase entity}}NotFoundError → NOT_FOUND when repository returns null", async () => { + @injectable() + class Null{{pascalCase entity}}Repository { + async get{{pascalCase entity}}() { + return null; + } + } + + {{camelCase name}}Container.unbindAll(); + {{camelCase name}}Container + .bind({{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository) + .to(Null{{pascalCase entity}}Repository); + {{camelCase name}}Container + .bind({{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}UseCase) + .toDynamicValue((ctx) => + get{{pascalCase entity}}UseCase( + ctx.container.get({{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository), + ), + ); + {{camelCase name}}Container + .bind({{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}Controller) + .toDynamicValue((ctx) => + get{{pascalCase entity}}Controller( + ctx.container.get({{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}UseCase), + ), + ); + + const caller = {{camelCase name}}Router.createCaller({}); + try { + await caller.get{{pascalCase entity}}({ id: "anything" }); + throw new Error("expected throw"); + } catch (e) { + expect(e).toBeInstanceOf(TRPCError); + expect((e as TRPCError).code).toBe("NOT_FOUND"); + } + }); +}); diff --git a/turbo/generators/templates/feature/src/integrations/api/router.ts.hbs b/turbo/generators/templates/feature/src/integrations/api/router.ts.hbs new file mode 100644 index 0000000..36a3674 --- /dev/null +++ b/turbo/generators/templates/feature/src/integrations/api/router.ts.hbs @@ -0,0 +1,22 @@ +import { router } from "@repo/core-shared/trpc/init"; + +import { {{camelCase name}}Container } from "../../di/container"; +import { {{constantCase name}}_SYMBOLS } from "../../di/symbols"; + +import { get{{pascalCase entity}}InputSchema } from "../../application/use-cases/get-{{kebabCase entity}}.use-case"; +import type { IGet{{pascalCase entity}}Controller } from "../../interface-adapters/controllers/get-{{kebabCase entity}}.controller"; + +import { {{camelCase name}}Procedure } from "./procedures"; + +export const {{camelCase name}}Router = router({ + get{{pascalCase entity}}: {{camelCase name}}Procedure + .input(get{{pascalCase entity}}InputSchema) + .query(({ input }) => { + const ctrl = {{camelCase name}}Container.get( + {{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}Controller, + ); + return ctrl(input); + }), +}); + +export type {{pascalCase name}}Router = typeof {{camelCase name}}Router; diff --git a/turbo/generators/templates/feature/src/interface-adapters/controllers/get-entity.controller.test.ts.hbs b/turbo/generators/templates/feature/src/interface-adapters/controllers/get-entity.controller.test.ts.hbs new file mode 100644 index 0000000..b12fb9f --- /dev/null +++ b/turbo/generators/templates/feature/src/interface-adapters/controllers/get-entity.controller.test.ts.hbs @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { get{{pascalCase entity}}Controller } from "@/interface-adapters/controllers/get-{{kebabCase entity}}.controller"; +import { get{{pascalCase entity}}UseCase } from "@/application/use-cases/get-{{kebabCase entity}}.use-case"; +import { Mock{{pascalCase entity}}Repository } from "@/infrastructure/repositories/{{kebabCase entity}}.repository.mock"; +import { InputParseError } from "@/entities/errors/common"; + +describe("get{{pascalCase entity}}Controller", () => { + it("returns the {{camelCase entity}} for a valid id", async () => { + const repo = new Mock{{pascalCase entity}}Repository(); + const useCase = get{{pascalCase entity}}UseCase(repo); + const controller = get{{pascalCase entity}}Controller(useCase); + + const result = await controller({ id: "seed-1" }); + + expect(result.id).toBe("seed-1"); + }); + + it("throws InputParseError when input is missing fields", async () => { + const repo = new Mock{{pascalCase entity}}Repository(); + const useCase = get{{pascalCase entity}}UseCase(repo); + const controller = get{{pascalCase entity}}Controller(useCase); + + await expect(controller({})).rejects.toBeInstanceOf(InputParseError); + }); + + it("throws InputParseError when input has unexpected fields (strict)", async () => { + const repo = new Mock{{pascalCase entity}}Repository(); + const useCase = get{{pascalCase entity}}UseCase(repo); + const controller = get{{pascalCase entity}}Controller(useCase); + + await expect( + controller({ id: "seed-1", unexpected: true }), + ).rejects.toBeInstanceOf(InputParseError); + }); +}); diff --git a/turbo/generators/templates/feature/src/interface-adapters/controllers/get-entity.controller.ts.hbs b/turbo/generators/templates/feature/src/interface-adapters/controllers/get-entity.controller.ts.hbs new file mode 100644 index 0000000..2a63bbf --- /dev/null +++ b/turbo/generators/templates/feature/src/interface-adapters/controllers/get-entity.controller.ts.hbs @@ -0,0 +1,23 @@ +import { InputParseError } from "../../entities/errors/common"; +import { + get{{pascalCase entity}}InputSchema, + type Get{{pascalCase entity}}Output, + type IGet{{pascalCase entity}}UseCase, +} from "../../application/use-cases/get-{{kebabCase entity}}.use-case"; + +function presenter(value: Get{{pascalCase entity}}Output) { + return value; +} + +export type IGet{{pascalCase entity}}Controller = ReturnType; + +export const get{{pascalCase entity}}Controller = + (get{{pascalCase entity}}UseCase: IGet{{pascalCase entity}}UseCase) => + async (input: unknown): Promise> => { + const parsed = get{{pascalCase entity}}InputSchema.safeParse(input); + if (!parsed.success) { + throw new InputParseError("Invalid get-{{kebabCase entity}} input", { cause: parsed.error }); + } + const result = await get{{pascalCase entity}}UseCase(parsed.data); + return presenter(result); + }; diff --git a/turbo/generators/templates/feature/src/ui/index.ts.hbs b/turbo/generators/templates/feature/src/ui/index.ts.hbs new file mode 100644 index 0000000..a288780 --- /dev/null +++ b/turbo/generators/templates/feature/src/ui/index.ts.hbs @@ -0,0 +1,4 @@ +// Phase-1 placeholder. Re-export React Query option builders here once you +// add `ui/query.ts`. See packages/navigation/src/ui/index.ts for the +// canonical shape. +export {}; diff --git a/turbo/generators/templates/feature/tsconfig.json.hbs b/turbo/generators/templates/feature/tsconfig.json.hbs new file mode 100644 index 0000000..a936111 --- /dev/null +++ b/turbo/generators/templates/feature/tsconfig.json.hbs @@ -0,0 +1,14 @@ +{ + "extends": "@repo/core-typescript/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "lib": ["ES2022", "DOM"], + "jsx": "preserve", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*", "tests/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/turbo/generators/templates/feature/turbo.json.hbs b/turbo/generators/templates/feature/turbo.json.hbs new file mode 100644 index 0000000..8a1d41a --- /dev/null +++ b/turbo/generators/templates/feature/turbo.json.hbs @@ -0,0 +1,4 @@ +{ + "extends": ["//"], + "tags": ["feature"] +} diff --git a/turbo/generators/templates/feature/vitest.config.ts.hbs b/turbo/generators/templates/feature/vitest.config.ts.hbs new file mode 100644 index 0000000..a5caebc --- /dev/null +++ b/turbo/generators/templates/feature/vitest.config.ts.hbs @@ -0,0 +1,47 @@ +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: { + exclude: [ + // DI bootstrap — wires InversifyJS at app startup; not unit-testable + "src/di/bind-production.ts", + // Pure TypeScript interface files — not executable + "src/application/repositories/**", + // Payload CMS templates — declarative data, tested via integration + "src/integrations/cms/**", + // React Query option builders — integration-tested in apps + "src/ui/**", + ], + 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") }, + }, +});