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:
@@ -19,6 +19,7 @@
|
||||
"@repo/core-cms": "workspace:*",
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"@repo/core-trpc": "workspace:^",
|
||||
"@repo/workspaces": "workspace:*",
|
||||
"@sentry/nextjs": "^10.51.0",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Dev-seed boot smoke (platform-retrofit story 04): prove `bindAll()` boots
|
||||
// the app with exactly the auth feature bound — no dangling DI symbols from
|
||||
// the deleted demo features.
|
||||
// the app with exactly the expected features bound — no dangling DI symbols
|
||||
// from the deleted demo features.
|
||||
//
|
||||
// Unlike bind-production.test.ts (which mocks the per-feature binders to test
|
||||
// dispatcher routing), this file runs the REAL auth dev-seed binder, so the
|
||||
@@ -19,7 +19,7 @@ vi.mock("payload", () => ({
|
||||
getPayload: vi.fn(async () => ({ jobs: { queue: vi.fn() } })),
|
||||
}));
|
||||
|
||||
describe("dev-seed boot smoke — bindAll() binds exactly the auth feature", () => {
|
||||
describe("dev-seed boot smoke — bindAll() binds exactly the expected features", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllEnvs();
|
||||
@@ -82,7 +82,7 @@ describe("dev-seed boot smoke — bindAll() binds exactly the auth feature", ()
|
||||
expect(cookie.value).not.toBe("");
|
||||
});
|
||||
|
||||
it("wires auth and nothing else — the dispatcher imports exactly one feature's binders", () => {
|
||||
it("wires auth + workspaces and nothing else — the dispatcher imports exactly the expected features' binders", () => {
|
||||
// vitest runs with cwd at the package root (jsdom rewrites
|
||||
// import.meta.url to an http: URL, so resolve from cwd instead).
|
||||
const source = readFileSync(
|
||||
@@ -94,6 +94,6 @@ describe("dev-seed boot smoke — bindAll() binds exactly the auth feature", ()
|
||||
);
|
||||
|
||||
expect(features.length).toBeGreaterThan(0);
|
||||
expect([...new Set(features)].sort()).toEqual(["auth"]);
|
||||
expect([...new Set(features)].sort()).toEqual(["auth", "workspaces"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
|
||||
import { bindProductionAuth } from "@repo/auth/di/bind-production";
|
||||
import { bindDevSeedAuth } from "@repo/auth/di/bind-dev-seed";
|
||||
import { bindProductionWorkspaces } from "@repo/workspaces/di/bind-production";
|
||||
import { bindDevSeedWorkspaces } from "@repo/workspaces/di/bind-dev-seed";
|
||||
|
||||
let bindPromise: Promise<void> | null = null;
|
||||
|
||||
@@ -92,6 +94,7 @@ export async function bindAllProduction(): Promise<void> {
|
||||
};
|
||||
|
||||
bindProductionAuth(ctx);
|
||||
bindProductionWorkspaces(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +114,7 @@ export async function bindAllDevSeed(): Promise<void> {
|
||||
};
|
||||
|
||||
await bindDevSeedAuth(ctx);
|
||||
await bindDevSeedWorkspaces(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@repo/core-consent": "workspace:*",
|
||||
"@repo/core-dsr": "workspace:*",
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"@repo/workspaces": "workspace:*",
|
||||
"@trpc/server": "^11.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { router } from "@repo/core-shared/trpc/init";
|
||||
import { authRouter } from "@repo/auth/api";
|
||||
import { workspacesRouter } from "@repo/workspaces/api";
|
||||
import { dsrRouter } from "@repo/core-dsr";
|
||||
import { consentRouter } from "@repo/core-consent";
|
||||
|
||||
export const appRouter = router({
|
||||
auth: authRouter,
|
||||
workspaces: workspacesRouter,
|
||||
// gen:routers — optional-core routers composed below
|
||||
dsr: dsrRouter,
|
||||
consent: consentRouter,
|
||||
|
||||
42
packages/workspaces/AGENTS.md
Normal file
42
packages/workspaces/AGENTS.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# AGENTS.md — workspaces
|
||||
|
||||
Feature package scaffolded by `turbo gen feature`. Provides domain logic, repositories, use cases, controllers and the tRPC router for `Workspace`.
|
||||
|
||||
## Overview
|
||||
|
||||
`@repo/workspaces` owns: the `Workspace` domain model, feature-scoped errors, the `IWorkspaceRepository` interface, one use case (`getWorkspaceUseCase`), 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 `workspacesRouter`.
|
||||
|
||||
## Layer responsibilities
|
||||
|
||||
| Layer | Key files |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| **entities/models** | `workspace.ts` — `Workspace` Zod schema + type |
|
||||
| **entities/errors** | `workspace.ts` (WorkspaceNotFoundError), `common.ts` (InputParseError) |
|
||||
| **application/use-cases** | `get-workspace.use-case.ts` — factory function + exported schemas |
|
||||
| **application/repositories** | `workspace.repository.interface.ts` — `IWorkspaceRepository` |
|
||||
| **infrastructure/repositories** | `workspace.repository.ts` (real Payload-backed; stub body), `workspace.repository.mock.ts` (in-memory) |
|
||||
| **interface-adapters/controllers** | `get-workspace.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` (workspacesProcedure), `router.ts` (workspacesRouter) |
|
||||
|
||||
## Public exports
|
||||
|
||||
| Subpath | Contents |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `.` | `Workspace` type; `WorkspaceNotFoundError`, `InputParseError`; `getWorkspaceInputSchema`, `getWorkspaceOutputSchema`, `GetWorkspaceInput`, `GetWorkspaceOutput`, `IGetWorkspaceUseCase`; `IGetWorkspaceController` type alias; `WorkspacesRouter` type |
|
||||
| `./api` | `workspacesRouter` (tRPC router) |
|
||||
| `./di/bind-production` | `bindProductionWorkspaces(config, tracer, logger)` |
|
||||
| `./di/bind-dev-seed` | `bindDevSeedWorkspaces(tracer, logger)` |
|
||||
| `./ui` | (reserved — empty barrel) |
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pnpm test --filter @repo/workspaces
|
||||
```
|
||||
|
||||
## 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
packages/workspaces/CHANGELOG.md
Normal file
9
packages/workspaces/CHANGELOG.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Changelog — @repo/workspaces
|
||||
|
||||
All notable changes to the `workspaces` 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 `workspaces` feature scaffolded via `pnpm turbo gen feature workspaces`. 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/workspaces/**`.
|
||||
3
packages/workspaces/eslint.config.js
Normal file
3
packages/workspaces/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
36
packages/workspaces/package.json
Normal file
36
packages/workspaces/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@repo/workspaces",
|
||||
"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,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");
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
3
packages/workspaces/src/__factories__/index.ts
Normal file
3
packages/workspaces/src/__factories__/index.ts
Normal 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 {};
|
||||
@@ -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 {};
|
||||
16
packages/workspaces/src/__seeds__/dev.ts
Normal file
16
packages/workspaces/src/__seeds__/dev.ts
Normal 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" }],
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Workspace } from "../../entities/models/workspace";
|
||||
|
||||
export interface IWorkspaceRepository {
|
||||
getWorkspace(id: string): Promise<Workspace | null>;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
56
packages/workspaces/src/di/bind-dev-seed.test.ts
Normal file
56
packages/workspaces/src/di/bind-dev-seed.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
112
packages/workspaces/src/di/bind-dev-seed.ts
Normal file
112
packages/workspaces/src/di/bind-dev-seed.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
100
packages/workspaces/src/di/bind-production.ts
Normal file
100
packages/workspaces/src/di/bind-production.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
40
packages/workspaces/src/di/container.test.ts
Normal file
40
packages/workspaces/src/di/container.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
6
packages/workspaces/src/di/container.ts
Normal file
6
packages/workspaces/src/di/container.ts
Normal 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);
|
||||
39
packages/workspaces/src/di/module.ts
Normal file
39
packages/workspaces/src/di/module.ts
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
10
packages/workspaces/src/di/symbols.ts
Normal file
10
packages/workspaces/src/di/symbols.ts
Normal 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;
|
||||
6
packages/workspaces/src/entities/errors/common.ts
Normal file
6
packages/workspaces/src/entities/errors/common.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class InputParseError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "InputParseError";
|
||||
}
|
||||
}
|
||||
6
packages/workspaces/src/entities/errors/workspace.ts
Normal file
6
packages/workspaces/src/entities/errors/workspace.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class WorkspaceNotFoundError extends Error {
|
||||
constructor(message = "Workspace not found", options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "WorkspaceNotFoundError";
|
||||
}
|
||||
}
|
||||
24
packages/workspaces/src/entities/models/workspace.test.ts
Normal file
24
packages/workspaces/src/entities/models/workspace.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
8
packages/workspaces/src/entities/models/workspace.ts
Normal file
8
packages/workspaces/src/entities/models/workspace.ts
Normal 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>;
|
||||
55
packages/workspaces/src/feature.manifest.ts
Normal file
55
packages/workspaces/src/feature.manifest.ts
Normal 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;
|
||||
19
packages/workspaces/src/index.ts
Normal file
19
packages/workspaces/src/index.ts
Normal 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>
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
12
packages/workspaces/src/integrations/api/procedures.ts
Normal file
12
packages/workspaces/src/integrations/api/procedures.ts
Normal 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"],
|
||||
]),
|
||||
);
|
||||
93
packages/workspaces/src/integrations/api/router.test.ts
Normal file
93
packages/workspaces/src/integrations/api/router.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
22
packages/workspaces/src/integrations/api/router.ts
Normal file
22
packages/workspaces/src/integrations/api/router.ts
Normal 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;
|
||||
7
packages/workspaces/src/integrations/cms/index.ts
Normal file
7
packages/workspaces/src/integrations/cms/index.ts
Normal 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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
4
packages/workspaces/src/ui/index.ts
Normal file
4
packages/workspaces/src/ui/index.ts
Normal 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 {};
|
||||
5
packages/workspaces/stryker.config.json
Normal file
5
packages/workspaces/stryker.config.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "../../node_modules/@stryker-mutator/core/schema/stryker-schema.json",
|
||||
"_comment": "workspaces feature mutation testing config. Extends @repo/core-testing/stryker.base.json (ADR-020 L3). Run with `pnpm mutate --filter @repo/workspaces`.",
|
||||
"extends": "@repo/core-testing/stryker.base.json"
|
||||
}
|
||||
14
packages/workspaces/tsconfig.json
Normal file
14
packages/workspaces/tsconfig.json
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
packages/workspaces/turbo.json
Normal file
4
packages/workspaces/turbo.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["feature"]
|
||||
}
|
||||
32
packages/workspaces/vitest.config.ts
Normal file
32
packages/workspaces/vitest.config.ts
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") },
|
||||
},
|
||||
});
|
||||
46
pnpm-lock.yaml
generated
46
pnpm-lock.yaml
generated
@@ -188,6 +188,9 @@ importers:
|
||||
"@repo/core-trpc":
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/core-trpc
|
||||
"@repo/workspaces":
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/workspaces
|
||||
"@sentry/nextjs":
|
||||
specifier: ^10.51.0
|
||||
version: 10.51.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(next@15.5.14(@babel/core@7.25.9)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(webpack@5.106.2)
|
||||
@@ -359,6 +362,9 @@ importers:
|
||||
"@repo/core-shared":
|
||||
specifier: workspace:*
|
||||
version: link:../core-shared
|
||||
"@repo/workspaces":
|
||||
specifier: workspace:*
|
||||
version: link:../workspaces
|
||||
"@trpc/server":
|
||||
specifier: ^11.0.0
|
||||
version: 11.16.0(typescript@5.9.3)
|
||||
@@ -1019,6 +1025,46 @@ importers:
|
||||
specifier: ^3.0.0
|
||||
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
|
||||
|
||||
packages/workspaces:
|
||||
dependencies:
|
||||
"@repo/core-shared":
|
||||
specifier: workspace:*
|
||||
version: link:../core-shared
|
||||
"@trpc/server":
|
||||
specifier: ^11.0.0
|
||||
version: 11.16.0(typescript@5.9.3)
|
||||
inversify:
|
||||
specifier: ^6.2.0
|
||||
version: 6.2.2(reflect-metadata@0.2.2)
|
||||
payload:
|
||||
specifier: ^3.14.0
|
||||
version: 3.81.0(graphql@16.13.2)(typescript@5.9.3)
|
||||
reflect-metadata:
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
zod:
|
||||
specifier: ^3.24.0
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
"@repo/core-eslint":
|
||||
specifier: workspace:*
|
||||
version: link:../core-eslint
|
||||
"@repo/core-testing":
|
||||
specifier: workspace:*
|
||||
version: link:../core-testing
|
||||
"@repo/core-typescript":
|
||||
specifier: workspace:*
|
||||
version: link:../core-typescript
|
||||
"@types/node":
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.17
|
||||
"@vitest/coverage-v8":
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
|
||||
vitest:
|
||||
specifier: ^3.1.0
|
||||
version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
|
||||
|
||||
turbo/generators:
|
||||
dependencies:
|
||||
"@turbo/gen":
|
||||
|
||||
Reference in New Issue
Block a user