feat(turbo): turbo gen feature generator (Phase 1, single-entity)

Adds `pnpm turbo gen feature` to scaffold a Lazar-conformant feature
package matching the navigation reference shape: entity + Zod schema,
single use case (`get<Entity>`), 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 01:09:22 +02:00
parent b4ec48f058
commit 019d4866a0
40 changed files with 1769 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
# Scaffolding a feature
`turbo gen feature` produces a Lazar-conformant feature package under
`packages/<name>/` 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 <name> <Entity> <entities-plural>
```
| Position | Prompt | Example | Conventions |
|---|---|---|---|
| `<name>` | Feature package name | `widgets` | `kebab-case`, becomes `@repo/<name>` and `packages/<name>/` |
| `<Entity>` | Entity name | `Widget` | `PascalCase` singular, drives class/symbol/use-case names |
| `<entities-plural>` | 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/<entity>.ts`) with a Zod schema +
unit test
- One use case (`src/application/use-cases/get-<entity>.use-case.ts`) with
exported input/output schemas, factory function, and tests
- One controller (`src/interface-adapters/controllers/get-<entity>.controller.ts`)
with the canonical safeParse → presenter shape
- Mock + real repository (`src/infrastructure/repositories/<entity>.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<Entity>` 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<Entity>` factories (only stubs)
- Multi-entity / multi-use-case (one `get<Entity>` 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<Name>` and `bindDevSeed<Name>` and call them from the
`bindAll()` dispatcher (production branch + dev-seed branch).
2. **`packages/core-api/src/root.ts`** — import `<name>Router` from
`@repo/<name>/api` and mount it on the app router.
3. **`packages/core-api/package.json`** — add `"@repo/<name>": "workspace:*"`
to dependencies.
4. **`apps/web-next/package.json`** — add `"@repo/<name>": "workspace:*"`
to dependencies.
5. **(Later) Payload CMS** — add a collection at
`packages/<name>/src/integrations/cms/collections/<entities-plural>.ts`
and register it in `packages/core-cms/...`.
6. **Verify**: `pnpm --filter @repo/<name> 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 (R41R44)

View File

@@ -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"

307
pnpm-lock.yaml generated
View File

@@ -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: {}

342
turbo/generators/config.ts Normal file
View File

@@ -0,0 +1,342 @@
import type { PlopTypes } from "@turbo/gen";
/**
* Turbo generator: `feature`
*
* Scaffolds a Lazar-conformant feature package under `packages/<name>/`
* 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/<name> and packages/<name>/):",
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) : "";
}

View File

@@ -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`

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View File

@@ -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"
}
}

View File

@@ -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<readonly [string, {{pascalCase entity}}]> = [
["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>(
"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");
});
});
},
);

View File

@@ -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 {};

View File

@@ -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 {};

View File

@@ -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<string, {{pascalCase entity}}> {
return new Map<string, {{pascalCase entity}}>([
["dev-1", { id: "dev-1", name: "Dev One" }],
["dev-2", { id: "dev-2", name: "Dev Two" }],
["dev-3", { id: "dev-3", name: "Dev Three" }],
]);
}

View File

@@ -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>;
}

View File

@@ -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);
});
});

View File

@@ -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<typeof get{{pascalCase entity}}InputSchema>;
// ── Output ───────────────────────────────────────────────────────────────
export const get{{pascalCase entity}}OutputSchema = {{camelCase entity}}Schema;
export type Get{{pascalCase entity}}Output = z.infer<typeof get{{pascalCase entity}}OutputSchema>;
// ── Use case ─────────────────────────────────────────────────────────────
export type IGet{{pascalCase entity}}UseCase = ReturnType<typeof get{{pascalCase entity}}UseCase>;
export const get{{pascalCase entity}}UseCase =
({{camelCase entity}}Repository: I{{pascalCase entity}}Repository) =>
async (input: Get{{pascalCase entity}}Input): Promise<Get{{pascalCase entity}}Output> => {
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);
};

View File

@@ -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<I{{pascalCase entity}}Repository>({{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<I{{pascalCase entity}}Repository>({{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<I{{pascalCase entity}}Repository>(
{{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<I{{pascalCase entity}}Repository>(
{{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository,
);
await bindDevSeed{{pascalCase name}}(noop.tracer, noop.logger);
const after = {{camelCase name}}Container.get<I{{pascalCase entity}}Repository>(
{{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository,
);
expect(after).not.toBe(before);
});
});

View File

@@ -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<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<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
{{camelCase name}}Container.bind<ILogger>(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<I{{pascalCase entity}}Repository>({{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}}),
),
),
);
}

View File

@@ -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<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
{{camelCase name}}Container.bind<ILogger>(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}}),
),
),
);
}

View File

@@ -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<I{{pascalCase entity}}Repository>(
{{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<IGet{{pascalCase entity}}UseCase>(
{{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<IGet{{pascalCase entity}}Controller>(
{{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}Controller,
);
expect(typeof controller).toBe("function");
});
});

View File

@@ -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);

View File

@@ -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<I{{pascalCase entity}}Repository>({{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository).to(
Mock{{pascalCase entity}}Repository,
);
bind<IGet{{pascalCase entity}}UseCase>(
{{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}UseCase,
).toDynamicValue((ctx) =>
get{{pascalCase entity}}UseCase(
ctx.container.get<I{{pascalCase entity}}Repository>(
{{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository,
),
),
);
bind<IGet{{pascalCase entity}}Controller>(
{{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}Controller,
).toDynamicValue((ctx) =>
get{{pascalCase entity}}Controller(
ctx.container.get<IGet{{pascalCase entity}}UseCase>(
{{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}UseCase,
),
),
);
});

View File

@@ -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;

View File

@@ -0,0 +1,6 @@
export class InputParseError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "InputParseError";
}
}

View File

@@ -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";
}
}

View File

@@ -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();
});
});

View File

@@ -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<typeof {{camelCase entity}}Schema>;

View File

@@ -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";

View File

@@ -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 },
);
});

View File

@@ -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<string, {{pascalCase entity}}>([
["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<string, {{pascalCase entity}}>;
private tracer: ITracer;
private logger: ILogger;
constructor(
initialData?: Map<string, {{pascalCase entity}}>,
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;
},
);
}
}

View File

@@ -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");
});
});

View File

@@ -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);
});
});

View File

@@ -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;
}
},
);
}
}

View File

@@ -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"],
]),
);

View File

@@ -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");
}
});
});

View File

@@ -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<IGet{{pascalCase entity}}Controller>(
{{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}Controller,
);
return ctrl(input);
}),
});
export type {{pascalCase name}}Router = typeof {{camelCase name}}Router;

View File

@@ -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);
});
});

View File

@@ -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<typeof get{{pascalCase entity}}Controller>;
export const get{{pascalCase entity}}Controller =
(get{{pascalCase entity}}UseCase: IGet{{pascalCase entity}}UseCase) =>
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
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);
};

View File

@@ -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 {};

View File

@@ -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"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["feature"]
}

View File

@@ -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") },
},
});