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

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

View File

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

View File

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

View File

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

View File

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

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

View 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/**`.

View File

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

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

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

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

View File

@@ -0,0 +1,14 @@
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"lib": ["ES2022", "DOM"],
"jsx": "preserve",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

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

View File

@@ -0,0 +1,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
View File

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