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