Initial commit
This commit is contained in:
42
turbo/generators/templates/feature/AGENTS.md.hbs
Normal file
42
turbo/generators/templates/feature/AGENTS.md.hbs
Normal 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 — empty barrel) |
|
||||
|
||||
## 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`
|
||||
9
turbo/generators/templates/feature/CHANGELOG.md.hbs
Normal file
9
turbo/generators/templates/feature/CHANGELOG.md.hbs
Normal file
@@ -0,0 +1,9 @@
|
||||
# Changelog — @repo/{{kebabCase name}}
|
||||
|
||||
All notable changes to the `{{kebabCase name}}` feature package. Maintained by [release-please](https://github.com/googleapis/release-please) on merges to `main`. See [ADR-021](../../docs/decisions/adr-021-versioning-and-changelog.md) and [`docs/guides/releasing.md`](../../docs/guides/releasing.md).
|
||||
|
||||
## 0.1.0 (Initial)
|
||||
|
||||
### Initial baseline
|
||||
|
||||
The `{{kebabCase name}}` feature scaffolded via `pnpm turbo gen feature {{kebabCase name}}`. Tracked at v0.1.0 in the release-please manifest; future entries appear above this section as release-please assembles them from conventional commits scoped to `packages/{{kebabCase name}}/**`.
|
||||
3
turbo/generators/templates/feature/eslint.config.js.hbs
Normal file
3
turbo/generators/templates/feature/eslint.config.js.hbs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
36
turbo/generators/templates/feature/package.json.hbs
Normal file
36
turbo/generators/templates/feature/package.json.hbs
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@repo/{{kebabCase name}}",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cms": "./src/integrations/cms/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"
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
import type { {{pascalCase entity}} } from "../entities/models/{{kebabCase entity}}";
|
||||
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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 {};
|
||||
@@ -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 {};
|
||||
16
turbo/generators/templates/feature/src/__seeds__/dev.ts.hbs
Normal file
16
turbo/generators/templates/feature/src/__seeds__/dev.ts.hbs
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { {{pascalCase entity}} } from "../entities/models/{{kebabCase entity}}";
|
||||
|
||||
/**
|
||||
* 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" }],
|
||||
]);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
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);
|
||||
const before = {{camelCase name}}Container.get<I{{pascalCase entity}}Repository>(
|
||||
{{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository,
|
||||
);
|
||||
|
||||
await bindDevSeed{{pascalCase name}}(noop);
|
||||
const after = {{camelCase name}}Container.get<I{{pascalCase entity}}Repository>(
|
||||
{{constantCase name}}_SYMBOLS.I{{pascalCase entity}}Repository,
|
||||
);
|
||||
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
});
|
||||
100
turbo/generators/templates/feature/src/di/bind-dev-seed.ts.hbs
Normal file
100
turbo/generators/templates/feature/src/di/bind-dev-seed.ts.hbs
Normal file
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
withSpan,
|
||||
withCapture,
|
||||
INSTRUMENTATION_SYMBOLS,
|
||||
type ITracer,
|
||||
type ILogger,
|
||||
} from "@repo/core-shared/instrumentation";
|
||||
import type { BindContext } from "@repo/core-shared/di";
|
||||
import {
|
||||
assertFeatureConformance,
|
||||
wireUseCase,
|
||||
} from "@repo/core-shared/conformance";
|
||||
import { {{camelCase name}}Manifest } from "../feature.manifest";
|
||||
import { {{camelCase name}}Container } from "./container";
|
||||
import { {{constantCase name}}_SYMBOLS } from "./symbols";
|
||||
import { Mock{{pascalCase entity}}Repository } from "../infrastructure/repositories/{{kebabCase entity}}.repository.mock";
|
||||
import { buildDev{{pascalCase entity}}Map } from "../__seeds__/dev";
|
||||
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 type { I{{pascalCase entity}}Repository } from "../application/repositories/{{kebabCase entity}}.repository.interface";
|
||||
|
||||
/**
|
||||
* Replace the default mock with a populated one for dev mode + storybook.
|
||||
*
|
||||
* Mutually exclusive with `bindProduction{{pascalCase name}}(ctx)`.
|
||||
* 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}}(ctx: BindContext): Promise<void> {
|
||||
const { tracer, logger, bus, queue, realtime, realtimeRegistry } = ctx;
|
||||
|
||||
// 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);
|
||||
|
||||
// Use case
|
||||
const wrappedGet{{pascalCase entity}} = wireUseCase({
|
||||
container: {{camelCase name}}Container,
|
||||
symbol: {{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}UseCase,
|
||||
factory: get{{pascalCase entity}}UseCase,
|
||||
deps: [repo],
|
||||
feature: "{{kebabCase name}}",
|
||||
layer: "use-case",
|
||||
name: "get{{pascalCase entity}}",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
|
||||
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}}),
|
||||
),
|
||||
),
|
||||
);
|
||||
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||
void bus;
|
||||
void queue;
|
||||
void realtime;
|
||||
void realtimeRegistry;
|
||||
// <gen:event-handlers>
|
||||
// <gen:jobs>
|
||||
// <gen:realtime-handlers>
|
||||
|
||||
// Boot-time conformance check (dev-seed mode).
|
||||
assertFeatureConformance(
|
||||
{{camelCase name}}Container,
|
||||
{{camelCase name}}Manifest,
|
||||
{
|
||||
get{{pascalCase entity}}: {{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}UseCase,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
withSpan,
|
||||
withCapture,
|
||||
INSTRUMENTATION_SYMBOLS,
|
||||
type ITracer,
|
||||
type ILogger,
|
||||
} from "@repo/core-shared/instrumentation";
|
||||
import type { BindProductionContext } from "@repo/core-shared/di";
|
||||
import {
|
||||
assertFeatureConformance,
|
||||
wireUseCase,
|
||||
} from "@repo/core-shared/conformance";
|
||||
import { {{camelCase name}}Container } from "./container";
|
||||
import { {{constantCase name}}_SYMBOLS } from "./symbols";
|
||||
import { {{camelCase name}}Manifest } from "../feature.manifest";
|
||||
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}}(ctx: BindProductionContext): void {
|
||||
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } = ctx;
|
||||
|
||||
// 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
|
||||
const wrappedGet{{pascalCase entity}} = wireUseCase({
|
||||
container: {{camelCase name}}Container,
|
||||
symbol: {{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}UseCase,
|
||||
factory: get{{pascalCase entity}}UseCase,
|
||||
deps: [repo],
|
||||
feature: "{{kebabCase name}}",
|
||||
layer: "use-case",
|
||||
name: "get{{pascalCase entity}}",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
|
||||
// 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}}),
|
||||
),
|
||||
),
|
||||
);
|
||||
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||
void bus;
|
||||
void queue;
|
||||
void realtime;
|
||||
void realtimeRegistry;
|
||||
// <gen:event-handlers>
|
||||
// <gen:jobs>
|
||||
// <gen:realtime-handlers>
|
||||
|
||||
// Boot-time conformance check: refuses to start if any use-case binding
|
||||
// is missing a required brand (withSpan / withCapture / withAudit).
|
||||
assertFeatureConformance(
|
||||
{{camelCase name}}Container,
|
||||
{{camelCase name}}Manifest,
|
||||
{
|
||||
get{{pascalCase entity}}: {{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}UseCase,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
39
turbo/generators/templates/feature/src/di/module.ts.hbs
Normal file
39
turbo/generators/templates/feature/src/di/module.ts.hbs
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
10
turbo/generators/templates/feature/src/di/symbols.ts.hbs
Normal file
10
turbo/generators/templates/feature/src/di/symbols.ts.hbs
Normal file
@@ -0,0 +1,10 @@
|
||||
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"),
|
||||
// <gen:event-handler-symbols>
|
||||
// <gen:job-symbols>
|
||||
// <gen:realtime-handler-symbols>
|
||||
} as const;
|
||||
@@ -0,0 +1,6 @@
|
||||
export class InputParseError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "InputParseError";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { defineFeature } from "@repo/core-shared/conformance";
|
||||
|
||||
/**
|
||||
* The {{camelCase name}} feature's conformance manifest. Drives binding-slot
|
||||
* types in `di/bind-production.ts` and is read by ESLint, the boot
|
||||
* assertion, and the CI drift gate.
|
||||
*
|
||||
* Conventions:
|
||||
* - `mutates: true` for any use case that creates, updates, or deletes state
|
||||
* - `audits` lists every audit event the use case emits (must match calls
|
||||
* to `auditLog.record(...)` in the factory body — ESLint enforces this)
|
||||
* - `publishes` / `consumes` cover cross-feature events through `IEventBus`
|
||||
*/
|
||||
export const {{camelCase name}}Manifest = defineFeature({
|
||||
name: "{{kebabCase name}}",
|
||||
requiredCores: [],
|
||||
useCases: {
|
||||
get{{pascalCase entity}}: {
|
||||
mutates: false,
|
||||
audits: [],
|
||||
publishes: [],
|
||||
consumes: [],
|
||||
analyticsEvents: [],
|
||||
},
|
||||
},
|
||||
realtimeChannels: [],
|
||||
jobs: [],
|
||||
// <gen:coverage>
|
||||
// Coverage bands — single source of truth (ADR-020). Read by:
|
||||
// - vitest.config.ts (test-time thresholds, L0)
|
||||
// - assertFeatureConformance (boot-time, fails dev/prod on drift)
|
||||
// - pnpm coverage:diff (cover-the-diff gate, L1)
|
||||
// Edit here; the helper in vitest.config picks up the new numbers.
|
||||
coverage: {
|
||||
bands: {
|
||||
baseline: { statements: 80, branches: 75, functions: 80, lines: 80 },
|
||||
entities: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
"use-cases": { statements: 100, branches: 95, functions: 100, lines: 100 },
|
||||
controllers: { statements: 100, branches: 95, functions: 100, lines: 100 },
|
||||
},
|
||||
mutationTargets: ["entities", "use-cases"],
|
||||
},
|
||||
} as const);
|
||||
|
||||
export type {{pascalCase name}}Manifest = typeof {{camelCase name}}Manifest;
|
||||
19
turbo/generators/templates/feature/src/index.ts.hbs
Normal file
19
turbo/generators/templates/feature/src/index.ts.hbs
Normal file
@@ -0,0 +1,19 @@
|
||||
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";
|
||||
|
||||
// <gen:events>
|
||||
// <gen:realtime-channels>
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
]),
|
||||
);
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
// Payload CMS integration barrel for @repo/{{kebabCase name}}.
|
||||
// Re-export this feature's collections and globals here as you add them
|
||||
// under ./collections and ./globals. See packages/navigation/src/integrations/cms
|
||||
// for the canonical shape. The `<gen:job-tasks>` anchor below is required by
|
||||
// `pnpm turbo gen job` and `pnpm turbo gen event` — do not remove it.
|
||||
export {};
|
||||
// <gen:job-tasks>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
4
turbo/generators/templates/feature/src/ui/index.ts.hbs
Normal file
4
turbo/generators/templates/feature/src/ui/index.ts.hbs
Normal 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 {};
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "../../node_modules/@stryker-mutator/core/schema/stryker-schema.json",
|
||||
"_comment": "{{kebabCase name}} feature mutation testing config. Extends @repo/core-testing/stryker.base.json (ADR-020 L3). Run with `pnpm mutate --filter @repo/{{kebabCase name}}`.",
|
||||
"extends": "@repo/core-testing/stryker.base.json"
|
||||
}
|
||||
14
turbo/generators/templates/feature/tsconfig.json.hbs
Normal file
14
turbo/generators/templates/feature/tsconfig.json.hbs
Normal 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"]
|
||||
}
|
||||
4
turbo/generators/templates/feature/turbo.json.hbs
Normal file
4
turbo/generators/templates/feature/turbo.json.hbs
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["feature"]
|
||||
}
|
||||
32
turbo/generators/templates/feature/vitest.config.ts.hbs
Normal file
32
turbo/generators/templates/feature/vitest.config.ts.hbs
Normal file
@@ -0,0 +1,32 @@
|
||||
import path from "node:path";
|
||||
import { mergeConfig } from "vitest/config";
|
||||
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
|
||||
import {
|
||||
DEFAULT_COVERAGE_BANDS,
|
||||
vitestThresholdsFromBands,
|
||||
} from "@repo/core-shared/conformance/coverage";
|
||||
|
||||
// Coverage thresholds derived from DEFAULT_COVERAGE_BANDS via the shared
|
||||
// helper (ADR-020). The feature.manifest.ts `coverage.bands` section
|
||||
// declares these for boot-time `assertFeatureConformance`. Edit the
|
||||
// manifest when adjusting per-feature bands.
|
||||
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: vitestThresholdsFromBands(DEFAULT_COVERAGE_BANDS),
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "./src") },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user