feat(workspaces): scaffold feature

Scaffold the workspaces feature package via pnpm turbo gen feature
(single Workspace entity, getWorkspace use case) and hand-wire the
aggregators: bindAll dispatcher (web-next), core-api app router, and
workspace deps. Release-please registration reverted per the root-only
versioning policy (AGENTS.md, ADR-027 retrofit). Pinned @trpc/server
to the repo-wide 11.16.0 resolution so instanceof TRPCError checks
share one module instance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
2026-07-12 21:37:17 +02:00
parent 97593593f2
commit 6216897680
46 changed files with 1214 additions and 5 deletions

View File

@@ -0,0 +1,58 @@
import { it, expect, beforeEach, describe } from "vitest";
import { defineContractSuite } from "@repo/core-testing/contract";
import type { IWorkspaceRepository } from "../application/repositories/workspace.repository.interface";
import type { Workspace } from "../entities/models/workspace";
/**
* Known fixtures every implementation's `buildSubject` must pre-seed.
* Exported so test files can pass them to `MockWorkspaceRepository` or the
* Payload stub without duplicating definitions.
*/
export const CONTRACT_WORKSPACE_SEED: ReadonlyArray<
readonly [string, Workspace]
> = [
["seed-1", { id: "seed-1", name: "Seed One" }],
["seed-2", { id: "seed-2", name: "Seed Two" }],
];
/**
* Contract for IWorkspaceRepository.
*
* The interface exposes only `getWorkspace(id)`. The contract verifies
* found vs missing behaviour and span emission.
*/
export const workspaceRepositoryContract =
defineContractSuite<IWorkspaceRepository>(
"IWorkspaceRepository",
({ buildSubject, getTracer }) => {
let repo: IWorkspaceRepository;
beforeEach(async () => {
repo = await buildSubject();
});
it("getWorkspace returns the seeded workspace when id exists", async () => {
const result = await repo.getWorkspace("seed-1");
expect(result).not.toBeNull();
expect(result?.id).toBe("seed-1");
expect(typeof result?.name).toBe("string");
});
it("getWorkspace returns null for an unknown id", async () => {
const result = await repo.getWorkspace("does-not-exist");
expect(result).toBeNull();
});
describe("span emission", () => {
it("getWorkspace emits span 'workspace.getWorkspace' with op=repository", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
await repo.getWorkspace("seed-1");
const span = tracer.findSpan("workspace.getWorkspace");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
});
});
},
);

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,4 @@
// Phase-1 stub. Replace with `defineFactory<Workspace>` once the
// entity shape stabilises. See packages/auth/src/__factories__/ for an
// example.
export {};

View File

@@ -0,0 +1,16 @@
import type { Workspace } from "../entities/models/workspace";
/**
* Realistic dev seed for `bindDevSeedWorkspaces`.
*
* 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 buildDevWorkspaceMap(): Map<string, Workspace> {
return new Map<string, Workspace>([
["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 { Workspace } from "../../entities/models/workspace";
export interface IWorkspaceRepository {
getWorkspace(id: string): Promise<Workspace | null>;
}

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { ZodError } from "zod";
import { getWorkspaceUseCase } from "@/application/use-cases/get-workspace.use-case";
import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock";
import { WorkspaceNotFoundError } from "@/entities/errors/workspace";
describe("getWorkspaceUseCase", () => {
it("returns the seeded workspace by id", async () => {
const repo = new MockWorkspaceRepository();
const useCase = getWorkspaceUseCase(repo);
const result = await useCase({ id: "seed-1" });
expect(result.id).toBe("seed-1");
expect(result.name).toBeTypeOf("string");
});
it("throws WorkspaceNotFoundError when repository returns null", async () => {
const repo = new MockWorkspaceRepository(new Map());
const useCase = getWorkspaceUseCase(repo);
await expect(useCase({ id: "missing" })).rejects.toBeInstanceOf(
WorkspaceNotFoundError,
);
});
it("throws ZodError when repository returns malformed data", async () => {
const malformedRepo = {
getWorkspace: async () => ({ id: "", name: "x" }) as never,
};
const useCase = getWorkspaceUseCase(malformedRepo);
await expect(useCase({ id: "anything" })).rejects.toBeInstanceOf(ZodError);
});
});

View File

@@ -0,0 +1,30 @@
import { z } from "zod";
import { WorkspaceNotFoundError } from "../../entities/errors/workspace";
import { workspaceSchema } from "../../entities/models/workspace";
import type { IWorkspaceRepository } from "../repositories/workspace.repository.interface";
// ── Input ────────────────────────────────────────────────────────────────
export const getWorkspaceInputSchema = z
.object({
id: z.string().min(1),
})
.strict();
export type GetWorkspaceInput = z.infer<typeof getWorkspaceInputSchema>;
// ── Output ───────────────────────────────────────────────────────────────
export const getWorkspaceOutputSchema = workspaceSchema;
export type GetWorkspaceOutput = z.infer<typeof getWorkspaceOutputSchema>;
// ── Use case ─────────────────────────────────────────────────────────────
export type IGetWorkspaceUseCase = ReturnType<typeof getWorkspaceUseCase>;
export const getWorkspaceUseCase =
(workspaceRepository: IWorkspaceRepository) =>
async (input: GetWorkspaceInput): Promise<GetWorkspaceOutput> => {
const result = await workspaceRepository.getWorkspace(input.id);
if (!result) {
throw new WorkspaceNotFoundError(`Workspace not found: ${input.id}`);
}
return getWorkspaceOutputSchema.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 { bindDevSeedWorkspaces } from "@/di/bind-dev-seed";
import { workspacesContainer } from "@/di/container";
import { WORKSPACES_SYMBOLS } from "@/di/symbols";
import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock";
import type { IWorkspaceRepository } from "@/application/repositories/workspace.repository.interface";
const noop = { tracer: new NoopTracer(), logger: new NoopLogger() };
describe("bindDevSeedWorkspaces", () => {
beforeEach(() => {
if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IWorkspaceRepository)) {
workspacesContainer.unbind(WORKSPACES_SYMBOLS.IWorkspaceRepository);
}
workspacesContainer
.bind<IWorkspaceRepository>(WORKSPACES_SYMBOLS.IWorkspaceRepository)
.to(MockWorkspaceRepository);
});
afterEach(() => {
if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IWorkspaceRepository)) {
workspacesContainer.unbind(WORKSPACES_SYMBOLS.IWorkspaceRepository);
}
workspacesContainer
.bind<IWorkspaceRepository>(WORKSPACES_SYMBOLS.IWorkspaceRepository)
.to(MockWorkspaceRepository);
});
it("populates the repository with the dev seed", async () => {
await bindDevSeedWorkspaces(noop);
const repo = workspacesContainer.get<IWorkspaceRepository>(
WORKSPACES_SYMBOLS.IWorkspaceRepository,
);
const found = await repo.getWorkspace("dev-1");
expect(found).not.toBeNull();
expect(found?.id).toBe("dev-1");
});
it("is idempotent — calling twice rebuilds a fresh populated repo", async () => {
await bindDevSeedWorkspaces(noop);
const before = workspacesContainer.get<IWorkspaceRepository>(
WORKSPACES_SYMBOLS.IWorkspaceRepository,
);
await bindDevSeedWorkspaces(noop);
const after = workspacesContainer.get<IWorkspaceRepository>(
WORKSPACES_SYMBOLS.IWorkspaceRepository,
);
expect(after).not.toBe(before);
});
});

View File

@@ -0,0 +1,112 @@
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 { workspacesManifest } from "../feature.manifest";
import { workspacesContainer } from "./container";
import { WORKSPACES_SYMBOLS } from "./symbols";
import { MockWorkspaceRepository } from "../infrastructure/repositories/workspace.repository.mock";
import { buildDevWorkspaceMap } from "../__seeds__/dev";
import { getWorkspaceUseCase } from "../application/use-cases/get-workspace.use-case";
import { getWorkspaceController } from "../interface-adapters/controllers/get-workspace.controller";
import type { IWorkspaceRepository } from "../application/repositories/workspace.repository.interface";
/**
* Replace the default mock with a populated one for dev mode + storybook.
*
* Mutually exclusive with `bindProductionWorkspaces(ctx)`.
* Tests must NOT call this — they construct `new MockWorkspaceRepository()`
* 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 bindDevSeedWorkspaces(ctx: BindContext): Promise<void> {
const { tracer, logger, bus, queue, realtime, realtimeRegistry } = ctx;
// Bind shared instrumentation into feature container
if (workspacesContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
workspacesContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
}
if (workspacesContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
workspacesContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
}
workspacesContainer
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
.toConstantValue(tracer);
workspacesContainer
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
.toConstantValue(logger);
if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IWorkspaceRepository)) {
workspacesContainer.unbind(WORKSPACES_SYMBOLS.IWorkspaceRepository);
}
const repo = new MockWorkspaceRepository(
buildDevWorkspaceMap(),
tracer,
logger,
);
workspacesContainer
.bind<IWorkspaceRepository>(WORKSPACES_SYMBOLS.IWorkspaceRepository)
.toConstantValue(repo);
// Use case
const wrappedGetWorkspace = wireUseCase({
container: workspacesContainer,
symbol: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
factory: getWorkspaceUseCase,
deps: [repo],
feature: "workspaces",
layer: "use-case",
name: "getWorkspace",
tracer,
logger,
});
if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IGetWorkspaceController)) {
workspacesContainer.unbind(WORKSPACES_SYMBOLS.IGetWorkspaceController);
}
workspacesContainer
.bind(WORKSPACES_SYMBOLS.IGetWorkspaceController)
.toConstantValue(
withSpan(
tracer,
{ name: "workspaces.getWorkspace", op: "controller" },
withCapture(
logger,
{
feature: "workspaces",
layer: "controller",
name: "workspaces.getWorkspace",
},
getWorkspaceController(wrappedGetWorkspace),
),
),
);
// 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(
workspacesContainer,
workspacesManifest,
{
getWorkspace: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
},
ctx,
);
}

View File

@@ -0,0 +1,100 @@
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 { workspacesContainer } from "./container";
import { WORKSPACES_SYMBOLS } from "./symbols";
import { workspacesManifest } from "../feature.manifest";
import { WorkspaceRepository } from "../infrastructure/repositories/workspace.repository";
import { getWorkspaceUseCase } from "../application/use-cases/get-workspace.use-case";
import { getWorkspaceController } from "../interface-adapters/controllers/get-workspace.controller";
export function bindProductionWorkspaces(ctx: BindProductionContext): void {
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } =
ctx;
// Bind shared instrumentation into feature container
if (workspacesContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
workspacesContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
}
if (workspacesContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
workspacesContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
}
workspacesContainer
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
.toConstantValue(tracer);
workspacesContainer
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
.toConstantValue(logger);
// Real repository
if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IWorkspaceRepository)) {
workspacesContainer.unbind(WORKSPACES_SYMBOLS.IWorkspaceRepository);
}
const repo = new WorkspaceRepository(config, tracer, logger);
workspacesContainer
.bind(WORKSPACES_SYMBOLS.IWorkspaceRepository)
.toConstantValue(repo);
// Use case
const wrappedGetWorkspace = wireUseCase({
container: workspacesContainer,
symbol: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
factory: getWorkspaceUseCase,
deps: [repo],
feature: "workspaces",
layer: "use-case",
name: "getWorkspace",
tracer,
logger,
});
// Controller — wrapped with span at bind time
if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IGetWorkspaceController)) {
workspacesContainer.unbind(WORKSPACES_SYMBOLS.IGetWorkspaceController);
}
workspacesContainer
.bind(WORKSPACES_SYMBOLS.IGetWorkspaceController)
.toConstantValue(
withSpan(
tracer,
{ name: "workspaces.getWorkspace", op: "controller" },
withCapture(
logger,
{
feature: "workspaces",
layer: "controller",
name: "workspaces.getWorkspace",
},
getWorkspaceController(wrappedGetWorkspace),
),
),
);
// 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(
workspacesContainer,
workspacesManifest,
{
getWorkspace: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
},
ctx,
);
}

View File

@@ -0,0 +1,40 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { workspacesContainer } from "./container";
import { WORKSPACES_SYMBOLS } from "./symbols";
import { WorkspacesModule } from "./module";
import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock";
import type { IWorkspaceRepository } from "@/application/repositories/workspace.repository.interface";
import type { IGetWorkspaceUseCase } from "@/application/use-cases/get-workspace.use-case";
import type { IGetWorkspaceController } from "@/interface-adapters/controllers/get-workspace.controller";
describe("workspacesContainer", () => {
beforeEach(() => {
workspacesContainer.unbindAll();
workspacesContainer.load(WorkspacesModule);
});
afterEach(() => {
workspacesContainer.unbindAll();
});
it("resolves IWorkspaceRepository to MockWorkspaceRepository", () => {
const repo = workspacesContainer.get<IWorkspaceRepository>(
WORKSPACES_SYMBOLS.IWorkspaceRepository,
);
expect(repo).toBeInstanceOf(MockWorkspaceRepository);
});
it("resolves IGetWorkspaceUseCase as a function", () => {
const useCase = workspacesContainer.get<IGetWorkspaceUseCase>(
WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
);
expect(typeof useCase).toBe("function");
});
it("resolves IGetWorkspaceController as a function", () => {
const controller = workspacesContainer.get<IGetWorkspaceController>(
WORKSPACES_SYMBOLS.IGetWorkspaceController,
);
expect(typeof controller).toBe("function");
});
});

View File

@@ -0,0 +1,6 @@
import "reflect-metadata";
import { Container } from "inversify";
import { WorkspacesModule } from "./module";
export const workspacesContainer = new Container({ defaultScope: "Singleton" });
workspacesContainer.load(WorkspacesModule);

View File

@@ -0,0 +1,39 @@
import { ContainerModule, type interfaces } from "inversify";
import type { IWorkspaceRepository } from "../application/repositories/workspace.repository.interface";
import { MockWorkspaceRepository } from "../infrastructure/repositories/workspace.repository.mock";
import {
getWorkspaceUseCase,
type IGetWorkspaceUseCase,
} from "../application/use-cases/get-workspace.use-case";
import {
getWorkspaceController,
type IGetWorkspaceController,
} from "../interface-adapters/controllers/get-workspace.controller";
import { WORKSPACES_SYMBOLS } from "./symbols";
export const WorkspacesModule = new ContainerModule((bind: interfaces.Bind) => {
bind<IWorkspaceRepository>(WORKSPACES_SYMBOLS.IWorkspaceRepository).to(
MockWorkspaceRepository,
);
bind<IGetWorkspaceUseCase>(
WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
).toDynamicValue((ctx) =>
getWorkspaceUseCase(
ctx.container.get<IWorkspaceRepository>(
WORKSPACES_SYMBOLS.IWorkspaceRepository,
),
),
);
bind<IGetWorkspaceController>(
WORKSPACES_SYMBOLS.IGetWorkspaceController,
).toDynamicValue((ctx) =>
getWorkspaceController(
ctx.container.get<IGetWorkspaceUseCase>(
WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
),
),
);
});

View File

@@ -0,0 +1,10 @@
export const WORKSPACES_SYMBOLS = {
IWorkspaceRepository: Symbol.for("workspaces:IWorkspaceRepository"),
// Use cases
IGetWorkspaceUseCase: Symbol.for("workspaces:IGetWorkspaceUseCase"),
// Controllers
IGetWorkspaceController: Symbol.for("workspaces:IGetWorkspaceController"),
// <gen:event-handler-symbols>
// <gen:job-symbols>
// <gen:realtime-handler-symbols>
} 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 WorkspaceNotFoundError extends Error {
constructor(message = "Workspace not found", options?: ErrorOptions) {
super(message, options);
this.name = "WorkspaceNotFoundError";
}
}

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { workspaceSchema } from "./workspace";
describe("workspaceSchema", () => {
it("accepts a valid workspace", () => {
const result = workspaceSchema.parse({ id: "1", name: "Example" });
expect(result.id).toBe("1");
expect(result.name).toBe("Example");
});
it("rejects an empty id", () => {
expect(() => workspaceSchema.parse({ id: "", name: "ok" })).toThrow();
});
it("rejects an empty name", () => {
expect(() => workspaceSchema.parse({ id: "1", name: "" })).toThrow();
});
it("rejects a name over 128 chars", () => {
expect(() =>
workspaceSchema.parse({ id: "1", name: "x".repeat(129) }),
).toThrow();
});
});

View File

@@ -0,0 +1,8 @@
import { z } from "zod";
export const workspaceSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(128),
});
export type Workspace = z.infer<typeof workspaceSchema>;

View File

@@ -0,0 +1,55 @@
import { defineFeature } from "@repo/core-shared/conformance";
/**
* The workspaces 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 workspacesManifest = defineFeature({
name: "workspaces",
requiredCores: [],
useCases: {
getWorkspace: {
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 WorkspacesManifest = typeof workspacesManifest;

View File

@@ -0,0 +1,19 @@
export type { Workspace } from "./entities/models/workspace";
export type { WorkspacesRouter } from "./integrations/api/router";
export { WorkspaceNotFoundError } from "./entities/errors/workspace";
export { InputParseError } from "./entities/errors/common";
// Use case schemas + types
export {
getWorkspaceInputSchema,
getWorkspaceOutputSchema,
type GetWorkspaceInput,
type GetWorkspaceOutput,
type IGetWorkspaceUseCase,
} from "./application/use-cases/get-workspace.use-case";
// Controller type aliases
export type { IGetWorkspaceController } from "./interface-adapters/controllers/get-workspace.controller";
// <gen:events>
// <gen:realtime-channels>

View File

@@ -0,0 +1,15 @@
import { describe } from "vitest";
import { RecordingTracer } from "@repo/core-testing/instrumentation";
import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock";
import {
workspaceRepositoryContract,
CONTRACT_WORKSPACE_SEED,
} from "@/__contracts__/workspace-repository.contract";
describe("MockWorkspaceRepository", () => {
const tracer = new RecordingTracer();
workspaceRepositoryContract.run(
() => new MockWorkspaceRepository(new Map(CONTRACT_WORKSPACE_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 { IWorkspaceRepository } from "../../application/repositories/workspace.repository.interface";
import type { Workspace } from "../../entities/models/workspace";
const DEFAULT_DATA = new Map<string, Workspace>([
["seed-1", { id: "seed-1", name: "Seed One" }],
["seed-2", { id: "seed-2", name: "Seed Two" }],
]);
@injectable()
export class MockWorkspaceRepository implements IWorkspaceRepository {
private readonly data: Map<string, Workspace>;
private tracer: ITracer;
private logger: ILogger;
constructor(
initialData?: Map<string, Workspace>,
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 getWorkspace(id: string): Promise<Workspace | null> {
return this.tracer.startSpan(
{ name: "workspace.getWorkspace", op: "repository", attributes: {} },
async (span) => {
const found = this.data.get(id) ?? null;
span.setAttribute("found", found !== null);
return found;
},
);
}
}

View File

@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import {
RecordingTracer,
RecordingLogger,
} from "@repo/core-testing/instrumentation";
import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock";
// Mock repo also wraps in spans.
describe("MockWorkspaceRepository emits spans", () => {
it("getWorkspace emits one span with op='repository'", async () => {
const tracer = new RecordingTracer();
const logger = new RecordingLogger();
const repo = new MockWorkspaceRepository(undefined, tracer, logger);
await repo.getWorkspace("seed-1");
expect(tracer.spans).toHaveLength(1);
expect(tracer.spans[0]).toMatchObject({
name: "workspace.getWorkspace",
op: "repository",
});
expect(typeof tracer.spans[0]!.attributes.found).toBe("boolean");
});
});

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import {
RecordingTracer,
RecordingLogger,
} from "@repo/core-testing/instrumentation";
import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config";
import { WorkspaceRepository } from "@/infrastructure/repositories/workspace.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("WorkspaceRepository (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 WorkspaceRepository(stubPayloadConfig, tracer, logger);
const result = await repo.getWorkspace("anything");
expect(result).toBeNull();
expect(tracer.spans).toHaveLength(1);
expect(tracer.spans[0]).toMatchObject({
name: "workspace.getWorkspace",
op: "repository",
});
});
it("records the requested id as a span attribute", async () => {
const tracer = new RecordingTracer();
const repo = new WorkspaceRepository(stubPayloadConfig, tracer);
await repo.getWorkspace("custom-id");
expect(tracer.spans[0]!.attributes.id).toBe("custom-id");
expect(tracer.spans[0]!.attributes.found).toBe(false);
});
});

View File

@@ -0,0 +1,65 @@
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 { IWorkspaceRepository } from "../../application/repositories/workspace.repository.interface";
import type { Workspace } from "../../entities/models/workspace";
const FEATURE = "workspaces" as const;
const REPO = "workspace" 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/workspace.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 WorkspaceRepository implements IWorkspaceRepository {
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 getWorkspace(id: string): Promise<Workspace | null> {
return this.tracer.startSpan(
{ name: "workspace.getWorkspace", op: "repository", attributes: {} },
async (span) => {
try {
// TODO: replace with `payload.find({ collection: "workspaces", 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: "getWorkspace" },
});
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 { WorkspaceNotFoundError } from "../../entities/errors/workspace";
import { InputParseError } from "../../entities/errors/common";
export const workspacesProcedure = t.procedure.use(
defineErrorMiddleware([
[InputParseError, "BAD_REQUEST"],
[WorkspaceNotFoundError, "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 { workspacesContainer } from "@/di/container";
import { WorkspacesModule } from "@/di/module";
import { WORKSPACES_SYMBOLS } from "@/di/symbols";
import { getWorkspaceUseCase } from "@/application/use-cases/get-workspace.use-case";
import { getWorkspaceController } from "@/interface-adapters/controllers/get-workspace.controller";
import { workspacesRouter } from "@/integrations/api/router";
describe("workspacesRouter", () => {
beforeEach(() => {
workspacesContainer.unbindAll();
workspacesContainer.load(WorkspacesModule);
});
afterEach(() => {
workspacesContainer.unbindAll();
});
it("exposes the getWorkspace procedure", () => {
const names = Object.keys(workspacesRouter._def.procedures);
expect(names).toContain("getWorkspace");
});
it("getWorkspace returns the seeded workspace", async () => {
const caller = workspacesRouter.createCaller({});
const result = await caller.getWorkspace({ id: "seed-1" });
expect(result.id).toBe("seed-1");
});
});
describe("workspacesRouter (error mapping)", () => {
beforeEach(() => {
workspacesContainer.unbindAll();
workspacesContainer.load(WorkspacesModule);
});
afterEach(() => {
workspacesContainer.unbindAll();
});
it("translates InputParseError → BAD_REQUEST when extra fields are passed", async () => {
const caller = workspacesRouter.createCaller({});
try {
await caller.getWorkspace({
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 WorkspaceNotFoundError → NOT_FOUND when repository returns null", async () => {
@injectable()
class NullWorkspaceRepository {
async getWorkspace() {
return null;
}
}
workspacesContainer.unbindAll();
workspacesContainer
.bind(WORKSPACES_SYMBOLS.IWorkspaceRepository)
.to(NullWorkspaceRepository);
workspacesContainer
.bind(WORKSPACES_SYMBOLS.IGetWorkspaceUseCase)
.toDynamicValue((ctx) =>
getWorkspaceUseCase(
ctx.container.get(WORKSPACES_SYMBOLS.IWorkspaceRepository),
),
);
workspacesContainer
.bind(WORKSPACES_SYMBOLS.IGetWorkspaceController)
.toDynamicValue((ctx) =>
getWorkspaceController(
ctx.container.get(WORKSPACES_SYMBOLS.IGetWorkspaceUseCase),
),
);
const caller = workspacesRouter.createCaller({});
try {
await caller.getWorkspace({ 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 { workspacesContainer } from "../../di/container";
import { WORKSPACES_SYMBOLS } from "../../di/symbols";
import { getWorkspaceInputSchema } from "../../application/use-cases/get-workspace.use-case";
import type { IGetWorkspaceController } from "../../interface-adapters/controllers/get-workspace.controller";
import { workspacesProcedure } from "./procedures";
export const workspacesRouter = router({
getWorkspace: workspacesProcedure
.input(getWorkspaceInputSchema)
.query(({ input }) => {
const ctrl = workspacesContainer.get<IGetWorkspaceController>(
WORKSPACES_SYMBOLS.IGetWorkspaceController,
);
return ctrl(input);
}),
});
export type WorkspacesRouter = typeof workspacesRouter;

View File

@@ -0,0 +1,7 @@
// Payload CMS integration barrel for @repo/workspaces.
// Re-export this feature's collections and globals here as you add them
// under ./collections and ./globals. See packages/auth/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>

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { getWorkspaceController } from "@/interface-adapters/controllers/get-workspace.controller";
import { getWorkspaceUseCase } from "@/application/use-cases/get-workspace.use-case";
import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock";
import { InputParseError } from "@/entities/errors/common";
describe("getWorkspaceController", () => {
it("returns the workspace for a valid id", async () => {
const repo = new MockWorkspaceRepository();
const useCase = getWorkspaceUseCase(repo);
const controller = getWorkspaceController(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 MockWorkspaceRepository();
const useCase = getWorkspaceUseCase(repo);
const controller = getWorkspaceController(useCase);
await expect(controller({})).rejects.toBeInstanceOf(InputParseError);
});
it("throws InputParseError when input has unexpected fields (strict)", async () => {
const repo = new MockWorkspaceRepository();
const useCase = getWorkspaceUseCase(repo);
const controller = getWorkspaceController(useCase);
await expect(
controller({ id: "seed-1", unexpected: true }),
).rejects.toBeInstanceOf(InputParseError);
});
});

View File

@@ -0,0 +1,25 @@
import { InputParseError } from "../../entities/errors/common";
import {
getWorkspaceInputSchema,
type GetWorkspaceOutput,
type IGetWorkspaceUseCase,
} from "../../application/use-cases/get-workspace.use-case";
function presenter(value: GetWorkspaceOutput) {
return value;
}
export type IGetWorkspaceController = ReturnType<typeof getWorkspaceController>;
export const getWorkspaceController =
(getWorkspaceUseCase: IGetWorkspaceUseCase) =>
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = getWorkspaceInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid get-workspace input", {
cause: parsed.error,
});
}
const result = await getWorkspaceUseCase(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/auth/src/ui/index.ts for the
// canonical shape.
export {};