chore: delete legacy packages (api, api-client, cms-client, cms-core, core, ui)

This commit is contained in:
2026-05-05 09:13:08 +02:00
parent f17f24e8f9
commit acae859773
114 changed files with 2 additions and 5852 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,166 +0,0 @@
# @repo/api-client -- Framework-Agnostic tRPC + React Query Provider
## Purpose
This package provides a framework-agnostic tRPC client and React Query provider that any frontend app (Next.js, TanStack Start, or future frameworks) can consume. It exposes `<ApiProvider>` for initialization and `useTRPC()` for fully typed data fetching. It contains zero business logic.
## Hard Rules
- **NEVER** import framework-specific code (no `next/`, no `@tanstack/start`, no `vinxi/`)
- **NEVER** put business logic in this package
- **NEVER** create custom hooks that duplicate what `useTRPC()` already provides
- Both Next.js and TanStack Start apps use the same `<ApiProvider>` and `useTRPC()`
- This package depends on `@repo/api` for the `AppRouter` type only (no runtime import)
## File Structure
```
packages/api-client/
src/
trpc.ts # Creates TRPCProvider + useTRPC via createTRPCContext<AppRouter>()
query-client.ts # Singleton QueryClient factory (SSR-safe)
provider.tsx # <ApiProvider> component: wires tRPC client + QueryClientProvider
index.ts # Package entry: re-exports ApiProvider, useTRPC, getQueryClient
package.json
AGENTS.md
```
## How Apps Consume This Package
### Step 1: Wrap your app with `<ApiProvider>`
The provider needs a `trpcUrl` pointing to the tRPC HTTP endpoint:
```tsx
// apps/web-next/src/app/providers.tsx
"use client";
import { ApiProvider } from "@repo/api-client";
export function Providers({ children }: { children: React.ReactNode }) {
return <ApiProvider trpcUrl="/api/trpc">{children}</ApiProvider>;
}
```
```tsx
// apps/web-next/src/app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
```
For TanStack Start, the provider goes in the root route:
```tsx
// apps/web-tanstack/src/routes/__root.tsx
import { Outlet, createRootRoute } from "@tanstack/react-router";
import { ApiProvider } from "@repo/api-client";
export const Route = createRootRoute({
component: () => (
<ApiProvider trpcUrl="http://localhost:3000/api/trpc">
<Outlet />
</ApiProvider>
),
});
```
### Step 2: Use `useTRPC()` in components
```tsx
"use client";
import { useTRPC } from "@repo/api-client";
import { useQuery, useMutation } from "@tanstack/react-query";
export function ArticleList() {
const trpc = useTRPC();
// Query -- reads data
const { data, isLoading, error } = useQuery(
trpc.content.listArticles.queryOptions({ status: "published", limit: 10 })
);
// Mutation -- writes data
const createArticle = useMutation(
trpc.content.createArticle.mutationOptions()
);
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
<ul>
{data?.map((article) => (
<li key={article.id}>{article.title}</li>
))}
</ul>
<button
onClick={() =>
createArticle.mutate({
title: "New Article",
content: "Hello world",
authorId: "user-1",
})
}
>
Create Article
</button>
</div>
);
}
```
## The `useTRPC()` Pattern
`useTRPC()` is created by `createTRPCContext<AppRouter>()` from `@trpc/tanstack-react-query`. It returns a proxy object that mirrors the router structure:
```
useTRPC()
.auth
.signIn.mutationOptions()
.signUp.mutationOptions()
.signOut.mutationOptions()
.content
.listArticles.queryOptions({ ... })
.createArticle.mutationOptions()
```
You pass `.queryOptions()` to `useQuery()` and `.mutationOptions()` to `useMutation()` from `@tanstack/react-query`. This gives you full control over caching, refetching, optimistic updates, and all React Query features.
## Custom Hook Wrappers Are Optional
Since `useTRPC()` gives fully typed access to every procedure, you do **not** need to create wrapper hooks like `useArticles()`. Only create a custom hook if you have shared logic (e.g., combining multiple queries, adding retry logic, or transforming results) that would otherwise be duplicated across multiple components.
## QueryClient Configuration
The `getQueryClient()` factory in `query-client.ts` handles SSR correctly:
- **Server-side:** Creates a new `QueryClient` per request (avoids cross-request data leaks)
- **Client-side:** Returns a singleton `QueryClient` (reused across renders)
- Default `staleTime` is 30 seconds
## Dependencies
| Dependency | Purpose |
|---|---|
| `@repo/api` | `AppRouter` type for end-to-end type safety (type-only import) |
| `@trpc/client` | tRPC client with `httpBatchLink` |
| `@trpc/tanstack-react-query` | `createTRPCContext` for React Query integration |
| `@tanstack/react-query` | `QueryClient`, `QueryClientProvider` |
| `react` | JSX runtime for provider component |
## Cross-References
- **Router types come from:** `packages/api/` -- see `packages/api/AGENTS.md`
- **tRPC HTTP endpoint:** `apps/web-next/src/app/api/trpc/[trpc]/route.ts`
- **Provider usage in Next.js:** `apps/web-next/src/app/providers.tsx`
- **Provider usage in TanStack:** `apps/web-tanstack/src/routes/__root.tsx`

View File

@@ -1,25 +0,0 @@
{
"name": "@repo/api-client",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "echo 'typechecked by consuming app bundler'",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/api": "workspace:*",
"@trpc/client": "^11.1.0",
"@trpc/tanstack-react-query": "^11.1.0",
"@tanstack/react-query": "^5.75.0",
"react": "^19.0.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/react": "^19.0.0"
}
}

View File

@@ -1,3 +0,0 @@
export { ApiProvider } from "./provider";
export { useTRPC } from "./trpc";
export { getQueryClient } from "./query-client";

View File

@@ -1,26 +0,0 @@
"use client";
import { QueryClientProvider } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "@repo/api";
import { TRPCProvider } from "./trpc";
import { getQueryClient } from "./query-client";
export function ApiProvider({
children,
trpcUrl,
}: {
children: React.ReactNode;
trpcUrl: string;
}) {
const queryClient = getQueryClient();
const trpcClient = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: trpcUrl })],
});
return (
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</TRPCProvider>
);
}

View File

@@ -1,21 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
let clientQueryClient: QueryClient | undefined;
export function getQueryClient(): QueryClient {
if (typeof window === "undefined") {
return new QueryClient({
defaultOptions: {
queries: { staleTime: 30 * 1000 },
},
});
}
if (!clientQueryClient) {
clientQueryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 30 * 1000 },
},
});
}
return clientQueryClient;
}

View File

@@ -1,4 +0,0 @@
import { createTRPCContext } from "@trpc/tanstack-react-query";
import type { AppRouter } from "@repo/api";
export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();

View File

@@ -1,11 +0,0 @@
{
"extends": "@repo/typescript-config/react-library.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,144 +0,0 @@
# @repo/api -- tRPC v11 Router Definitions
## Purpose
This package defines all tRPC v11 routers for the monorepo. Each router validates input with Zod and delegates execution to controllers in `@repo/core`. Routers are the **only** entry point for client-side RPC calls. They contain zero business logic.
## Hard Rules
- **NEVER** put business logic in routers -- always delegate to a controller from `@repo/core`
- **NEVER** import from `@repo/core/infrastructure` or any `apps/*` package
- Input validation uses Zod schemas inline on each procedure
- Each domain gets its own `{domain}.router.ts` file
- Use `.query()` for reads (GET-like), `.mutation()` for writes (POST/PUT/DELETE-like)
- All procedures call exactly one controller function from `@repo/core`
## File Structure
```
packages/api/
src/
trpc.ts # tRPC initialization, exports router + publicProcedure
router/
index.ts # appRouter composition, exports AppRouter type
auth.router.ts # Auth domain: signIn, signUp, signOut
content.router.ts # Content domain: listArticles, createArticle
index.ts # Package entry: re-exports appRouter + AppRouter type
package.json
AGENTS.md
```
## How Procedures Map to Controllers
| Procedure type | HTTP equivalent | When to use | Example |
|---|---|---|---|
| `.query()` | GET | Fetching/reading data | `content.listArticles` |
| `.mutation()` | POST/PATCH/DELETE | Creating, updating, deleting data | `auth.signIn`, `content.createArticle` |
Every procedure follows the same pattern:
```typescript
myProcedure: publicProcedure
.input(z.object({ /* Zod schema */ }))
.query(async ({ input }) => { // or .mutation()
return await myController(input); // delegate to @repo/core controller
}),
```
## Existing Routers
### auth.router.ts
| Procedure | Type | Input | Controller |
|---|---|---|---|
| `signIn` | mutation | `{ username: string, password: string }` | `signInController` |
| `signUp` | mutation | `{ username: string, password: string, confirmPassword: string }` | `signUpController` |
| `signOut` | mutation | `{ sessionId: string }` | `signOutController` |
### content.router.ts
| Procedure | Type | Input | Controller |
|---|---|---|---|
| `listArticles` | query | `{ status?, authorId?, limit?, offset? }` (optional) | `getArticlesController` |
| `createArticle` | mutation | `{ title: string, content: string, authorId: string, slug?: string }` | `createArticleController` |
## Recipe: Adding a New tRPC Router
This example adds a `comments` domain router with `listComments` (query) and `createComment` (mutation).
### Step 1: Create the router file
Create `src/router/comments.router.ts`:
```typescript
import { z } from "zod";
import { router, publicProcedure } from "../trpc";
import {
getCommentsController,
createCommentController,
} from "@repo/core";
export const commentsRouter = router({
listComments: publicProcedure
.input(
z
.object({
articleId: z.string(),
limit: z.number().optional(),
offset: z.number().optional(),
})
)
.query(async ({ input }) => {
return await getCommentsController(input);
}),
createComment: publicProcedure
.input(
z.object({
articleId: z.string(),
authorId: z.string(),
body: z.string().min(1).max(2000),
})
)
.mutation(async ({ input }) => {
return await createCommentController(input);
}),
});
```
### Step 2: Register in the appRouter
Edit `src/router/index.ts`:
```typescript
import { router } from "../trpc";
import { authRouter } from "./auth.router";
import { contentRouter } from "./content.router";
import { commentsRouter } from "./comments.router"; // <-- add import
export const appRouter = router({
auth: authRouter,
content: contentRouter,
comments: commentsRouter, // <-- register here
});
export type AppRouter = typeof appRouter;
```
### Step 3: Verify
The `AppRouter` type is automatically inferred. Any app using `@repo/api-client` will immediately see `trpc.comments.listComments.useQuery(...)` and `trpc.comments.createComment.useMutation(...)` with full type safety -- no code generation step needed.
## Dependencies
| Dependency | Purpose |
|---|---|
| `@repo/core` | Controllers that contain business logic |
| `@trpc/server` | tRPC v11 server-side primitives |
| `zod` | Runtime input validation schemas |
## Cross-References
- **Controllers live in:** `packages/core/src/interface-adapters/controllers/` -- see `packages/core/AGENTS.md`
- **Client consumption:** `packages/api-client/` -- see `packages/api-client/AGENTS.md`
- **HTTP endpoint:** `apps/web-next/src/app/api/trpc/[trpc]/route.ts` uses the fetch adapter to serve `appRouter`

View File

@@ -1,23 +0,0 @@
{
"name": "@repo/api",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "echo 'typechecked by consuming app bundler'",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/core": "workspace:*",
"@trpc/server": "^11.1.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "^22.0.0"
}
}

View File

@@ -1 +0,0 @@
export { appRouter, type AppRouter } from "./router/index";

View File

@@ -1,38 +0,0 @@
import { z } from "zod";
import { router, publicProcedure } from "../trpc";
import {
signInController,
signUpController,
signOutController,
} from "@repo/core";
export const authRouter = router({
signIn: publicProcedure
.input(
z.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
})
)
.mutation(async ({ input }) => {
return await signInController(input);
}),
signUp: publicProcedure
.input(
z.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
confirmPassword: z.string().min(6).max(255),
})
)
.mutation(async ({ input }) => {
return await signUpController(input);
}),
signOut: publicProcedure
.input(z.object({ sessionId: z.string() }))
.mutation(async ({ input }) => {
return await signOutController(input.sessionId);
}),
});

View File

@@ -1,33 +0,0 @@
import { z } from "zod";
import { router, publicProcedure } from "../trpc";
import { createArticleController, getArticlesController } from "@repo/core";
export const contentRouter = router({
listArticles: publicProcedure
.input(
z
.object({
status: z.string().optional(),
authorId: z.string().optional(),
limit: z.number().optional(),
offset: z.number().optional(),
})
.optional()
)
.query(async ({ input }) => {
return await getArticlesController(input ?? {});
}),
createArticle: publicProcedure
.input(
z.object({
title: z.string().min(1).max(255),
content: z.string(),
authorId: z.string(),
slug: z.string().optional(),
})
)
.mutation(async ({ input }) => {
return await createArticleController(input);
}),
});

View File

@@ -1,10 +0,0 @@
import { router } from "../trpc";
import { authRouter } from "./auth.router";
import { contentRouter } from "./content.router";
export const appRouter = router({
auth: authRouter,
content: contentRouter,
});
export type AppRouter = typeof appRouter;

View File

@@ -1,6 +0,0 @@
import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;

View File

@@ -1,12 +0,0 @@
{
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,217 +0,0 @@
# @repo/cms-client -- Dual-Mode Payload Client
## Purpose
Provides a typed, uniform interface for accessing Payload CMS data via either the Local API (direct in-process calls) or the HTTP REST API (network requests). The consuming app chooses the mode at startup and injects the Payload instance -- this package never imports it.
## **NEVER import from `@repo/cms-core`, `@repo/core`, or any `apps/*` package.**
This package is completely standalone. The Payload instance is INJECTED at app startup, never imported by this package.
## File Structure
```
packages/cms-client/
src/
types.ts # FindOptions, PayloadClientResult, PayloadClient interface
client.ts # createPayloadClient() factory -- returns Local or HTTP client
local-client.ts # LocalPayloadClient class -- wraps Payload Local API
http-client.ts # HTTPPayloadClient class -- wraps Payload REST API via fetch
index.ts # Package entry: re-exports factory, classes, and types
package.json
AGENTS.md
```
## Dual-Mode Initialization
### Local Mode (primary -- used for server-side code with direct DB access)
Local mode wraps the Payload Local API. It requires a live `Payload` instance, which is obtained via `getPayload()` in the consuming app:
```typescript
// Example: apps/web-next/src/lib/payload.ts
import { getPayload } from "payload";
import config from "@repo/cms-core/src/payload.config";
import { createPayloadClient } from "@repo/cms-client";
const payload = await getPayload({ config });
const client = createPayloadClient({ mode: "local", payload });
// Now use client.find(), client.create(), etc.
const articles = await client.find("articles", {
where: { status: { equals: "published" } },
sort: "-publishedAt",
limit: 10,
});
```
### HTTP Mode (fallback -- used for external services without direct DB access)
HTTP mode makes REST calls to the Payload API. It only needs the base URL:
```typescript
import { createPayloadClient } from "@repo/cms-client";
const client = createPayloadClient({
mode: "http",
baseURL: "http://localhost:3001",
});
// Same API surface as local mode
const articles = await client.find("articles", {
where: { status: { equals: "published" } },
limit: 10,
});
```
## Initialization Table
| App / Context | Mode | How initialized | Why this mode |
|---|---|---|---|
| `apps/cms` (server-side) | Local | `getPayload({ config })` from `@repo/cms-core` | Same process as Payload, direct DB access |
| `apps/web-next` (server components/actions) | Local | `getPayload({ config })` from `@repo/cms-core` | Server-side rendering needs fast DB access |
| `apps/web-tanstack` (server loaders) | Local | `getPayload({ config })` from `@repo/cms-core` | Server-side data loading needs fast DB access |
| Client-side (browser) | N/A | Does not use this package directly | Browser goes through tRPC, server handles CMS access |
| External services / microservices | HTTP | `createPayloadClient({ mode: "http", baseURL })` | No access to Payload instance, only REST API |
## PayloadClient API Reference
All methods are available on both Local and HTTP clients via the `PayloadClient` interface.
### `find<T>(collection, options?): Promise<PayloadClientResult<T>>`
Paginated query for documents in a collection.
```typescript
const result = await client.find<Article>("articles", {
where: { status: { equals: "published" } },
sort: "-publishedAt",
limit: 10,
page: 1,
depth: 2,
locale: "en",
});
// result.docs, result.totalDocs, result.totalPages, etc.
```
### `findByID<T>(collection, id, options?): Promise<T>`
Fetch a single document by ID.
```typescript
const article = await client.findByID<Article>("articles", "abc123", {
depth: 2,
});
```
### `create<T>(collection, data, options?): Promise<T>`
Create a new document.
```typescript
const newArticle = await client.create<Article>("articles", {
title: "My Article",
content: "...",
author: "user-id-123",
status: "draft",
}, { depth: 1 });
```
### `update<T>(collection, id, data, options?): Promise<T>`
Update an existing document (partial update).
```typescript
const updated = await client.update<Article>("articles", "abc123", {
status: "published",
publishedAt: new Date().toISOString(),
});
```
### `delete<T>(collection, id): Promise<T>`
Delete a document by ID.
```typescript
const deleted = await client.delete<Article>("articles", "abc123");
```
## FindOptions Interface
```typescript
interface FindOptions {
where?: Record<string, unknown>; // Payload query operators ({ field: { equals: value } })
sort?: string; // Field name, prefix with "-" for descending
limit?: number; // Max documents per page (default: 10)
page?: number; // Page number (1-based)
depth?: number; // Relationship population depth (default: 1)
locale?: string; // Locale for localized fields
}
```
## PayloadClientResult Interface
```typescript
interface PayloadClientResult<T> {
docs: T[]; // Array of documents for current page
totalDocs: number; // Total matching documents across all pages
limit: number; // Max docs per page (as requested)
totalPages: number; // Total number of pages
page: number; // Current page number (1-based)
pagingCounter: number; // Index of first doc on current page
hasPrevPage: boolean; // Whether a previous page exists
hasNextPage: boolean; // Whether a next page exists
prevPage: number | null; // Previous page number, or null
nextPage: number | null; // Next page number, or null
}
```
## App Startup Pattern
The Payload instance is always created in the consuming app, then injected into the client:
```typescript
// apps/web-next/src/lib/payload.ts
import { getPayload } from "payload";
import config from "@repo/cms-core/src/payload.config";
import { createPayloadClient, type PayloadClient } from "@repo/cms-client";
let cachedClient: PayloadClient | null = null;
export async function getPayloadClient(): Promise<PayloadClient> {
if (cachedClient) return cachedClient;
const payload = await getPayload({ config });
cachedClient = createPayloadClient({ mode: "local", payload });
return cachedClient;
}
```
This pattern ensures:
1. The Payload instance is created once and reused
2. The cms-client package never imports config or Payload itself
3. Each app controls its own initialization
## Type Generation
Payload generates TypeScript types from your collection/global definitions:
```bash
cd apps/cms && pnpm generate:types
# Runs: payload generate:types
# Outputs to: packages/cms-core/src/payload-types.ts (configured in payload.config.ts)
```
After adding or modifying collections/globals in `@repo/cms-core`, re-run type generation to keep types in sync.
## Dependencies
| Dependency | Purpose |
|---|---|
| `payload` | `Payload` type for the Local API client constructor (type-only at build time) |
## Cross-References
- **CMS configuration:** `packages/cms-core/` -- see `packages/cms-core/AGENTS.md`
- **CMS app (where getPayload is called):** `apps/cms/` -- see `apps/cms/AGENTS.md`
- **Core use cases (consumers of this client):** `packages/core/` -- see `packages/core/AGENTS.md`

View File

@@ -1,21 +0,0 @@
{
"name": "@repo/cms-client",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"payload": "^3.14.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "^22.0.0"
}
}

View File

@@ -1,17 +0,0 @@
import type { Payload } from "payload";
import type { PayloadClient } from "./types";
import { LocalPayloadClient } from "./local-client";
import { HTTPPayloadClient } from "./http-client";
type PayloadClientOptions =
| { mode: "local"; payload: Payload }
| { mode: "http"; baseURL: string };
export function createPayloadClient(
options: PayloadClientOptions
): PayloadClient {
if (options.mode === "local") {
return new LocalPayloadClient(options.payload);
}
return new HTTPPayloadClient(options.baseURL);
}

View File

@@ -1,87 +0,0 @@
import type {
FindOptions,
PayloadClient,
PayloadClientResult,
} from "./types";
export class HTTPPayloadClient implements PayloadClient {
constructor(private baseURL: string) {}
private async request<T>(path: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${this.baseURL}${path}`, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!response.ok) {
throw new Error(
`Payload API error: ${response.status} ${response.statusText}`
);
}
return response.json() as Promise<T>;
}
async find<T = Record<string, unknown>>(
collection: string,
options?: FindOptions
): Promise<PayloadClientResult<T>> {
const params = new URLSearchParams();
if (options?.limit) params.set("limit", String(options.limit));
if (options?.page) params.set("page", String(options.page));
if (options?.sort) params.set("sort", options.sort);
if (options?.depth) params.set("depth", String(options.depth));
if (options?.where) params.set("where", JSON.stringify(options.where));
const query = params.toString();
return this.request<PayloadClientResult<T>>(
`/api/${collection}${query ? `?${query}` : ""}`
);
}
async findByID<T = Record<string, unknown>>(
collection: string,
id: string,
options?: { depth?: number }
): Promise<T> {
const params = new URLSearchParams();
if (options?.depth) params.set("depth", String(options.depth));
const query = params.toString();
return this.request<T>(
`/api/${collection}/${id}${query ? `?${query}` : ""}`
);
}
async create<T = Record<string, unknown>>(
collection: string,
data: Record<string, unknown>,
options?: { depth?: number }
): Promise<T> {
const params = new URLSearchParams();
if (options?.depth) params.set("depth", String(options.depth));
const query = params.toString();
return this.request<T>(
`/api/${collection}${query ? `?${query}` : ""}`,
{ method: "POST", body: JSON.stringify(data) }
);
}
async update<T = Record<string, unknown>>(
collection: string,
id: string,
data: Record<string, unknown>,
options?: { depth?: number }
): Promise<T> {
const params = new URLSearchParams();
if (options?.depth) params.set("depth", String(options.depth));
const query = params.toString();
return this.request<T>(
`/api/${collection}/${id}${query ? `?${query}` : ""}`,
{ method: "PATCH", body: JSON.stringify(data) }
);
}
async delete<T = Record<string, unknown>>(
collection: string,
id: string
): Promise<T> {
return this.request<T>(`/api/${collection}/${id}`, { method: "DELETE" });
}
}

View File

@@ -1,8 +0,0 @@
export { createPayloadClient } from "./client";
export { LocalPayloadClient } from "./local-client";
export { HTTPPayloadClient } from "./http-client";
export type {
PayloadClient,
PayloadClientResult,
FindOptions,
} from "./types";

View File

@@ -1,78 +0,0 @@
import type { Payload } from "payload";
import type {
FindOptions,
PayloadClient,
PayloadClientResult,
} from "./types";
export class LocalPayloadClient implements PayloadClient {
constructor(private payload: Payload) {}
async find<T = Record<string, unknown>>(
collection: string,
options?: FindOptions
): Promise<PayloadClientResult<T>> {
const result = await this.payload.find({
collection: collection as any,
where: options?.where as any,
sort: options?.sort,
limit: options?.limit,
page: options?.page,
depth: options?.depth,
locale: options?.locale as any,
});
return result as unknown as PayloadClientResult<T>;
}
async findByID<T = Record<string, unknown>>(
collection: string,
id: string,
options?: { depth?: number }
): Promise<T> {
const result = await this.payload.findByID({
collection: collection as any,
id,
depth: options?.depth,
});
return result as unknown as T;
}
async create<T = Record<string, unknown>>(
collection: string,
data: Record<string, unknown>,
options?: { depth?: number }
): Promise<T> {
const result = await this.payload.create({
collection: collection as any,
data: data as any,
depth: options?.depth,
});
return result as unknown as T;
}
async update<T = Record<string, unknown>>(
collection: string,
id: string,
data: Record<string, unknown>,
options?: { depth?: number }
): Promise<T> {
const result = await this.payload.update({
collection: collection as any,
id,
data: data as any,
depth: options?.depth,
});
return result as unknown as T;
}
async delete<T = Record<string, unknown>>(
collection: string,
id: string
): Promise<T> {
const result = await this.payload.delete({
collection: collection as any,
id,
});
return result as unknown as T;
}
}

View File

@@ -1,52 +0,0 @@
export interface FindOptions {
where?: Record<string, unknown>;
sort?: string;
limit?: number;
page?: number;
depth?: number;
locale?: string;
}
export interface PayloadClientResult<T> {
docs: T[];
totalDocs: number;
limit: number;
totalPages: number;
page: number;
pagingCounter: number;
hasPrevPage: boolean;
hasNextPage: boolean;
prevPage: number | null;
nextPage: number | null;
}
export interface PayloadClient {
find<T = Record<string, unknown>>(
collection: string,
options?: FindOptions
): Promise<PayloadClientResult<T>>;
findByID<T = Record<string, unknown>>(
collection: string,
id: string,
options?: { depth?: number }
): Promise<T>;
create<T = Record<string, unknown>>(
collection: string,
data: Record<string, unknown>,
options?: { depth?: number }
): Promise<T>;
update<T = Record<string, unknown>>(
collection: string,
id: string,
data: Record<string, unknown>,
options?: { depth?: number }
): Promise<T>;
delete<T = Record<string, unknown>>(
collection: string,
id: string
): Promise<T>;
}

View File

@@ -1,12 +0,0 @@
{
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,323 +0,0 @@
# @repo/cms-core -- ALL Payload CMS Configuration
## Purpose
This package contains **all** Payload CMS configuration: `payload.config.ts`, collections, globals, hooks, and access control. The `apps/cms` application is a thin shell that imports from this package -- it contains no CMS logic of its own.
## Hard Rules
- **ALL** collections, globals, hooks, and access patterns live here, **never** in `apps/cms`
- Can import from `@repo/core/application` (for use case delegation in hooks)
- **NEVER** import from `@repo/core/infrastructure`
- **NEVER** import from any `apps/*` package
- Keep hooks thin (5-10 lines max) -- delegate to use cases for business logic
## File Structure
```
packages/cms-core/
src/
payload.config.ts # Main Payload config (db, editor, collections, globals)
collections/
users/
index.ts # Users CollectionConfig (auth: true)
articles/
index.ts # Articles CollectionConfig (versions, hooks)
fields.ts # Article field definitions
hooks/
before-change.ts # Auto-generate slug from title
media/
index.ts # Media CollectionConfig (upload)
globals/
site-settings.ts # SiteSettings GlobalConfig
index.ts # Package entry: exports all configs
package.json
AGENTS.md
```
## Existing Collections
### Users (auth collection)
- **Slug:** `users`
- **Auth:** `true` (provides login, password hashing, session management)
- **Fields:** `displayName` (text), `role` (select: admin/editor/author, default: author)
- **Admin title:** email
### Articles (content with versioning)
- **Slug:** `articles`
- **Versions:** `{ drafts: true }` -- enables draft/published workflow
- **Hooks:** `beforeChange: [autoGenerateSlug]` -- generates slug from title if slug is empty
- **Fields:** title (text, required), slug (text, unique, sidebar), content (richText), status (select: draft/published, default: draft), author (relationship to users, required), featuredImage (upload to media), publishedAt (date)
- **Admin title:** title, default columns: title, status, author, updatedAt
### Media (file uploads)
- **Slug:** `media`
- **Upload:** Accepts `image/*` and `application/pdf`
- **Fields:** `alt` (text, required)
- **Admin title:** filename
## Existing Globals
### SiteSettings
- **Slug:** `site-settings`
- **Admin group:** Settings
- **Fields:** `siteName` (text, required, default: "My App"), `siteDescription` (textarea)
## Hook Rules
| Category | Where it lives | Description | Examples |
|---|---|---|---|
| CMS-operational | Stays in the hook file | Logic tied to CMS data shaping, not business rules | Slug generation, image resizing, setting default values, formatting fields |
| Business logic | Delegated to `@repo/core` use case | Logic that enforces a business rule or triggers side effects | Sending notifications, validating against external data, cross-domain updates |
### DO
- Keep hooks to 5-10 lines max
- Import use cases from `@repo/core/application` (application layer only)
- Map Payload hook arguments (`data`, `operation`, `req`) to use case input types
- Return `data` from `beforeChange` / `beforeValidate` hooks
### DON'T
- Import from `@repo/core/infrastructure` -- violates Clean Architecture
- Put business validation logic directly in hooks
- Call external services (email, analytics, APIs) directly from hooks
- Duplicate logic that already exists in a use case
- Access `req.payload` for cross-collection operations (delegate to use case instead)
**Rule of thumb:** If deleting the hook would break a business requirement, the logic must live in a use case in `@repo/core`.
## Recipe: Adding a New Collection
This example adds a `Tags` collection.
### Step 1: Create the collection folder and fields
Create `src/collections/tags/fields.ts`:
```typescript
import type { Field } from "payload";
export const tagFields: Field[] = [
{
name: "name",
type: "text",
required: true,
unique: true,
maxLength: 100,
},
{
name: "slug",
type: "text",
unique: true,
admin: {
position: "sidebar",
description: "Auto-generated from name if left empty",
},
},
{
name: "description",
type: "textarea",
},
];
```
### Step 2: Create the CollectionConfig
Create `src/collections/tags/index.ts`:
```typescript
import type { CollectionConfig } from "payload";
import { tagFields } from "./fields";
import { autoGenerateSlug } from "./hooks/before-change";
export const Tags: CollectionConfig = {
slug: "tags",
admin: {
useAsTitle: "name",
defaultColumns: ["name", "slug", "updatedAt"],
},
hooks: {
beforeChange: [autoGenerateSlug],
},
fields: tagFields,
};
```
### Step 3: Add a CMS-operational hook (slug generation)
Create `src/collections/tags/hooks/before-change.ts`:
```typescript
import type { CollectionBeforeChangeHook } from "payload";
function generateSlug(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export const autoGenerateSlug: CollectionBeforeChangeHook = ({
data,
operation,
}) => {
if (operation === "create" || operation === "update") {
if (data && data.name && !data.slug) {
data.slug = generateSlug(data.name);
}
}
return data;
};
```
### Step 4: (Optional) Add a business-logic hook delegating to a use case
Create `src/collections/tags/hooks/after-change.ts`:
```typescript
import type { CollectionAfterChangeHook } from "payload";
import { syncTagToSearchIndex } from "@repo/core/application";
export const syncTagAfterChange: CollectionAfterChangeHook = async ({
doc,
operation,
}) => {
// Delegate to use case -- this hook is just a thin bridge
await syncTagToSearchIndex({
id: doc.id,
name: doc.name,
slug: doc.slug,
operation,
});
return doc;
};
```
### Step 5: (Optional) Add access control
Create `src/collections/tags/access/is-admin.ts`:
```typescript
import type { Access } from "payload";
export const isAdmin: Access = ({ req: { user } }) => {
return user?.role === "admin";
};
```
Then reference it in the CollectionConfig:
```typescript
export const Tags: CollectionConfig = {
slug: "tags",
access: {
create: isAdmin,
update: isAdmin,
delete: isAdmin,
// read is open by default
},
// ...rest
};
```
### Step 6: Register in payload.config.ts
Edit `src/payload.config.ts`:
```typescript
import { Tags } from "./collections/tags";
export default buildConfig({
collections: [Users, Articles, Media, Tags], // <-- add Tags
// ...rest
});
```
### Step 7: Export from package entry
Edit `src/index.ts`:
```typescript
export { Tags } from "./collections/tags"; // <-- add export
```
## Recipe: Adding a New Global
This example adds a `Navigation` global.
### Step 1: Create the GlobalConfig
Create `src/globals/navigation.ts`:
```typescript
import type { GlobalConfig } from "payload";
export const Navigation: GlobalConfig = {
slug: "navigation",
admin: {
group: "Settings",
},
fields: [
{
name: "mainMenu",
type: "array",
fields: [
{
name: "label",
type: "text",
required: true,
},
{
name: "url",
type: "text",
required: true,
},
],
},
],
};
```
### Step 2: Register in payload.config.ts
```typescript
import { Navigation } from "./globals/navigation";
export default buildConfig({
globals: [SiteSettings, Navigation], // <-- add Navigation
// ...rest
});
```
### Step 3: Export from package entry
```typescript
export { Navigation } from "./globals/navigation"; // <-- add export
```
## Payload Config Overview
The `payload.config.ts` uses:
- **Database:** `@payloadcms/db-postgres` (PostgreSQL via `DATABASE_URL` env var)
- **Editor:** `@payloadcms/richtext-lexical` (Lexical rich text editor)
- **Secret:** `PAYLOAD_SECRET` env var (required for production)
- **TypeScript output:** Generates `payload-types.ts` in this package's `src/` directory
## Dependencies
| Dependency | Purpose |
|---|---|
| `payload` | Payload CMS core |
| `@payloadcms/db-postgres` | PostgreSQL database adapter |
| `@payloadcms/richtext-lexical` | Lexical rich text editor |
## Cross-References
- **CMS app shell:** `apps/cms/` -- see `apps/cms/AGENTS.md`
- **Use cases for hooks:** `packages/core/src/application/use-cases/` -- see `packages/core/AGENTS.md`
- **CMS client for querying:** `packages/cms-client/` -- see `packages/cms-client/AGENTS.md`

View File

@@ -1,23 +0,0 @@
{
"name": "@repo/cms-core",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"payload": "^3.14.0",
"@payloadcms/db-postgres": "^3.14.0",
"@payloadcms/richtext-lexical": "^3.14.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "^22.0.0"
}
}

View File

@@ -1,60 +0,0 @@
import type { Field } from "payload";
export const articleFields: Field[] = [
{
name: "title",
type: "text",
required: true,
maxLength: 255,
},
{
name: "slug",
type: "text",
unique: true,
admin: {
position: "sidebar",
description: "Auto-generated from title if left empty",
},
},
{
name: "content",
type: "richText",
},
{
name: "status",
type: "select",
options: [
{ label: "Draft", value: "draft" },
{ label: "Published", value: "published" },
],
defaultValue: "draft",
required: true,
admin: {
position: "sidebar",
},
},
{
name: "author",
type: "relationship",
relationTo: "users",
required: true,
admin: {
position: "sidebar",
},
},
{
name: "featuredImage",
type: "upload",
relationTo: "media",
},
{
name: "publishedAt",
type: "date",
admin: {
position: "sidebar",
date: {
pickerAppearance: "dayAndTime",
},
},
},
];

View File

@@ -1,20 +0,0 @@
import type { CollectionBeforeChangeHook } from "payload";
function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export const autoGenerateSlug: CollectionBeforeChangeHook = ({
data,
operation,
}) => {
if (operation === "create" || operation === "update") {
if (data && data.title && !data.slug) {
data.slug = generateSlug(data.title);
}
}
return data;
};

View File

@@ -1,19 +0,0 @@
import type { CollectionConfig } from "payload";
import { articleFields } from "./fields";
import { autoGenerateSlug } from "./hooks/before-change";
export const Articles: CollectionConfig = {
slug: "articles",
admin: {
useAsTitle: "title",
defaultColumns: ["title", "status", "author", "updatedAt"],
},
hooks: {
beforeChange: [autoGenerateSlug],
},
versions: {
drafts: true,
},
fields: articleFields,
};

View File

@@ -1,18 +0,0 @@
import type { CollectionConfig } from "payload";
export const Media: CollectionConfig = {
slug: "media",
upload: {
mimeTypes: ["image/*", "application/pdf"],
},
admin: {
useAsTitle: "filename",
},
fields: [
{
name: "alt",
type: "text",
required: true,
},
],
};

View File

@@ -1,26 +0,0 @@
import type { CollectionConfig } from "payload";
export const Users: CollectionConfig = {
slug: "users",
auth: true,
admin: {
useAsTitle: "email",
},
fields: [
{
name: "displayName",
type: "text",
},
{
name: "role",
type: "select",
options: [
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
{ label: "Author", value: "author" },
],
defaultValue: "author",
required: true,
},
],
};

View File

@@ -1,20 +0,0 @@
import type { GlobalConfig } from "payload";
export const SiteSettings: GlobalConfig = {
slug: "site-settings",
admin: {
group: "Settings",
},
fields: [
{
name: "siteName",
type: "text",
required: true,
defaultValue: "My App",
},
{
name: "siteDescription",
type: "textarea",
},
],
};

View File

@@ -1,5 +0,0 @@
export { Users } from "./collections/users";
export { Articles } from "./collections/articles";
export { Media } from "./collections/media";
export { SiteSettings } from "./globals/site-settings";
export { default as config } from "./payload.config";

View File

@@ -1,425 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
users: User;
articles: Article;
media: Media;
'payload-kv': PayloadKv;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
articles: ArticlesSelect<false> | ArticlesSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: number;
};
fallbackLocale: null;
globals: {
'site-settings': SiteSetting;
};
globalsSelect: {
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
};
locale: null;
widgets: {
collections: CollectionsWidget;
};
user: User;
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: number;
displayName?: string | null;
role: 'admin' | 'editor' | 'author';
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
collection: 'users';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "articles".
*/
export interface Article {
id: number;
title: string;
/**
* Auto-generated from title if left empty
*/
slug?: string | null;
content?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
status: 'draft' | 'published';
author: number | User;
featuredImage?: (number | null) | Media;
publishedAt?: string | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: number;
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv".
*/
export interface PayloadKv {
id: number;
key: string;
data:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
document?:
| ({
relationTo: 'users';
value: number | User;
} | null)
| ({
relationTo: 'articles';
value: number | Article;
} | null)
| ({
relationTo: 'media';
value: number | Media;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
user: {
relationTo: 'users';
value: number | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
displayName?: T;
role?: T;
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "articles_select".
*/
export interface ArticlesSelect<T extends boolean = true> {
title?: T;
slug?: T;
content?: T;
status?: T;
author?: T;
featuredImage?: T;
publishedAt?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
*/
export interface PayloadKvSelect<T extends boolean = true> {
key?: T;
data?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "site-settings".
*/
export interface SiteSetting {
id: number;
siteName: string;
siteDescription?: string | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "site-settings_select".
*/
export interface SiteSettingsSelect<T extends boolean = true> {
siteName?: T;
siteDescription?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collections_widget".
*/
export interface CollectionsWidget {
data?: {
[k: string]: unknown;
};
width: 'full';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}

View File

@@ -1,30 +0,0 @@
import { buildConfig } from "payload";
import { postgresAdapter } from "@payloadcms/db-postgres";
import { lexicalEditor } from "@payloadcms/richtext-lexical";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Users } from "./collections/users";
import { Articles } from "./collections/articles";
import { Media } from "./collections/media";
import { SiteSettings } from "./globals/site-settings";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
export default buildConfig({
editor: lexicalEditor(),
collections: [Users, Articles, Media],
globals: [SiteSettings],
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
db: postgresAdapter({
pool: {
connectionString:
process.env.DATABASE_URL ||
"postgresql://postgres:postgres@localhost:5432/template",
},
}),
typescript: {
outputFile: path.resolve(dirname, "payload-types.ts"),
},
});

View File

@@ -1,13 +0,0 @@
{
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,249 +0,0 @@
# @repo/core -- Clean Architecture Core Package
This is the central business logic package. All domain entities, use cases, repository/service interfaces, controllers, and the InversifyJS DI container live here. Nothing in this package depends on any web framework (Next.js, TanStack, Payload). It is portable and testable in isolation.
**Package location:** `packages/core`
**Entry point:** `src/index.ts`
**Test runner:** Vitest (`pnpm vitest run` from this directory)
---
## Layer Diagram
Dependencies point inward (right to left). Outer layers depend on inner layers, never the reverse.
```
+------------------------------------------------------------------+
| di/ |
| (wires everything together -- imports ALL internal layers) |
+------------------------------------------------------------------+
| | | |
v v v v
+----------------+ +----------------+ +----------------+ +-----------+
| interface- | | infrastructure/| | application/ | | entities/ |
| adapters/ | | (implements | | (use cases, | | (models, |
| controllers/ | | interfaces | | repo/service | | errors) |
| (validates | | with concrete | | interfaces) | | |
| input, calls | | code) | | | | INNERMOST |
| use cases) | | | | imports: | | zero deps |
| | | imports: | | entities/ | | |
| imports: | | application/ | | ONLY | | |
| application/ | | entities/ | | | | |
| entities/ | | @repo/ | | | | |
| | | cms-client | | | | |
+----------------+ +----------------+ +----------------+ +-----------+
```
---
## Import Rules
| Layer | Can Import From | NEVER Import From |
|---|---|---|
| `entities/` | Nothing (Zod is the only external dependency) | `application/`, `infrastructure/`, `interface-adapters/`, `di/`, any `@repo/*`, any framework |
| `application/` | `entities/` only | `infrastructure/`, `interface-adapters/`, `di/` (except `getInjection` from `di/container` in use cases) |
| `interface-adapters/` | `application/` (use cases), `entities/` (types, errors) | `infrastructure/`, any `@repo/*` except via DI |
| `infrastructure/` | `application/` (interfaces to implement), `entities/` (types), `@repo/cms-client` | `interface-adapters/`, apps/*, Next.js, TanStack |
| `di/` | All internal layers (it wires them together) | apps/*, any framework package |
Note: Use cases in `application/` import `getInjection` from `di/container` to resolve dependencies at runtime. This is the one controlled exception to the "application never imports di" rule -- `getInjection` is a lookup function, not a concrete implementation.
---
## DI Resolution Table
| Symbol Key | Interface | Production Implementation | Mock Implementation |
|---|---|---|---|
| `IUsersRepository` | `IUsersRepository` (getUser, getUserByUsername, createUser) | PayloadUsersRepository (future -- will use `@repo/cms-client`) | `MockUsersRepository` (`infrastructure/repositories/mock-users.repository.ts`) |
| `IArticlesRepository` | `IArticlesRepository` (getArticle, getArticles, createArticle, updateArticle) | PayloadArticlesRepository (future -- will use `@repo/cms-client`) | `MockArticlesRepository` (`infrastructure/repositories/mock-articles.repository.ts`) |
| `IAuthenticationService` | `IAuthenticationService` (generateUserId, hashPassword, verifyPassword, validateSession, createSession, invalidateSession) | BetterAuthService (future) | `MockAuthenticationService` (`infrastructure/services/mock-auth.service.ts`) |
| `ITelemetryService` | `ITelemetryService` (startSpan) | OTelSentryService (future) | `MockTelemetryService` (`infrastructure/services/mock-telemetry.service.ts`) |
Currently all modules bind mock implementations. When production implementations are added, the modules will conditionally bind based on environment or configuration.
---
## Naming Conventions
| Type | Pattern | Example |
|---|---|---|
| Entity model | `{name}.ts` | `article.ts`, `user.ts` |
| Entity error | `{domain}.ts` | `auth.ts`, `common.ts` |
| Repository interface | `{name}.repository.interface.ts` | `users.repository.interface.ts` |
| Service interface | `{name}.service.interface.ts` | `auth.service.interface.ts` |
| Use case | `{verb}-{noun}.use-case.ts` | `sign-in.use-case.ts`, `create-article.use-case.ts` |
| Controller | `{noun}.controller.ts` (may export multiple functions) | `articles.controller.ts` |
| Production impl | `{provider}-{name}.repository.ts` | `payload-users.repository.ts` |
| Mock impl | `mock-{name}.repository.ts` or `mock-{name}.service.ts` | `mock-users.repository.ts` |
| DI module | `{domain}.module.ts` | `auth.module.ts`, `content.module.ts` |
| Test file | `{name}.test.ts` matching the source file name | `sign-in.use-case.test.ts` |
---
## How to Add a New Dependency (5-Step Recipe)
This recipe adds a new repository or service interface to the DI container. For a complete feature, also follow the root `AGENTS.md` end-to-end recipe.
### Step 1: Define the interface in `application/`
Create `src/application/repositories/{name}.repository.interface.ts` or `src/application/services/{name}.service.interface.ts`:
```typescript
import type { MyEntity } from "@/entities/models/my-entity";
export interface IMyRepository {
findById(id: string): Promise<MyEntity | undefined>;
findAll(): Promise<MyEntity[]>;
create(input: MyEntity): Promise<MyEntity>;
}
```
Export from the appropriate barrel: `src/application/repositories/index.ts` or `src/application/services/index.ts`.
### Step 2: Add the DI symbol to `di/types.ts`
```typescript
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
export const DI_SYMBOLS = {
// ...existing symbols...
IMyRepository: Symbol.for("IMyRepository"),
};
export interface DI_RETURN_TYPES {
// ...existing types...
IMyRepository: IMyRepository;
}
```
### Step 3: Create mock implementation in `infrastructure/`
```typescript
import { injectable } from "inversify";
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
import type { MyEntity } from "@/entities/models/my-entity";
@injectable()
export class MockMyRepository implements IMyRepository {
private _items: MyEntity[] = [];
async findById(id: string): Promise<MyEntity | undefined> {
return this._items.find((item) => item.id === id);
}
async findAll(): Promise<MyEntity[]> {
return [...this._items];
}
async create(input: MyEntity): Promise<MyEntity> {
this._items.push(input);
return input;
}
}
```
### Step 4: Create or update DI module in `di/modules/`
```typescript
import { ContainerModule, interfaces } from "inversify";
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
import { MockMyRepository } from "@/infrastructure/repositories/mock-my.repository";
import { DI_SYMBOLS } from "../types";
const initializeModule = (bind: interfaces.Bind) => {
bind<IMyRepository>(DI_SYMBOLS.IMyRepository).to(MockMyRepository);
};
export const MyModule = new ContainerModule(initializeModule);
```
### Step 5: Load module in `di/container.ts`
```typescript
import { MyModule } from "./modules/my.module";
export const initializeContainer = () => {
// ...existing modules...
ApplicationContainer.load(MyModule);
};
export const destroyContainer = () => {
// ...existing modules...
ApplicationContainer.unload(MyModule);
};
```
---
## Testing Pattern
All tests follow this lifecycle pattern:
```typescript
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { destroyContainer, initializeContainer } from "@/di/container";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("myUseCase", () => {
it("does something", async () => {
const result = await myUseCase(/* ... */);
expect(result).toBeDefined();
});
});
```
Critical requirements:
- `import "reflect-metadata"` MUST be the first import in every test file. InversifyJS decorators rely on runtime metadata reflection.
- `initializeContainer()` loads all DI modules (binding mock implementations).
- `destroyContainer()` unloads all modules, ensuring clean state between tests. Without this, Singleton-scoped mocks retain state across tests.
Test file locations mirror source structure:
- `tests/unit/use-cases/{domain}/{name}.use-case.test.ts`
- `tests/unit/controllers/{domain}/{name}.controller.test.ts`
---
## tsconfig Requirements
These settings in `tsconfig.json` are MANDATORY. Do not remove them:
| Setting | Why |
|---|---|
| `experimentalDecorators: true` | InversifyJS uses TypeScript decorators (`@injectable()`, `@inject()`) |
| `emitDecoratorMetadata: true` | InversifyJS reads parameter type metadata at runtime for constructor injection |
| `types: ["reflect-metadata", "node"]` | `reflect-metadata` polyfill must be globally available for decorator metadata |
| Path alias `@/*` -> `./src/*` | All internal imports use `@/` prefix (e.g., `@/entities/models/user`) |
The base config comes from `@repo/typescript-config/base.json` which already includes `experimentalDecorators` and `emitDecoratorMetadata`. The core `tsconfig.json` extends it and adds the path alias and types.
---
## Running Tests
```bash
cd packages/core
pnpm vitest run # Run all tests once
pnpm vitest # Run in watch mode
pnpm vitest run --reporter=verbose # Verbose output
```
---
## Cross-References
- `src/entities/AGENTS.md` -- Entity models and error classes
- `src/application/AGENTS.md` -- Use cases, repository/service interfaces
- `src/infrastructure/AGENTS.md` -- Concrete implementations with `@injectable()`
- `src/interface-adapters/controllers/AGENTS.md` -- Controller patterns
- `src/di/AGENTS.md` -- DI container configuration and lifecycle
- `src/application/use-cases/auth/AGENTS.md` -- Auth domain business rules
- `src/application/use-cases/content/AGENTS.md` -- Content domain business rules
- Root `AGENTS.md` -- Full end-to-end feature recipe and monorepo map

View File

@@ -1,25 +0,0 @@
{
"name": "@repo/core",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"inversify": "^6.2.0",
"reflect-metadata": "^0.2.2",
"zod": "^3.24.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "^25.5.2",
"vitest": "^3.1.0"
}
}

View File

@@ -1,340 +0,0 @@
# Application Layer -- Use Cases + Interfaces
**Path:** `packages/core/src/application/`
**Role:** Define the business logic (use cases) and the abstract contracts (repository and service interfaces) that the infrastructure layer implements. This is the second-innermost layer of Clean Architecture.
---
## Rules
1. Imports from `entities/` ONLY (for types, schemas, and error classes).
2. **NEVER** imports from `infrastructure/` -- use cases depend on interfaces, not implementations.
3. **NEVER** imports from `interface-adapters/` -- controllers call use cases, not the reverse.
4. Use cases obtain dependencies via `getInjection()` from `di/container` at runtime. This is the single allowed cross-layer dependency.
5. All repository and service interfaces are pure TypeScript interfaces (no decorators, no classes).
6. All methods on interfaces return Promises (data access is always async).
---
## Existing Repository Interfaces
| Interface | File | Methods |
|---|---|---|
| `IUsersRepository` | `repositories/users.repository.interface.ts` | `getUser(id)`, `getUserByUsername(username)`, `createUser(input)` |
| `IArticlesRepository` | `repositories/articles.repository.interface.ts` | `getArticle(id)`, `getArticles(options?)`, `createArticle(input)`, `updateArticle(id, input)` |
## Existing Service Interfaces
| Interface | File | Methods |
|---|---|---|
| `IAuthenticationService` | `services/auth.service.interface.ts` | `generateUserId()`, `hashPassword(password)`, `verifyPassword(hash, password)`, `validateSession(sessionId)`, `createSession(user)`, `invalidateSession(sessionId)` |
| `ITelemetryService` | `services/telemetry.service.interface.ts` | `startSpan(name, fn)` |
## Existing Use Cases
| Use Case | File | Domain | What It Does |
|---|---|---|---|
| `signInUseCase` | `use-cases/auth/sign-in.use-case.ts` | Auth | Looks up user by username, verifies password, creates session |
| `signUpUseCase` | `use-cases/auth/sign-up.use-case.ts` | Auth | Checks username uniqueness, hashes password, creates user + session |
| `signOutUseCase` | `use-cases/auth/sign-out.use-case.ts` | Auth | Invalidates session, returns blank cookie |
| `createArticleUseCase` | `use-cases/content/create-article.use-case.ts` | Content | Generates slug from title, creates draft article |
| `getArticlesUseCase` | `use-cases/content/get-articles.use-case.ts` | Content | Retrieves articles with optional filtering and pagination |
---
## Repository Interface Template
```typescript
// src/application/repositories/{name}.repository.interface.ts
import type { MyEntity } from "@/entities/models/my-entity";
export interface IMyRepository {
/**
* Find a single entity by ID.
* Returns undefined if not found (do NOT throw -- let the use case decide).
*/
findById(id: string): Promise<MyEntity | undefined>;
/**
* Find all entities matching optional filters.
*/
findAll(options?: {
status?: string;
limit?: number;
offset?: number;
}): Promise<MyEntity[]>;
/**
* Create a new entity. Returns the created entity.
*/
create(input: MyEntity): Promise<MyEntity>;
/**
* Update an existing entity. Returns the updated entity or undefined if not found.
*/
update(id: string, input: Partial<MyEntity>): Promise<MyEntity | undefined>;
}
```
Key conventions:
- Return `undefined` for "not found" cases, not `null`, and do not throw.
- The use case decides what to do when something is not found (throw `NotFoundError`, return default, etc.).
- Always return the entity after create/update so the caller has the final state.
---
## Service Interface Template
```typescript
// src/application/services/{name}.service.interface.ts
import type { SomeEntity } from "@/entities/models/some-entity";
export interface IMyService {
/**
* Service methods define external capabilities the domain needs
* but does not implement itself (email, auth, telemetry, etc.)
*/
doSomething(input: string): Promise<SomeEntity>;
}
```
The distinction between repositories and services:
- **Repositories** abstract data storage (CRUD operations on entities).
- **Services** abstract external capabilities (authentication, email, telemetry, file storage).
---
## Use Case Template
```typescript
// src/application/use-cases/{domain}/{verb}-{noun}.use-case.ts
import type { MyEntity } from "@/entities/models/my-entity";
import { NotFoundError } from "@/entities/errors/common";
import { getInjection } from "@/di/container";
export async function myUseCase(input: {
id: string;
// ... other input fields
}): Promise<MyEntity> {
// 1. Get dependencies from DI container
const myRepository = getInjection("IMyRepository");
// 2. Execute business logic using entities and interfaces
const existing = await myRepository.findById(input.id);
if (!existing) {
throw new NotFoundError("Entity not found");
}
// 3. Return result (entity types from entities/ layer)
return existing;
}
```
Critical pattern: `getInjection("IMyRepository")` returns a fully typed instance. The string key must match a key in `DI_SYMBOLS` (see `di/types.ts`). TypeScript will enforce the return type via the `DI_RETURN_TYPES` mapping.
---
## Adding a New Repository Interface (5-Step Recipe)
### Step 1: Create the interface file
Create `src/application/repositories/{name}.repository.interface.ts` following the template above.
### Step 2: Export from the barrel
Add to `src/application/repositories/index.ts`:
```typescript
export type { IMyRepository } from "./{name}.repository.interface";
```
### Step 3: Create mock implementation
In `src/infrastructure/repositories/mock-{name}.repository.ts`:
```typescript
import { injectable } from "inversify";
import type { IMyRepository } from "@/application/repositories/{name}.repository.interface";
import type { MyEntity } from "@/entities/models/my-entity";
@injectable()
export class MockMyRepository implements IMyRepository {
private _items: MyEntity[] = [];
async findById(id: string): Promise<MyEntity | undefined> {
return this._items.find((item) => item.id === id);
}
async findAll(): Promise<MyEntity[]> {
return [...this._items];
}
async create(input: MyEntity): Promise<MyEntity> {
this._items.push(input);
return input;
}
async update(id: string, input: Partial<MyEntity>): Promise<MyEntity | undefined> {
const index = this._items.findIndex((item) => item.id === id);
if (index === -1) return undefined;
this._items[index] = { ...this._items[index]!, ...input };
return this._items[index];
}
}
```
### Step 4: Register in DI
Add symbol to `di/types.ts`, create or update module in `di/modules/`, load in `di/container.ts`. See `di/AGENTS.md` for full details.
### Step 5: Verify
Write a use case that calls `getInjection("IMyRepository")` and a test that exercises it with the mock.
---
## Adding a New Service Interface (Recipe)
Same as repository interface, but files go in `src/application/services/` and `src/infrastructure/services/`. The naming convention is `{name}.service.interface.ts` for the interface and `mock-{name}.service.ts` for the mock.
---
## Adding a New Use Case (TDD Recipe)
### Step 1: Write the test FIRST
Create `tests/unit/use-cases/{domain}/{verb}-{noun}.use-case.test.ts`:
```typescript
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
destroyContainer,
initializeContainer,
} from "@/di/container";
import { myNewUseCase } from "@/application/use-cases/{domain}/{verb}-{noun}.use-case";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("myNewUseCase", () => {
it("succeeds with valid input", async () => {
const result = await myNewUseCase({ /* valid input */ });
expect(result).toBeDefined();
// ... assert specific properties
});
it("throws correct error for invalid state", async () => {
await expect(
myNewUseCase({ /* input that triggers error */ })
).rejects.toBeInstanceOf(SomeError);
});
});
```
### Step 2: Implement the use case
Create `src/application/use-cases/{domain}/{verb}-{noun}.use-case.ts` following the template above. Run the test to verify.
### Step 3: Export from core
Add to `src/index.ts`:
```typescript
export { myNewUseCase } from "./application/use-cases/{domain}/{verb}-{noun}.use-case";
```
### Step 4: Create a controller (if needed)
Controllers go in `interface-adapters/controllers/`. See `interface-adapters/controllers/AGENTS.md`.
---
## Test Template (Full Example)
```typescript
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
destroyContainer,
initializeContainer,
} from "@/di/container";
import { createArticleUseCase } from "@/application/use-cases/content/create-article.use-case";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("createArticleUseCase", () => {
it("creates an article with generated slug and draft status", async () => {
const result = await createArticleUseCase({
title: "My First Article",
content: "Hello world",
authorId: "1",
});
expect(result.title).toBe("My First Article");
expect(result.slug).toBe("my-first-article");
expect(result.status).toBe("draft");
expect(result.authorId).toBe("1");
expect(result.id).toBeDefined();
});
it("uses provided slug if given", async () => {
const result = await createArticleUseCase({
title: "Another Article",
content: "Content here",
authorId: "1",
slug: "custom-slug",
});
expect(result.slug).toBe("custom-slug");
});
});
```
---
## File Structure
```
application/
AGENTS.md
repositories/
index.ts <-- barrel: exports all repository interfaces
users.repository.interface.ts
articles.repository.interface.ts
services/
index.ts <-- barrel: exports all service interfaces
auth.service.interface.ts
telemetry.service.interface.ts
use-cases/
auth/
AGENTS.md <-- auth domain rules and recipes
sign-in.use-case.ts
sign-up.use-case.ts
sign-out.use-case.ts
content/
AGENTS.md <-- content domain rules and recipes
create-article.use-case.ts
get-articles.use-case.ts
```
---
## Cross-References
- `entities/AGENTS.md` -- The only layer this layer can import from
- `infrastructure/AGENTS.md` -- Implements the interfaces defined here
- `di/AGENTS.md` -- Where interfaces are bound to implementations
- `use-cases/auth/AGENTS.md` -- Auth domain business rules
- `use-cases/content/AGENTS.md` -- Content domain business rules

View File

@@ -1,16 +0,0 @@
import type { Article } from "@/entities/models/article";
export interface IArticlesRepository {
getArticle(id: string): Promise<Article | undefined>;
getArticles(options?: {
status?: string;
authorId?: string;
limit?: number;
offset?: number;
}): Promise<Article[]>;
createArticle(input: Article): Promise<Article>;
updateArticle(
id: string,
input: Partial<Article>
): Promise<Article | undefined>;
}

View File

@@ -1,2 +0,0 @@
export type { IUsersRepository } from "./users.repository.interface";
export type { IArticlesRepository } from "./articles.repository.interface";

View File

@@ -1,7 +0,0 @@
import type { User } from "@/entities/models/user";
export interface IUsersRepository {
getUser(id: string): Promise<User | undefined>;
getUserByUsername(username: string): Promise<User | undefined>;
createUser(input: User): Promise<User>;
}

View File

@@ -1,14 +0,0 @@
import type { Cookie } from "@/entities/models/cookie";
import type { Session } from "@/entities/models/session";
import type { User } from "@/entities/models/user";
export interface IAuthenticationService {
generateUserId(): string;
hashPassword(password: string): Promise<string>;
verifyPassword(hash: string, password: string): Promise<boolean>;
validateSession(
sessionId: string
): Promise<{ user: User; session: Session }>;
createSession(user: User): Promise<{ session: Session; cookie: Cookie }>;
invalidateSession(sessionId: string): Promise<{ blankCookie: Cookie }>;
}

View File

@@ -1,2 +0,0 @@
export type { IAuthenticationService } from "./auth.service.interface";
export type { ITelemetryService } from "./telemetry.service.interface";

View File

@@ -1,3 +0,0 @@
export interface ITelemetryService {
startSpan<T>(name: string, fn: () => T | Promise<T>): Promise<T>;
}

View File

@@ -1,208 +0,0 @@
# Auth Domain -- Use Cases
**Path:** `packages/core/src/application/use-cases/auth/`
**Domain Responsibility:** Authentication and authorization -- sign-in, sign-up, sign-out, session management. This domain owns the user identity lifecycle from account creation through session invalidation.
---
## Complete Business Rules
1. **Passwords are always hashed.** Plain-text passwords never enter the repository. The `IAuthenticationService.hashPassword()` method handles hashing before storage.
2. **Usernames must be unique.** Sign-up checks for existing username via `IUsersRepository.getUserByUsername()` before creating a new user.
3. **Sign-in requires valid credentials.** The user must exist AND the password must verify against the stored hash.
4. **Sessions are created on successful sign-in or sign-up.** `IAuthenticationService.createSession()` returns both a `Session` object and a `Cookie` for the client.
5. **Sessions expire after 7 days.** This is the current mock implementation default (`Date.now() + 86400000 * 7`). Production implementations may use different expiry logic.
6. **Sign-out invalidates the session.** `IAuthenticationService.invalidateSession()` removes the session and returns a blank cookie (empty value) to clear the client-side cookie.
7. **User IDs are generated by the auth service.** `IAuthenticationService.generateUserId()` produces the ID, not the repository. This allows the auth provider (e.g., Better Auth) to control ID format.
---
## Error Cases
| Error Class | When Thrown | Use Case |
|---|---|---|
| `AuthenticationError` | User does not exist (sign-in) | `signInUseCase` |
| `AuthenticationError` | Incorrect password (sign-in) | `signInUseCase` |
| `AuthenticationError` | Username already taken (sign-up) | `signUpUseCase` |
| `UnauthenticatedError` | Session ID not found or expired (session validation) | `IAuthenticationService.validateSession()` |
| `InputParseError` | Invalid input fields (handled by controller, not use case) | `signInController`, `signUpController` |
Note: Use cases throw `AuthenticationError` for credential failures. They intentionally do NOT distinguish between "user not found" and "wrong password" in the error message exposed to callers -- this prevents username enumeration attacks. The sign-in use case throws `"Incorrect username or password"` for wrong passwords and `"User does not exist"` internally, but callers should treat both as authentication failures.
---
## Dependencies
### IUsersRepository
Provides user data access. Methods used by auth use cases:
| Method | Signature | Used By |
|---|---|---|
| `getUser` | `(id: string) => Promise<User \| undefined>` | `MockAuthenticationService.validateSession()` (indirectly) |
| `getUserByUsername` | `(username: string) => Promise<User \| undefined>` | `signInUseCase`, `signUpUseCase` |
| `createUser` | `(input: User) => Promise<User>` | `signUpUseCase` |
### IAuthenticationService
Provides authentication operations. All methods:
| Method | Signature | Used By |
|---|---|---|
| `generateUserId` | `() => string` | `signUpUseCase` |
| `hashPassword` | `(password: string) => Promise<string>` | `signUpUseCase` |
| `verifyPassword` | `(hash: string, password: string) => Promise<boolean>` | `signInUseCase` |
| `validateSession` | `(sessionId: string) => Promise<{ user: User; session: Session }>` | (future use cases needing auth context) |
| `createSession` | `(user: User) => Promise<{ session: Session; cookie: Cookie }>` | `signInUseCase`, `signUpUseCase` |
| `invalidateSession` | `(sessionId: string) => Promise<{ blankCookie: Cookie }>` | `signOutUseCase` |
---
## Existing Use Cases
### signInUseCase
**File:** `sign-in.use-case.ts`
**Input:** `{ username: string; password: string }`
**Output:** `{ session: Session; cookie: Cookie }`
**Logic:**
1. Look up user by username via `IUsersRepository.getUserByUsername()`.
2. If not found, throw `AuthenticationError("User does not exist")`.
3. Verify password via `IAuthenticationService.verifyPassword()`.
4. If invalid, throw `AuthenticationError("Incorrect username or password")`.
5. Create session via `IAuthenticationService.createSession()`.
6. Return session and cookie.
### signUpUseCase
**File:** `sign-up.use-case.ts`
**Input:** `{ username: string; password: string }`
**Output:** `{ session: Session; cookie: Cookie; user: Pick<User, "id" | "username"> }`
**Logic:**
1. Check if username exists via `IUsersRepository.getUserByUsername()`.
2. If exists, throw `AuthenticationError("Username taken")`.
3. Hash password via `IAuthenticationService.hashPassword()`.
4. Generate user ID via `IAuthenticationService.generateUserId()`.
5. Create user via `IUsersRepository.createUser()`.
6. Create session via `IAuthenticationService.createSession()`.
7. Return session, cookie, and safe user info (id + username, no passwordHash).
### signOutUseCase
**File:** `sign-out.use-case.ts`
**Input:** `sessionId: string`
**Output:** `{ blankCookie: Cookie }`
**Logic:**
1. Invalidate session via `IAuthenticationService.invalidateSession()`.
2. Return blank cookie (empty value clears the client-side session cookie).
---
## Adding a New Auth Use Case (Complete Recipe with Test-First Example)
Example: adding a `validateSessionUseCase` that checks if a session is valid and returns the authenticated user.
### Step 1: Write the test FIRST
Create `tests/unit/use-cases/auth/validate-session.use-case.test.ts`:
```typescript
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
destroyContainer,
initializeContainer,
} from "@/di/container";
import { signUpUseCase } from "@/application/use-cases/auth/sign-up.use-case";
import { validateSessionUseCase } from "@/application/use-cases/auth/validate-session.use-case";
import { UnauthenticatedError } from "@/entities/errors/auth";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("validateSessionUseCase", () => {
it("returns user and session for valid session", async () => {
// First create a user and get a session
const signUpResult = await signUpUseCase({
username: "testuser",
password: "testpassword",
});
const result = await validateSessionUseCase(
signUpResult.session.id
);
expect(result.user.username).toBe("testuser");
expect(result.session.id).toBe(signUpResult.session.id);
});
it("throws UnauthenticatedError for invalid session", async () => {
await expect(
validateSessionUseCase("nonexistent-session-id")
).rejects.toBeInstanceOf(UnauthenticatedError);
});
});
```
### Step 2: Implement the use case
Create `src/application/use-cases/auth/validate-session.use-case.ts`:
```typescript
import type { Session } from "@/entities/models/session";
import type { User } from "@/entities/models/user";
import { getInjection } from "@/di/container";
export async function validateSessionUseCase(
sessionId: string
): Promise<{ user: User; session: Session }> {
const authService = getInjection("IAuthenticationService");
return await authService.validateSession(sessionId);
}
```
### Step 3: Export from core
Add to `packages/core/src/index.ts`:
```typescript
export { validateSessionUseCase } from "./application/use-cases/auth/validate-session.use-case";
```
### Step 4: Run tests
```bash
cd packages/core && pnpm vitest run
```
### Step 5: Create controller and tRPC procedure (if needed)
Follow the controller recipe in `interface-adapters/controllers/AGENTS.md`.
---
## Security Considerations
1. **Passwords are always hashed before storage.** The `signUpUseCase` calls `authService.hashPassword()` and stores only the hash. The mock uses a simple `hashed_` prefix; production implementations must use bcrypt, argon2, or similar.
2. **Sessions expire.** The mock sets a 7-day expiry. Production implementations should enforce this with database TTLs or cleanup jobs.
3. **Blank cookie on sign-out.** Setting the cookie value to `""` tells the browser to clear it. The cookie name is defined in `config.ts` as `SESSION_COOKIE = "session"`.
4. **No password in responses.** `signUpUseCase` returns `Pick<User, "id" | "username">`, explicitly excluding `passwordHash`.
5. **Error messages should not leak information.** In production, consider using a generic "Invalid credentials" message for both "user not found" and "wrong password" scenarios to prevent username enumeration.
---
## Cross-References
- `application/AGENTS.md` -- General use case patterns and interfaces
- `entities/errors/auth.ts` -- `AuthenticationError`, `UnauthenticatedError`, `UnauthorizedError`
- `entities/models/user.ts` -- `User` type
- `entities/models/session.ts` -- `Session` type
- `entities/models/cookie.ts` -- `Cookie` type
- `di/AGENTS.md` -- How `IUsersRepository` and `IAuthenticationService` are resolved
- `infrastructure/services/mock-auth.service.ts` -- Mock auth implementation details

View File

@@ -1,27 +0,0 @@
import { AuthenticationError } from "@/entities/errors/auth";
import type { Cookie } from "@/entities/models/cookie";
import type { Session } from "@/entities/models/session";
import { getInjection } from "@/di/container";
export async function signInUseCase(input: {
username: string;
password: string;
}): Promise<{ session: Session; cookie: Cookie }> {
const usersRepository = getInjection("IUsersRepository");
const authService = getInjection("IAuthenticationService");
const existingUser = await usersRepository.getUserByUsername(input.username);
if (!existingUser) {
throw new AuthenticationError("User does not exist");
}
const validPassword = await authService.verifyPassword(
existingUser.passwordHash,
input.password
);
if (!validPassword) {
throw new AuthenticationError("Incorrect username or password");
}
return await authService.createSession(existingUser);
}

View File

@@ -1,9 +0,0 @@
import type { Cookie } from "@/entities/models/cookie";
import { getInjection } from "@/di/container";
export async function signOutUseCase(
sessionId: string
): Promise<{ blankCookie: Cookie }> {
const authService = getInjection("IAuthenticationService");
return await authService.invalidateSession(sessionId);
}

View File

@@ -1,39 +0,0 @@
import { AuthenticationError } from "@/entities/errors/auth";
import type { Cookie } from "@/entities/models/cookie";
import type { Session } from "@/entities/models/session";
import type { User } from "@/entities/models/user";
import { getInjection } from "@/di/container";
export async function signUpUseCase(input: {
username: string;
password: string;
}): Promise<{
session: Session;
cookie: Cookie;
user: Pick<User, "id" | "username">;
}> {
const usersRepository = getInjection("IUsersRepository");
const authService = getInjection("IAuthenticationService");
const existingUser = await usersRepository.getUserByUsername(input.username);
if (existingUser) {
throw new AuthenticationError("Username taken");
}
const passwordHash = await authService.hashPassword(input.password);
const userId = authService.generateUserId();
const newUser = await usersRepository.createUser({
id: userId,
username: input.username,
passwordHash,
});
const { cookie, session } = await authService.createSession(newUser);
return {
cookie,
session,
user: { id: newUser.id, username: newUser.username },
};
}

View File

@@ -1,240 +0,0 @@
# Content Domain -- Use Cases
**Path:** `packages/core/src/application/use-cases/content/`
**Domain Responsibility:** Article management -- creation, retrieval, filtering, and publishing workflow. This domain owns the content lifecycle from draft creation through publication.
---
## Complete Business Rules
1. **Articles must have a title and content.** These are required fields validated at the controller level via Zod schemas.
2. **Slugs are auto-generated from the title if not provided.** The `generateSlug()` function in `createArticleUseCase` handles this.
3. **New articles default to "draft" status.** The `status` field is set to `"draft"` on creation. There is no way to create a published article directly.
4. **Articles have an author.** The `authorId` field links to a user. The use case does not verify the author exists (that responsibility belongs to a future authorization layer).
5. **Article IDs are UUIDs.** Generated via `crypto.randomUUID()` at creation time.
6. **Timestamps are set at creation.** Both `createdAt` and `updatedAt` are set to `new Date()` when the article is created.
7. **Filtering is supported.** `getArticlesUseCase` accepts optional `status`, `authorId`, `limit`, and `offset` parameters.
8. **Default pagination is 50 items.** The mock repository defaults `limit` to 50 if not specified.
---
## Error Cases
| Error Class | When Thrown | Use Case |
|---|---|---|
| `InputParseError` | Missing or invalid input fields (title, content, authorId) | `createArticleController` (controller level) |
| `InputParseError` | Invalid filter parameters | `getArticlesController` (controller level) |
| `NotFoundError` | Article not found by ID (future: update/delete operations) | Future use cases |
| `UnauthorizedError` | User cannot edit/delete another user's article (future) | Future use cases |
Note: Current use cases do not throw domain errors directly. Validation happens in controllers. Future use cases (update, delete, publish) will introduce `NotFoundError` and `UnauthorizedError`.
---
## Dependencies
### IArticlesRepository
Provides article data access. All methods:
| Method | Signature | Used By |
|---|---|---|
| `getArticle` | `(id: string) => Promise<Article \| undefined>` | (future use cases) |
| `getArticles` | `(options?: { status?, authorId?, limit?, offset? }) => Promise<Article[]>` | `getArticlesUseCase` |
| `createArticle` | `(input: Article) => Promise<Article>` | `createArticleUseCase` |
| `updateArticle` | `(id: string, input: Partial<Article>) => Promise<Article \| undefined>` | (future use cases) |
---
## Existing Use Cases
### createArticleUseCase
**File:** `create-article.use-case.ts`
**Input:** `{ title: string; content: string; authorId: string; slug?: string }`
**Output:** `Article`
**Logic:**
1. Get `IArticlesRepository` via `getInjection("IArticlesRepository")`.
2. Generate UUID for article ID via `crypto.randomUUID()`.
3. Generate slug from title if not provided (see Slug Generation below).
4. Set `status` to `"draft"`.
5. Set `createdAt` and `updatedAt` to current time.
6. Call `articlesRepository.createArticle()` with the complete article object.
7. Return the created article.
### getArticlesUseCase
**File:** `get-articles.use-case.ts`
**Input:** `{ status?: string; authorId?: string; limit?: number; offset?: number }` (all optional)
**Output:** `Article[]`
**Logic:**
1. Get `IArticlesRepository` via `getInjection("IArticlesRepository")`.
2. Pass options directly to `articlesRepository.getArticles()`.
3. Return the result array.
---
## Slug Generation Logic
The `generateSlug()` function lives inside `create-article.use-case.ts` (private to the module, not exported):
```typescript
function generateSlug(title: string): string {
return title
.toLowerCase() // "My First Article" -> "my first article"
.replace(/[^a-z0-9]+/g, "-") // "my first article" -> "my-first-article"
.replace(/^-|-$/g, ""); // trim leading/trailing hyphens
}
```
Behavior:
- `"My First Article"` -> `"my-first-article"`
- `"Hello, World! #1"` -> `"hello-world-1"`
- `" Leading Spaces "` -> `"leading-spaces"`
- `"UPPER CASE"` -> `"upper-case"`
The same slug generation logic is duplicated in `packages/cms-core/src/collections/articles/hooks/before-change.ts` for the Payload CMS side. If you change the algorithm, update BOTH locations.
If a `slug` is explicitly provided in the input, it is used as-is without any transformation.
---
## Adding a New Content Use Case (Recipe)
Example: adding a `publishArticleUseCase` that changes an article's status from "draft" to "published".
### Step 1: Write the test FIRST
Create `tests/unit/use-cases/content/publish-article.use-case.test.ts`:
```typescript
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
destroyContainer,
initializeContainer,
} from "@/di/container";
import { createArticleUseCase } from "@/application/use-cases/content/create-article.use-case";
import { publishArticleUseCase } from "@/application/use-cases/content/publish-article.use-case";
import { NotFoundError } from "@/entities/errors/common";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("publishArticleUseCase", () => {
it("changes article status to published", async () => {
const article = await createArticleUseCase({
title: "Draft Article",
content: "Content",
authorId: "1",
});
expect(article.status).toBe("draft");
const published = await publishArticleUseCase(article.id);
expect(published.status).toBe("published");
expect(published.id).toBe(article.id);
});
it("throws NotFoundError for non-existent article", async () => {
await expect(
publishArticleUseCase("non-existent-id")
).rejects.toBeInstanceOf(NotFoundError);
});
});
```
### Step 2: Implement the use case
Create `src/application/use-cases/content/publish-article.use-case.ts`:
```typescript
import type { Article } from "@/entities/models/article";
import { NotFoundError } from "@/entities/errors/common";
import { getInjection } from "@/di/container";
export async function publishArticleUseCase(
articleId: string
): Promise<Article> {
const articlesRepository = getInjection("IArticlesRepository");
const updated = await articlesRepository.updateArticle(articleId, {
status: "published",
updatedAt: new Date(),
});
if (!updated) {
throw new NotFoundError("Article not found");
}
return updated;
}
```
### Step 3: Export from core
Add to `packages/core/src/index.ts`:
```typescript
export { publishArticleUseCase } from "./application/use-cases/content/publish-article.use-case";
```
### Step 4: Create controller
Create `src/interface-adapters/controllers/content/` (add to `articles.controller.ts` or create a new file):
```typescript
import { z } from "zod";
import { InputParseError } from "@/entities/errors/common";
import { publishArticleUseCase } from "@/application/use-cases/content/publish-article.use-case";
const publishInputSchema = z.object({
articleId: z.string(),
});
export async function publishArticleController(
input: Partial<z.infer<typeof publishInputSchema>>
) {
const { data, error: inputParseError } = publishInputSchema.safeParse(input);
if (inputParseError) {
throw new InputParseError("Invalid data", { cause: inputParseError });
}
return await publishArticleUseCase(data.articleId);
}
```
### Step 5: Add tRPC procedure
In `packages/api/src/router/content.router.ts`:
```typescript
import { publishArticleController } from "@repo/core";
publishArticle: publicProcedure
.input(z.object({ articleId: z.string() }))
.mutation(async ({ input }) => {
return await publishArticleController(input);
}),
```
### Step 6: Run tests
```bash
cd packages/core && pnpm vitest run
```
---
## Cross-References
- `application/AGENTS.md` -- General use case patterns and the `IArticlesRepository` interface
- `entities/models/article.ts` -- `Article` type, `articleSchema`, `articleStatusSchema`
- `entities/errors/common.ts` -- `NotFoundError`, `InputParseError`
- `infrastructure/repositories/mock-articles.repository.ts` -- Mock implementation with in-memory storage
- `di/AGENTS.md` -- How `IArticlesRepository` is resolved (currently via `content.module.ts`)
- `packages/cms-core/src/collections/articles/` -- Payload CMS Articles collection (parallel data model)

View File

@@ -1,32 +0,0 @@
import type { Article } from "@/entities/models/article";
import { getInjection } from "@/di/container";
function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export async function createArticleUseCase(input: {
title: string;
content: string;
authorId: string;
slug?: string;
}): Promise<Article> {
const articlesRepository = getInjection("IArticlesRepository");
const now = new Date();
const article: Article = {
id: crypto.randomUUID(),
title: input.title,
slug: input.slug ?? generateSlug(input.title),
content: input.content,
status: "draft",
authorId: input.authorId,
createdAt: now,
updatedAt: now,
};
return await articlesRepository.createArticle(article);
}

View File

@@ -1,12 +0,0 @@
import type { Article } from "@/entities/models/article";
import { getInjection } from "@/di/container";
export async function getArticlesUseCase(options?: {
status?: string;
authorId?: string;
limit?: number;
offset?: number;
}): Promise<Article[]> {
const articlesRepository = getInjection("IArticlesRepository");
return await articlesRepository.getArticles(options);
}

View File

@@ -1 +0,0 @@
export const SESSION_COOKIE = "session";

View File

@@ -1,246 +0,0 @@
# DI -- InversifyJS Dependency Injection Container
**Path:** `packages/core/src/di/`
**Role:** Wire together all abstract interfaces and their concrete implementations using InversifyJS. The container is the root composition point -- it knows about every layer and resolves dependencies at runtime. All other code accesses dependencies through `getInjection()`, never by importing implementations directly.
---
## Complete Resolution Table
| Symbol Key | Interface | Production Implementation | Mock Implementation | DI Module |
|---|---|---|---|---|
| `IUsersRepository` | `IUsersRepository` (getUser, getUserByUsername, createUser) | PayloadUsersRepository (future) | `MockUsersRepository` (`infrastructure/repositories/mock-users.repository.ts`) | `auth.module.ts` |
| `IArticlesRepository` | `IArticlesRepository` (getArticle, getArticles, createArticle, updateArticle) | PayloadArticlesRepository (future) | `MockArticlesRepository` (`infrastructure/repositories/mock-articles.repository.ts`) | `content.module.ts` |
| `IAuthenticationService` | `IAuthenticationService` (generateUserId, hashPassword, verifyPassword, validateSession, createSession, invalidateSession) | BetterAuthService (future) | `MockAuthenticationService` (`infrastructure/services/mock-auth.service.ts`) | `auth.module.ts` |
| `ITelemetryService` | `ITelemetryService` (startSpan) | OTelSentryService (future) | `MockTelemetryService` (`infrastructure/services/mock-telemetry.service.ts`) | `auth.module.ts` |
---
## How to Register a New Dependency (Full Recipe)
### Step 1: Add symbol and return type to `types.ts`
```typescript
// di/types.ts
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
export const DI_SYMBOLS = {
IAuthenticationService: Symbol.for("IAuthenticationService"),
ITelemetryService: Symbol.for("ITelemetryService"),
IUsersRepository: Symbol.for("IUsersRepository"),
IArticlesRepository: Symbol.for("IArticlesRepository"),
IMyRepository: Symbol.for("IMyRepository"), // <-- ADD THIS
};
export interface DI_RETURN_TYPES {
IAuthenticationService: IAuthenticationService;
ITelemetryService: ITelemetryService;
IUsersRepository: IUsersRepository;
IArticlesRepository: IArticlesRepository;
IMyRepository: IMyRepository; // <-- ADD THIS
}
```
The `DI_SYMBOLS` object maps string keys to unique `Symbol` values (used by InversifyJS for binding). The `DI_RETURN_TYPES` interface provides TypeScript type safety for `getInjection()`.
### Step 2: Create the DI module file
Create `di/modules/{domain}.module.ts` (or add to an existing one):
```typescript
// di/modules/my-domain.module.ts
import { ContainerModule, interfaces } from "inversify";
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
import { MockMyRepository } from "@/infrastructure/repositories/mock-my.repository";
import { DI_SYMBOLS } from "../types";
const initializeModule = (bind: interfaces.Bind) => {
bind<IMyRepository>(DI_SYMBOLS.IMyRepository).to(MockMyRepository);
};
export const MyDomainModule = new ContainerModule(initializeModule);
```
For modules with multiple bindings (like `auth.module.ts`):
```typescript
const initializeModule = (bind: interfaces.Bind) => {
bind<IUsersRepository>(DI_SYMBOLS.IUsersRepository).to(MockUsersRepository);
bind<IAuthenticationService>(DI_SYMBOLS.IAuthenticationService).to(
MockAuthenticationService
);
};
```
### Step 3: Load and unload the module in `container.ts`
```typescript
// di/container.ts
import { MyDomainModule } from "./modules/my-domain.module";
export const initializeContainer = () => {
ApplicationContainer.load(AuthModule);
ApplicationContainer.load(ContentModule);
ApplicationContainer.load(MyDomainModule); // <-- ADD THIS
};
export const destroyContainer = () => {
ApplicationContainer.unload(AuthModule);
ApplicationContainer.unload(ContentModule);
ApplicationContainer.unload(MyDomainModule); // <-- ADD THIS
};
```
Both `load` and `unload` must be updated. Forgetting `unload` causes test isolation failures.
---
## Container Lifecycle
### Production (non-test environments)
```typescript
if (process.env.NODE_ENV !== "test") {
initializeContainer();
}
```
The container auto-initializes when the module is first imported. All bindings are available immediately.
### Test environments
Tests manually control the container lifecycle:
```typescript
import "reflect-metadata";
import { afterEach, beforeEach } from "vitest";
import { destroyContainer, initializeContainer } from "@/di/container";
beforeEach(() => {
initializeContainer(); // Load all modules, bind all implementations
});
afterEach(() => {
destroyContainer(); // Unload all modules, clear all bindings
});
```
Why manual control in tests:
- Each test gets a fresh container with fresh Singleton instances.
- In-memory mock data does not leak between tests.
- Forgetting `destroyContainer` in `afterEach` causes state pollution: the mock repositories retain data from previous tests, causing flaky tests.
---
## Scoping
The container defaults to **Singleton** scope:
```typescript
const ApplicationContainer = new Container({
defaultScope: "Singleton",
});
```
This means each call to `getInjection("IUsersRepository")` within a container lifecycle returns the same instance. This is correct for:
- Repositories (stateful mocks, connection pools in production)
- Services (auth sessions, telemetry clients)
When to use **Transient** scope (a new instance per resolution):
- Stateless utility services
- Per-request scoped objects
To override for a specific binding:
```typescript
bind<IMyService>(DI_SYMBOLS.IMyService)
.to(MyService)
.inTransientScope();
```
---
## `getInjection()` Usage in Use Cases
```typescript
// In a use case file:
import { getInjection } from "@/di/container";
export async function myUseCase(input: { id: string }) {
// The string key is type-safe: it must match a key in DI_SYMBOLS.
// The return type is automatically inferred from DI_RETURN_TYPES.
const usersRepository = getInjection("IUsersRepository");
// ^-- TypeScript infers: IUsersRepository
const authService = getInjection("IAuthenticationService");
// ^-- TypeScript infers: IAuthenticationService
const user = await usersRepository.getUser(input.id);
// ...
}
```
The `getInjection` function signature:
```typescript
export function getInjection<K extends keyof typeof DI_SYMBOLS>(
symbol: K
): DI_RETURN_TYPES[K] {
return ApplicationContainer.get(DI_SYMBOLS[symbol]);
}
```
This provides full type safety: if you pass `"IUsersRepository"`, the return type is `IUsersRepository`. If you pass an invalid key, TypeScript reports a compile error.
---
## DO NOT
| Do Not | Why |
|---|---|
| Import from `apps/*` or framework packages (Next.js, TanStack) | DI is framework-agnostic. Framework code lives in apps. |
| Use the container outside of `@repo/core` | All external access goes through `getInjection()` or exported use case / controller functions. |
| Call `ApplicationContainer.get()` directly from use cases | Use `getInjection()` instead -- it provides type safety and a consistent API. |
| Forget to unload modules in `destroyContainer()` | Causes test state pollution (mock data leaks between tests). |
| Remove `import "reflect-metadata"` from `container.ts` | InversifyJS uses runtime reflection to read constructor parameter types. Without this import, `@inject()` decorators silently fail. |
---
## tsconfig Requirements
These settings are required for InversifyJS and MUST NOT be removed:
| Setting | Where | Why |
|---|---|---|
| `experimentalDecorators: true` | `@repo/typescript-config/base.json` | Enables `@injectable()` and `@inject()` decorator syntax used by InversifyJS |
| `emitDecoratorMetadata: true` | `@repo/typescript-config/base.json` | Emits runtime type metadata that InversifyJS reads to auto-resolve constructor parameter types |
| `types: ["reflect-metadata", "node"]` | `packages/core/tsconfig.json` | Makes `reflect-metadata` type definitions globally available. Required for `emitDecoratorMetadata` to function |
| `import "reflect-metadata"` | Top of `container.ts` and every test file | Polyfills the `Reflect.metadata` API at runtime. Without this, decorator metadata is not stored and `@inject()` silently injects `undefined` |
If any of these are removed, InversifyJS will throw errors like:
- `"No matching bindings found"` (metadata not emitted)
- `"Missing required @injectable annotation"` (decorators not enabled)
- `"Cannot read properties of undefined"` (reflect-metadata not imported)
---
## File Structure
```
di/
AGENTS.md
types.ts <-- DI_SYMBOLS + DI_RETURN_TYPES
container.ts <-- ApplicationContainer, initializeContainer, destroyContainer, getInjection
modules/
auth.module.ts <-- Binds IUsersRepository, IAuthenticationService, ITelemetryService
content.module.ts <-- Binds IArticlesRepository
```
---
## Cross-References
- `application/AGENTS.md` -- Defines the interfaces that are bound here
- `infrastructure/AGENTS.md` -- Provides the implementations that are bound here
- Root `AGENTS.md` -- Shows how DI fits into the full feature recipe

View File

@@ -1,32 +0,0 @@
import "reflect-metadata";
import { Container } from "inversify";
import { AuthModule } from "./modules/auth.module";
import { ContentModule } from "./modules/content.module";
import { DI_RETURN_TYPES, DI_SYMBOLS } from "./types";
const ApplicationContainer = new Container({
defaultScope: "Singleton",
});
export const initializeContainer = () => {
ApplicationContainer.load(AuthModule);
ApplicationContainer.load(ContentModule);
};
export const destroyContainer = () => {
ApplicationContainer.unload(AuthModule);
ApplicationContainer.unload(ContentModule);
};
if (process.env.NODE_ENV !== "test") {
initializeContainer();
}
export function getInjection<K extends keyof typeof DI_SYMBOLS>(
symbol: K
): DI_RETURN_TYPES[K] {
return ApplicationContainer.get(DI_SYMBOLS[symbol]);
}
export { ApplicationContainer };

View File

@@ -1,16 +0,0 @@
import { ContainerModule, interfaces } from "inversify";
import type { IUsersRepository } from "@/application/repositories/users.repository.interface";
import type { IAuthenticationService } from "@/application/services/auth.service.interface";
import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository";
import { MockAuthenticationService } from "@/infrastructure/services/mock-auth.service";
import { DI_SYMBOLS } from "../types";
const initializeModule = (bind: interfaces.Bind) => {
bind<IUsersRepository>(DI_SYMBOLS.IUsersRepository).to(MockUsersRepository);
bind<IAuthenticationService>(DI_SYMBOLS.IAuthenticationService).to(
MockAuthenticationService
);
};
export const AuthModule = new ContainerModule(initializeModule);

View File

@@ -1,13 +0,0 @@
import { ContainerModule, interfaces } from "inversify";
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository";
import { DI_SYMBOLS } from "../types";
const initializeModule = (bind: interfaces.Bind) => {
bind<IArticlesRepository>(DI_SYMBOLS.IArticlesRepository).to(
MockArticlesRepository
);
};
export const ContentModule = new ContainerModule(initializeModule);

View File

@@ -1,18 +0,0 @@
import type { IAuthenticationService } from "@/application/services/auth.service.interface";
import type { ITelemetryService } from "@/application/services/telemetry.service.interface";
import type { IUsersRepository } from "@/application/repositories/users.repository.interface";
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
export const DI_SYMBOLS = {
IAuthenticationService: Symbol.for("IAuthenticationService"),
ITelemetryService: Symbol.for("ITelemetryService"),
IUsersRepository: Symbol.for("IUsersRepository"),
IArticlesRepository: Symbol.for("IArticlesRepository"),
};
export interface DI_RETURN_TYPES {
IAuthenticationService: IAuthenticationService;
ITelemetryService: ITelemetryService;
IUsersRepository: IUsersRepository;
IArticlesRepository: IArticlesRepository;
}

View File

@@ -1,212 +0,0 @@
# Entities Layer -- Innermost, Zero Dependencies
**Path:** `packages/core/src/entities/`
**Role:** Define the pure domain types (models) and domain error classes. This is the innermost layer of Clean Architecture. Nothing here has side effects, I/O, or async behavior.
---
## Rules
1. **NEVER** import from `application/`, `infrastructure/`, `interface-adapters/`, or `di/`.
2. **NEVER** import external libraries except `zod` (for schema validation in models).
3. Everything is **pure** -- no side effects, no I/O, no `async`, no `fetch`, no database calls.
4. Models are Zod schemas with inferred TypeScript types.
5. Errors are custom `Error` subclasses with domain-specific semantics.
6. Error classes always accept `(message: string, options?: ErrorOptions)` to support error chaining via `cause`.
---
## Existing Models
| Model | File | Schema | Fields |
|---|---|---|---|
| User | `models/user.ts` | `userSchema` | `id`, `username`, `passwordHash` |
| Article | `models/article.ts` | `articleSchema` | `id`, `title`, `slug`, `content`, `status` ("draft"/"published"), `authorId`, `createdAt`, `updatedAt` |
| Session | `models/session.ts` | `sessionSchema` | `id`, `userId`, `expiresAt` |
| Cookie | `models/cookie.ts` | (plain type, no Zod) | `name`, `value`, `attributes` (secure, path, domain, sameSite, httpOnly, maxAge, expires) |
---
## Existing Errors
| Error Class | File | When Thrown |
|---|---|---|
| `AuthenticationError` | `errors/auth.ts` | Wrong credentials during sign-in, or username already taken during sign-up |
| `UnauthenticatedError` | `errors/auth.ts` | Invalid or expired session when validating authentication |
| `UnauthorizedError` | `errors/auth.ts` | User lacks permission for the requested operation (future use) |
| `NotFoundError` | `errors/common.ts` | Requested resource does not exist (future use in update/delete operations) |
| `InputParseError` | `errors/common.ts` | Controller Zod validation fails. The `cause` property contains the `ZodError` for detailed field-level messages |
---
## Complete Model Template
Use this template when creating a new entity model:
```typescript
// src/entities/models/{name}.ts
import { z } from "zod";
// 1. Define the Zod schema with all validation rules
export const {name}Schema = z.object({
id: z.string(),
// ... add fields with Zod validators
createdAt: z.date(),
updatedAt: z.date(),
});
// 2. Infer the TypeScript type from the schema
export type {Name} = z.infer<typeof {name}Schema>;
// 3. If you have enum-like fields, define them separately for reuse:
// export const {name}StatusSchema = z.enum(["active", "inactive"]);
// export type {Name}Status = z.infer<typeof {name}StatusSchema>;
```
Real example from `article.ts`:
```typescript
import { z } from "zod";
export const articleStatusSchema = z.enum(["draft", "published"]);
export const articleSchema = z.object({
id: z.string(),
title: z.string().min(1).max(255),
slug: z.string().min(1).max(255),
content: z.string(),
status: articleStatusSchema.default("draft"),
authorId: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type Article = z.infer<typeof articleSchema>;
export type ArticleStatus = z.infer<typeof articleStatusSchema>;
```
---
## Complete Error Template
Use this template when creating a new error class:
```typescript
// src/entities/errors/{domain}.ts
export class {Name}Error extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
```
Real example from `auth.ts`:
```typescript
export class AuthenticationError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
export class UnauthenticatedError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
export class UnauthorizedError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
```
The `options?: ErrorOptions` parameter is important -- it enables error chaining. Controllers pass Zod errors as `cause`:
```typescript
throw new InputParseError("Invalid data", { cause: zodError });
```
---
## Adding a New Model (Step-by-Step)
### Step 1: Create the model file
Create `src/entities/models/{name}.ts` following the template above.
### Step 2: Export from the models barrel
Edit `src/entities/models/index.ts` and add:
```typescript
export { {name}Schema, type {Name} } from "./{name}";
```
### Step 3: Verify the export chain
The chain `models/index.ts` -> `entities/index.ts` -> `core/src/index.ts` uses `export *` at each level, so you only need to update `models/index.ts`. The exports will automatically flow through:
- `src/entities/index.ts` contains: `export * from "./models/index";`
- `src/index.ts` contains: `export * from "./entities/index";`
### Step 4: Use in application layer
The model type is now available in repository interfaces, use cases, and controllers via:
```typescript
import type { {Name} } from "@/entities/models/{name}";
```
---
## Adding a New Error (Step-by-Step)
### Step 1: Create or edit the error file
If adding to an existing domain (e.g., auth), edit `src/entities/errors/auth.ts`.
If creating a new domain, create `src/entities/errors/{domain}.ts`.
### Step 2: Export from the errors barrel
Edit `src/entities/errors/index.ts`:
```typescript
export { {Name}Error } from "./{domain}";
```
### Step 3: Verify the export chain
Same chain as models: `errors/index.ts` -> `entities/index.ts` -> `core/src/index.ts`. You only need to update `errors/index.ts`.
### Step 4: Use in use cases and controllers
```typescript
import { {Name}Error } from "@/entities/errors/{domain}";
// In a use case:
if (somethingWrong) {
throw new {Name}Error("Descriptive message");
}
```
---
## File Structure
```
entities/
AGENTS.md
index.ts <-- re-exports models/* and errors/*
models/
index.ts <-- barrel: exports all models
user.ts
article.ts
session.ts
cookie.ts
errors/
index.ts <-- barrel: exports all errors
auth.ts <-- AuthenticationError, UnauthenticatedError, UnauthorizedError
common.ts <-- NotFoundError, InputParseError
```

View File

@@ -1,17 +0,0 @@
export class AuthenticationError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
export class UnauthenticatedError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
export class UnauthorizedError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}

View File

@@ -1,11 +0,0 @@
export class NotFoundError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
export class InputParseError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}

View File

@@ -1,6 +0,0 @@
export {
AuthenticationError,
UnauthenticatedError,
UnauthorizedError,
} from "./auth";
export { NotFoundError, InputParseError } from "./common";

View File

@@ -1,2 +0,0 @@
export * from "./models/index";
export * from "./errors/index";

View File

@@ -1,17 +0,0 @@
import { z } from "zod";
export const articleStatusSchema = z.enum(["draft", "published"]);
export const articleSchema = z.object({
id: z.string(),
title: z.string().min(1).max(255),
slug: z.string().min(1).max(255),
content: z.string(),
status: articleStatusSchema.default("draft"),
authorId: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type Article = z.infer<typeof articleSchema>;
export type ArticleStatus = z.infer<typeof articleStatusSchema>;

View File

@@ -1,15 +0,0 @@
type CookieAttributes = {
secure?: boolean;
path?: string;
domain?: string;
sameSite?: "lax" | "strict" | "none";
httpOnly?: boolean;
maxAge?: number;
expires?: Date;
};
export type Cookie = {
name: string;
value: string;
attributes: CookieAttributes;
};

View File

@@ -1,9 +0,0 @@
export { userSchema, type User } from "./user";
export {
articleSchema,
articleStatusSchema,
type Article,
type ArticleStatus,
} from "./article";
export { sessionSchema, type Session } from "./session";
export type { Cookie } from "./cookie";

View File

@@ -1,9 +0,0 @@
import { z } from "zod";
export const sessionSchema = z.object({
id: z.string(),
userId: z.string(),
expiresAt: z.date(),
});
export type Session = z.infer<typeof sessionSchema>;

View File

@@ -1,9 +0,0 @@
import { z } from "zod";
export const userSchema = z.object({
id: z.string(),
username: z.string().min(3).max(31),
passwordHash: z.string().min(6).max(255),
});
export type User = z.infer<typeof userSchema>;

View File

@@ -1,22 +0,0 @@
// @repo/core — Clean Architecture core package
export * from "./entities/index";
export * from "./application/repositories/index";
export * from "./application/services/index";
export { signInUseCase } from "./application/use-cases/auth/sign-in.use-case";
export { signUpUseCase } from "./application/use-cases/auth/sign-up.use-case";
export { signOutUseCase } from "./application/use-cases/auth/sign-out.use-case";
export { createArticleUseCase } from "./application/use-cases/content/create-article.use-case";
export { getArticlesUseCase } from "./application/use-cases/content/get-articles.use-case";
export { signInController } from "./interface-adapters/controllers/auth/sign-in.controller";
export { signUpController } from "./interface-adapters/controllers/auth/sign-up.controller";
export { signOutController } from "./interface-adapters/controllers/auth/sign-out.controller";
export {
createArticleController,
getArticlesController,
} from "./interface-adapters/controllers/content/articles.controller";
export {
getInjection,
initializeContainer,
destroyContainer,
} from "./di/container";
export { DI_SYMBOLS } from "./di/types";

View File

@@ -1,261 +0,0 @@
# Infrastructure Layer -- Concrete Implementations
**Path:** `packages/core/src/infrastructure/`
**Role:** Provide concrete implementations of the abstract interfaces defined in `application/`. Every repository interface and service interface gets at least one implementation here. All implementations use the `@injectable()` decorator for InversifyJS DI.
---
## Rules
1. Implements interfaces from `application/` (repository or service interfaces).
2. Imports from `application/` (interfaces) and `entities/` (types, errors).
3. **NEVER** imported by `application/` or `entities/`. The dependency arrow points inward: infrastructure depends on application, not the reverse.
4. **NEVER** imported by `interface-adapters/` (controllers). Controllers use interfaces via DI.
5. Can import external libraries (Payload client, Better Auth, Sentry, etc.).
6. Can import `@repo/cms-client` -- this is the bridge to Payload CMS data. Note: `@repo/cms-client` is standalone with zero monorepo dependencies.
7. Every implementation class MUST have the `@injectable()` decorator. Without it, InversifyJS cannot construct the class.
8. Always provide a **mock implementation** for every real implementation. Tests run with mocks; production runs with real implementations.
---
## Existing Implementations
| Implementation | File | Implements | Type |
|---|---|---|---|
| `MockUsersRepository` | `repositories/mock-users.repository.ts` | `IUsersRepository` | Mock (in-memory array, pre-seeded with alice + bob) |
| `MockArticlesRepository` | `repositories/mock-articles.repository.ts` | `IArticlesRepository` | Mock (in-memory array, starts empty) |
| `MockAuthenticationService` | `services/mock-auth.service.ts` | `IAuthenticationService` | Mock (hashed_password prefix, in-memory session store) |
| `MockTelemetryService` | `services/mock-telemetry.service.ts` | `ITelemetryService` | Mock (no-op, just calls the wrapped function) |
The `MockAuthenticationService` is notable because it uses constructor injection:
```typescript
@injectable()
export class MockAuthenticationService implements IAuthenticationService {
constructor(
@inject(DI_SYMBOLS.IUsersRepository)
private _usersRepository: IUsersRepository
) {}
// ...
}
```
This demonstrates how InversifyJS resolves nested dependencies automatically. When the container creates `MockAuthenticationService`, it first resolves `IUsersRepository` and injects it.
---
## Naming Conventions
| Type | Pattern | Example |
|---|---|---|
| Production repository | `{provider}-{name}.repository.ts` | `payload-users.repository.ts` |
| Mock repository | `mock-{name}.repository.ts` | `mock-users.repository.ts` |
| Production service | `{provider}-{name}.service.ts` | `better-auth.service.ts`, `otel-telemetry.service.ts` |
| Mock service | `mock-{name}.service.ts` | `mock-auth.service.ts` |
The `{provider}` prefix identifies the external system: `payload`, `better`, `otel`, `sentry`, etc.
---
## Implementation Template (Repository)
```typescript
// src/infrastructure/repositories/{provider}-{name}.repository.ts
import { injectable } from "inversify";
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
import type { MyEntity } from "@/entities/models/my-entity";
@injectable()
export class ProviderMyRepository implements IMyRepository {
// Constructor can accept injected dependencies if needed:
// constructor(
// @inject(DI_SYMBOLS.ISomeDep) private someDep: ISomeDep
// ) {}
async findById(id: string): Promise<MyEntity | undefined> {
// Call external system (database, API, etc.)
throw new Error("Not implemented");
}
async findAll(): Promise<MyEntity[]> {
throw new Error("Not implemented");
}
async create(input: MyEntity): Promise<MyEntity> {
throw new Error("Not implemented");
}
async update(id: string, input: Partial<MyEntity>): Promise<MyEntity | undefined> {
throw new Error("Not implemented");
}
}
```
---
## Mock Implementation Template
```typescript
// src/infrastructure/repositories/mock-{name}.repository.ts
import { injectable } from "inversify";
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
import type { MyEntity } from "@/entities/models/my-entity";
@injectable()
export class MockMyRepository implements IMyRepository {
private _items: MyEntity[] = [];
async findById(id: string): Promise<MyEntity | undefined> {
return this._items.find((item) => item.id === id);
}
async findAll(): Promise<MyEntity[]> {
return [...this._items];
}
async create(input: MyEntity): Promise<MyEntity> {
this._items.push(input);
return input;
}
async update(id: string, input: Partial<MyEntity>): Promise<MyEntity | undefined> {
const index = this._items.findIndex((item) => item.id === id);
if (index === -1) return undefined;
this._items[index] = { ...this._items[index]!, ...input };
return this._items[index];
}
}
```
Mock pattern notes:
- Use `private _items: MyEntity[] = []` for in-memory storage.
- Spread arrays (`[...this._items]`) to avoid returning mutable references.
- Use `Array.find` for single lookups, `Array.filter` for queries.
- Use `Array.findIndex` + splice/spread for updates.
- Pre-seed data in the array initializer if tests need existing records (see `MockUsersRepository` which pre-seeds alice and bob).
---
## Adding a New Implementation (Recipe)
### Step 1: Create the implementation file
Follow the implementation template above. Place it in:
- `repositories/` for data access implementations
- `services/` for external service implementations
### Step 2: Ensure `@injectable()` is present
```typescript
import { injectable } from "inversify";
@injectable()
export class MyImplementation implements IMyInterface {
// ...
}
```
If the class needs another DI dependency injected via constructor, use `@inject()`:
```typescript
import { inject, injectable } from "inversify";
import { DI_SYMBOLS } from "@/di/types";
@injectable()
export class MyImplementation implements IMyInterface {
constructor(
@inject(DI_SYMBOLS.IOtherDependency)
private _otherDep: IOtherDependency
) {}
}
```
### Step 3: Create or verify the mock implementation
If this is a production implementation, verify that a corresponding mock already exists. If adding a new interface, create the mock at the same time.
### Step 4: Register in the DI module
Update the appropriate module in `di/modules/` to bind the implementation:
```typescript
// For development/mock:
bind<IMyInterface>(DI_SYMBOLS.IMyInterface).to(MockMyImplementation);
// For production (when adding real implementations):
// if (process.env.NODE_ENV === "production") {
// bind<IMyInterface>(DI_SYMBOLS.IMyInterface).to(RealMyImplementation);
// } else {
// bind<IMyInterface>(DI_SYMBOLS.IMyInterface).to(MockMyImplementation);
// }
```
### Step 5: Test via use cases
Infrastructure implementations are tested indirectly through use case tests. The DI container wires mock implementations in test mode. Run `pnpm vitest run` to verify.
---
## Note on @repo/cms-client
The `@repo/cms-client` package is standalone. It provides a `PayloadClient` interface with two modes:
- **Local mode**: Direct Payload SDK access (for server-side code co-located with Payload)
- **HTTP mode**: REST API calls (for code running separately from Payload)
Infrastructure implementations can import and use `@repo/cms-client` to bridge Payload CMS data into Clean Architecture:
```typescript
import { injectable } from "inversify";
import { createPayloadClient, type PayloadClient } from "@repo/cms-client";
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
import type { Article } from "@/entities/models/article";
@injectable()
export class PayloadArticlesRepository implements IArticlesRepository {
private client: PayloadClient;
constructor() {
this.client = createPayloadClient({
mode: "http",
baseURL: process.env.CMS_URL ?? "http://localhost:3001",
});
}
async getArticles(options?: { status?: string }): Promise<Article[]> {
const result = await this.client.find<Article>("articles", {
where: options?.status ? { status: { equals: options.status } } : undefined,
});
return result.docs;
}
// ... other methods
}
```
This is the intended pattern for production implementations. The infrastructure layer owns the translation between Payload's data format and the core entity types.
---
## File Structure
```
infrastructure/
AGENTS.md
repositories/
mock-users.repository.ts
mock-articles.repository.ts
services/
mock-auth.service.ts
mock-telemetry.service.ts
```
---
## Cross-References
- `application/AGENTS.md` -- Defines the interfaces that implementations here must satisfy
- `entities/AGENTS.md` -- Types used in implementation method signatures
- `di/AGENTS.md` -- Where implementations are bound to their interface symbols

View File

@@ -1,46 +0,0 @@
import { injectable } from "inversify";
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
import type { Article } from "@/entities/models/article";
@injectable()
export class MockArticlesRepository implements IArticlesRepository {
private _articles: Article[] = [];
async getArticle(id: string): Promise<Article | undefined> {
return this._articles.find((a) => a.id === id);
}
async getArticles(options?: {
status?: string;
authorId?: string;
limit?: number;
offset?: number;
}): Promise<Article[]> {
let result = [...this._articles];
if (options?.status) {
result = result.filter((a) => a.status === options.status);
}
if (options?.authorId) {
result = result.filter((a) => a.authorId === options.authorId);
}
const offset = options?.offset ?? 0;
const limit = options?.limit ?? 50;
return result.slice(offset, offset + limit);
}
async createArticle(input: Article): Promise<Article> {
this._articles.push(input);
return input;
}
async updateArticle(
id: string,
input: Partial<Article>
): Promise<Article | undefined> {
const index = this._articles.findIndex((a) => a.id === id);
if (index === -1) return undefined;
this._articles[index] = { ...this._articles[index]!, ...input };
return this._articles[index];
}
}

View File

@@ -1,25 +0,0 @@
import { injectable } from "inversify";
import type { IUsersRepository } from "@/application/repositories/users.repository.interface";
import type { User } from "@/entities/models/user";
@injectable()
export class MockUsersRepository implements IUsersRepository {
private _users: User[] = [
{ id: "1", username: "alice", passwordHash: "hashed_password_alice" },
{ id: "2", username: "bob", passwordHash: "hashed_password_bob" },
];
async getUser(id: string): Promise<User | undefined> {
return this._users.find((u) => u.id === id);
}
async getUserByUsername(username: string): Promise<User | undefined> {
return this._users.find((u) => u.username === username);
}
async createUser(input: User): Promise<User> {
this._users.push(input);
return input;
}
}

View File

@@ -1,72 +0,0 @@
import { inject, injectable } from "inversify";
import type { IAuthenticationService } from "@/application/services/auth.service.interface";
import type { IUsersRepository } from "@/application/repositories/users.repository.interface";
import { UnauthenticatedError } from "@/entities/errors/auth";
import { sessionSchema, type Session } from "@/entities/models/session";
import type { Cookie } from "@/entities/models/cookie";
import type { User } from "@/entities/models/user";
import { DI_SYMBOLS } from "@/di/types";
import { SESSION_COOKIE } from "@/config";
@injectable()
export class MockAuthenticationService implements IAuthenticationService {
private _sessions: Record<string, { session: Session; user: User }> = {};
constructor(
@inject(DI_SYMBOLS.IUsersRepository)
private _usersRepository: IUsersRepository
) {}
generateUserId(): string {
return (Math.random() + 1).toString(36).substring(7);
}
async hashPassword(password: string): Promise<string> {
return `hashed_${password}`;
}
async verifyPassword(hash: string, password: string): Promise<boolean> {
return hash === `hashed_${password}`;
}
async validateSession(
sessionId: string
): Promise<{ user: User; session: Session }> {
const result = this._sessions[sessionId];
if (!result) {
throw new UnauthenticatedError("Unauthenticated");
}
const user = await this._usersRepository.getUser(result.user.id);
if (!user) {
throw new UnauthenticatedError("Unauthenticated");
}
return { user, session: result.session };
}
async createSession(
user: User
): Promise<{ session: Session; cookie: Cookie }> {
const session = sessionSchema.parse({
id: "session_" + user.id,
userId: user.id,
expiresAt: new Date(Date.now() + 86400000 * 7),
});
const cookie: Cookie = {
name: SESSION_COOKIE,
value: session.id,
attributes: {},
};
this._sessions[session.id] = { session, user };
return { session, cookie };
}
async invalidateSession(
sessionId: string
): Promise<{ blankCookie: Cookie }> {
delete this._sessions[sessionId];
return {
blankCookie: { name: SESSION_COOKIE, value: "", attributes: {} },
};
}
}

View File

@@ -1,10 +0,0 @@
import { injectable } from "inversify";
import type { ITelemetryService } from "@/application/services/telemetry.service.interface";
@injectable()
export class MockTelemetryService implements ITelemetryService {
async startSpan<T>(_name: string, fn: () => T | Promise<T>): Promise<T> {
return fn();
}
}

View File

@@ -1,279 +0,0 @@
# Controllers -- Interface Adapters Layer
**Path:** `packages/core/src/interface-adapters/controllers/`
**Role:** Controllers are the outermost layer within `@repo/core`. They validate external input using Zod schemas, delegate to use cases for business logic, and return results. Controllers are called by tRPC router procedures in `@repo/api`.
---
## Rules
1. Controllers validate input with Zod `safeParse` and throw `InputParseError` on failure.
2. Controllers call use cases -- they **NEVER** contain business logic themselves.
3. Controllers import from `application/` (use cases) and `entities/` (types, errors) only.
4. Controllers **NEVER** import from `infrastructure/`. They must not know about concrete data access.
5. Controllers **NEVER** import from `di/` directly. They call use case functions, which internally use `getInjection()`.
6. Each controller function is a plain `async function`, not a class. This keeps them lightweight and easy to call from any transport layer (tRPC, REST, GraphQL).
7. Input parameters use `Partial<z.infer<typeof schema>>` to accept potentially incomplete input, then validate with `safeParse`.
---
## Existing Controllers
| Controller | File | Functions | Calls |
|---|---|---|---|
| Sign In | `auth/sign-in.controller.ts` | `signInController(input)` | `signInUseCase` |
| Sign Up | `auth/sign-up.controller.ts` | `signUpController(input)` | `signUpUseCase` |
| Sign Out | `auth/sign-out.controller.ts` | `signOutController(sessionId)` | `signOutUseCase` |
| Articles | `content/articles.controller.ts` | `createArticleController(input)`, `getArticlesController(input)` | `createArticleUseCase`, `getArticlesUseCase` |
---
## Complete Controller Template
```typescript
// src/interface-adapters/controllers/{domain}/{name}.controller.ts
import { z } from "zod";
import { InputParseError } from "@/entities/errors/common";
import type { MyEntity } from "@/entities/models/my-entity";
import { myUseCase } from "@/application/use-cases/{domain}/my.use-case";
// 1. Define input validation schema
const inputSchema = z.object({
field1: z.string().min(1).max(255),
field2: z.number().positive(),
optionalField: z.string().optional(),
});
// 2. Export the controller function
export async function myController(
input: Partial<z.infer<typeof inputSchema>>
): Promise<MyEntity> {
// 3. Validate input with safeParse
const { data, error: inputParseError } = inputSchema.safeParse(input);
// 4. Throw InputParseError if validation fails
if (inputParseError) {
throw new InputParseError("Invalid data", { cause: inputParseError });
}
// 5. Delegate to use case and return result
return await myUseCase(data);
}
```
---
## Error Mapping Pattern
Controllers handle two categories of errors:
### 1. Input Validation Errors (thrown by the controller itself)
When Zod `safeParse` fails, the controller throws `InputParseError` with the `ZodError` as `cause`:
```typescript
const { data, error: inputParseError } = inputSchema.safeParse(input);
if (inputParseError) {
throw new InputParseError("Invalid data", { cause: inputParseError });
}
```
The tRPC router (or any other caller) can inspect `error.cause` to extract field-level validation messages.
### 2. Business Logic Errors (thrown by use cases, pass through the controller)
Use cases throw domain errors (`AuthenticationError`, `NotFoundError`, etc.). Controllers do NOT catch these -- they propagate to the caller. The tRPC error handler maps them to appropriate HTTP status codes.
### Error Flow
```
Controller
|
+--> InputParseError (Zod validation failed) -> 400 Bad Request
|
+--> Use Case
|
+--> AuthenticationError -> 401 Unauthorized
+--> UnauthenticatedError -> 401 Unauthorized
+--> UnauthorizedError -> 403 Forbidden
+--> NotFoundError -> 404 Not Found
+--> (unexpected) -> 500 Internal Server Error
```
---
## Real Examples
### Simple validation (sign-in.controller.ts)
```typescript
import { z } from "zod";
import { InputParseError } from "@/entities/errors/common";
import type { Cookie } from "@/entities/models/cookie";
import { signInUseCase } from "@/application/use-cases/auth/sign-in.use-case";
const inputSchema = z.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
});
export async function signInController(
input: Partial<z.infer<typeof inputSchema>>
): Promise<Cookie> {
const { data, error: inputParseError } = inputSchema.safeParse(input);
if (inputParseError) {
throw new InputParseError("Invalid data", { cause: inputParseError });
}
const { cookie } = await signInUseCase(data);
return cookie;
}
```
### Cross-field validation (sign-up.controller.ts)
```typescript
const inputSchema = z
.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
confirmPassword: z.string().min(6).max(255),
})
.superRefine(({ password, confirmPassword }, ctx) => {
if (confirmPassword !== password) {
ctx.addIssue({
code: "custom",
message: "The passwords did not match",
path: ["password"],
});
ctx.addIssue({
code: "custom",
message: "The passwords did not match",
path: ["confirmPassword"],
});
}
});
```
### Multiple functions in one file (articles.controller.ts)
When a domain has related operations, group them in one controller file:
```typescript
const createInputSchema = z.object({ title: z.string().min(1), /* ... */ });
const getInputSchema = z.object({ status: z.string().optional(), /* ... */ });
export async function createArticleController(input) { /* ... */ }
export async function getArticlesController(input) { /* ... */ }
```
---
## Adding a New Controller (Recipe)
### Step 1: Create the controller file
Create `src/interface-adapters/controllers/{domain}/{name}.controller.ts` following the template.
### Step 2: Define the Zod input schema
Define validation rules that match what the tRPC router will pass in. Use `.safeParse()`, not `.parse()`.
### Step 3: Implement the controller function
Validate -> delegate to use case -> return result. No business logic.
### Step 4: Export from core
Add to `src/index.ts`:
```typescript
export { myController } from "./interface-adapters/controllers/{domain}/{name}.controller";
```
### Step 5: Create tRPC router procedure
In `packages/api/src/router/{domain}.router.ts`, import the controller from `@repo/core` and wire it:
```typescript
import { myController } from "@repo/core";
myProcedure: publicProcedure
.input(z.object({ /* same shape as controller schema */ }))
.mutation(async ({ input }) => {
return await myController(input);
}),
```
### Step 6: Write tests
Create `tests/unit/controllers/{domain}/{name}.controller.test.ts`:
```typescript
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
destroyContainer,
initializeContainer,
} from "@/di/container";
import { myController } from "@/interface-adapters/controllers/{domain}/{name}.controller";
import { InputParseError } from "@/entities/errors/common";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("myController", () => {
it("succeeds with valid input", async () => {
const result = await myController({
field1: "valid",
field2: 42,
});
expect(result).toBeDefined();
});
it("throws InputParseError for invalid input", async () => {
await expect(
myController({ field1: "" }) // missing required fields
).rejects.toBeInstanceOf(InputParseError);
});
it("propagates domain errors from use case", async () => {
await expect(
myController({ /* input that triggers domain error */ })
).rejects.toBeInstanceOf(SomeDomainError);
});
});
```
---
## File Structure
```
interface-adapters/
controllers/
AGENTS.md
auth/
sign-in.controller.ts
sign-up.controller.ts
sign-out.controller.ts
content/
articles.controller.ts
```
---
## Cross-References
- `application/AGENTS.md` -- Use cases that controllers delegate to
- `entities/AGENTS.md` -- Error classes and entity types used in controllers
- Root `AGENTS.md` -- How controllers fit into the full data flow
- `packages/api/AGENTS.md` -- tRPC routers that call controllers

View File

@@ -1,23 +0,0 @@
import { z } from "zod";
import { InputParseError } from "@/entities/errors/common";
import type { Cookie } from "@/entities/models/cookie";
import { signInUseCase } from "@/application/use-cases/auth/sign-in.use-case";
const inputSchema = z.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
});
export async function signInController(
input: Partial<z.infer<typeof inputSchema>>
): Promise<Cookie> {
const { data, error: inputParseError } = inputSchema.safeParse(input);
if (inputParseError) {
throw new InputParseError("Invalid data", { cause: inputParseError });
}
const { cookie } = await signInUseCase(data);
return cookie;
}

View File

@@ -1,14 +0,0 @@
import { InputParseError } from "@/entities/errors/common";
import type { Cookie } from "@/entities/models/cookie";
import { signOutUseCase } from "@/application/use-cases/auth/sign-out.use-case";
export async function signOutController(
sessionId: string | undefined
): Promise<Cookie> {
if (!sessionId) {
throw new InputParseError("Must provide a session ID");
}
const { blankCookie } = await signOutUseCase(sessionId);
return blankCookie;
}

View File

@@ -1,37 +0,0 @@
import { z } from "zod";
import { InputParseError } from "@/entities/errors/common";
import { signUpUseCase } from "@/application/use-cases/auth/sign-up.use-case";
const inputSchema = z
.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
confirmPassword: z.string().min(6).max(255),
})
.superRefine(({ password, confirmPassword }, ctx) => {
if (confirmPassword !== password) {
ctx.addIssue({
code: "custom",
message: "The passwords did not match",
path: ["password"],
});
ctx.addIssue({
code: "custom",
message: "The passwords did not match",
path: ["confirmPassword"],
});
}
});
export async function signUpController(
input: Partial<z.infer<typeof inputSchema>>
): Promise<ReturnType<typeof signUpUseCase>> {
const { data, error: inputParseError } = inputSchema.safeParse(input);
if (inputParseError) {
throw new InputParseError("Invalid data", { cause: inputParseError });
}
return await signUpUseCase(data);
}

View File

@@ -1,44 +0,0 @@
import { z } from "zod";
import { InputParseError } from "@/entities/errors/common";
import type { Article } from "@/entities/models/article";
import { createArticleUseCase } from "@/application/use-cases/content/create-article.use-case";
import { getArticlesUseCase } from "@/application/use-cases/content/get-articles.use-case";
const createInputSchema = z.object({
title: z.string().min(1).max(255),
content: z.string(),
authorId: z.string(),
slug: z.string().optional(),
});
const getInputSchema = z.object({
status: z.string().optional(),
authorId: z.string().optional(),
limit: z.number().optional(),
offset: z.number().optional(),
});
export async function createArticleController(
input: Partial<z.infer<typeof createInputSchema>>
): Promise<Article> {
const { data, error: inputParseError } = createInputSchema.safeParse(input);
if (inputParseError) {
throw new InputParseError("Invalid data", { cause: inputParseError });
}
return await createArticleUseCase(data);
}
export async function getArticlesController(
input: Partial<z.infer<typeof getInputSchema>>
): Promise<Article[]> {
const { data, error: inputParseError } = getInputSchema.safeParse(input);
if (inputParseError) {
throw new InputParseError("Invalid data", { cause: inputParseError });
}
return await getArticlesUseCase(data);
}

View File

@@ -1,41 +0,0 @@
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
destroyContainer,
initializeContainer,
} from "@/di/container";
import { signInController } from "@/interface-adapters/controllers/auth/sign-in.controller";
import { InputParseError } from "@/entities/errors/common";
import { AuthenticationError } from "@/entities/errors/auth";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("signInController", () => {
it("returns cookie for valid input", async () => {
const cookie = await signInController({
username: "alice",
password: "password_alice",
});
expect(cookie).toHaveProperty("name");
expect(cookie).toHaveProperty("value");
});
it("throws InputParseError for invalid input", async () => {
await expect(
signInController({ username: "ab", password: "short" })
).rejects.toBeInstanceOf(InputParseError);
});
it("throws AuthenticationError for wrong credentials", async () => {
await expect(
signInController({ username: "alice", password: "wrongpassword" })
).rejects.toBeInstanceOf(AuthenticationError);
});
});

View File

@@ -1,30 +0,0 @@
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
destroyContainer,
initializeContainer,
} from "@/di/container";
import { signOutController } from "@/interface-adapters/controllers/auth/sign-out.controller";
import { InputParseError } from "@/entities/errors/common";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("signOutController", () => {
it("returns blank cookie for valid session", async () => {
const cookie = await signOutController("some-session-id");
expect(cookie.value).toBe("");
});
it("throws InputParseError when no session ID provided", async () => {
await expect(signOutController(undefined)).rejects.toBeInstanceOf(
InputParseError
);
});
});

View File

@@ -1,44 +0,0 @@
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
destroyContainer,
initializeContainer,
} from "@/di/container";
import { signUpController } from "@/interface-adapters/controllers/auth/sign-up.controller";
import { InputParseError } from "@/entities/errors/common";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("signUpController", () => {
it("returns session, cookie, and user for valid input", async () => {
const result = await signUpController({
username: "newuser",
password: "securepassword",
confirmPassword: "securepassword",
});
expect(result).toHaveProperty("session");
expect(result).toHaveProperty("cookie");
expect(result).toHaveProperty("user");
});
it("throws InputParseError when passwords don't match", async () => {
await expect(
signUpController({
username: "newuser",
password: "password1",
confirmPassword: "password2",
})
).rejects.toBeInstanceOf(InputParseError);
});
it("throws InputParseError for missing fields", async () => {
await expect(signUpController({})).rejects.toBeInstanceOf(InputParseError);
});
});

View File

@@ -1,50 +0,0 @@
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
destroyContainer,
initializeContainer,
} from "@/di/container";
import {
createArticleController,
getArticlesController,
} from "@/interface-adapters/controllers/content/articles.controller";
import { InputParseError } from "@/entities/errors/common";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("createArticleController", () => {
it("creates an article with valid input", async () => {
const result = await createArticleController({
title: "Test Article",
content: "Some content",
authorId: "1",
});
expect(result.title).toBe("Test Article");
expect(result.slug).toBe("test-article");
});
it("throws InputParseError for missing title", async () => {
await expect(
createArticleController({ content: "content", authorId: "1" } as any)
).rejects.toBeInstanceOf(InputParseError);
});
});
describe("getArticlesController", () => {
it("returns articles", async () => {
await createArticleController({
title: "Article",
content: "Content",
authorId: "1",
});
const result = await getArticlesController({});
expect(result).toHaveLength(1);
});
});

View File

@@ -1,41 +0,0 @@
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
destroyContainer,
initializeContainer,
} from "@/di/container";
import { signInUseCase } from "@/application/use-cases/auth/sign-in.use-case";
import { AuthenticationError } from "@/entities/errors/auth";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("signInUseCase", () => {
it("returns session and cookie for valid credentials", async () => {
const result = await signInUseCase({
username: "alice",
password: "password_alice",
});
expect(result).toHaveProperty("session");
expect(result).toHaveProperty("cookie");
expect(result.session.userId).toBe("1");
});
it("throws AuthenticationError for non-existing user", async () => {
await expect(
signInUseCase({ username: "non-existing", password: "any" })
).rejects.toBeInstanceOf(AuthenticationError);
});
it("throws AuthenticationError for wrong password", async () => {
await expect(
signInUseCase({ username: "alice", password: "wrong" })
).rejects.toBeInstanceOf(AuthenticationError);
});
});

View File

@@ -1,24 +0,0 @@
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
destroyContainer,
initializeContainer,
} from "@/di/container";
import { signOutUseCase } from "@/application/use-cases/auth/sign-out.use-case";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("signOutUseCase", () => {
it("returns a blank cookie", async () => {
const result = await signOutUseCase("some-session-id");
expect(result).toHaveProperty("blankCookie");
expect(result.blankCookie.value).toBe("");
});
});

View File

@@ -1,36 +0,0 @@
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
destroyContainer,
initializeContainer,
} from "@/di/container";
import { signUpUseCase } from "@/application/use-cases/auth/sign-up.use-case";
import { AuthenticationError } from "@/entities/errors/auth";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("signUpUseCase", () => {
it("creates user and returns session, cookie, and user info", async () => {
const result = await signUpUseCase({
username: "newuser",
password: "securepassword",
});
expect(result).toHaveProperty("session");
expect(result).toHaveProperty("cookie");
expect(result).toHaveProperty("user");
expect(result.user.username).toBe("newuser");
});
it("throws AuthenticationError if username is taken", async () => {
await expect(
signUpUseCase({ username: "alice", password: "anypassword" })
).rejects.toBeInstanceOf(AuthenticationError);
});
});

View File

@@ -1,41 +0,0 @@
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
destroyContainer,
initializeContainer,
} from "@/di/container";
import { createArticleUseCase } from "@/application/use-cases/content/create-article.use-case";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("createArticleUseCase", () => {
it("creates an article with generated slug and draft status", async () => {
const result = await createArticleUseCase({
title: "My First Article",
content: "Hello world",
authorId: "1",
});
expect(result.title).toBe("My First Article");
expect(result.slug).toBe("my-first-article");
expect(result.status).toBe("draft");
expect(result.authorId).toBe("1");
expect(result.id).toBeDefined();
});
it("uses provided slug if given", async () => {
const result = await createArticleUseCase({
title: "Another Article",
content: "Content here",
authorId: "1",
slug: "custom-slug",
});
expect(result.slug).toBe("custom-slug");
});
});

View File

@@ -1,51 +0,0 @@
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
destroyContainer,
initializeContainer,
} from "@/di/container";
import { createArticleUseCase } from "@/application/use-cases/content/create-article.use-case";
import { getArticlesUseCase } from "@/application/use-cases/content/get-articles.use-case";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("getArticlesUseCase", () => {
it("returns empty array when no articles exist", async () => {
const result = await getArticlesUseCase();
expect(result).toEqual([]);
});
it("returns created articles", async () => {
await createArticleUseCase({
title: "Article One",
content: "Content one",
authorId: "1",
});
await createArticleUseCase({
title: "Article Two",
content: "Content two",
authorId: "1",
});
const result = await getArticlesUseCase();
expect(result).toHaveLength(2);
});
it("filters by status", async () => {
await createArticleUseCase({
title: "Draft Article",
content: "Draft",
authorId: "1",
});
const result = await getArticlesUseCase({ status: "published" });
expect(result).toHaveLength(0);
});
});

View File

@@ -1,12 +0,0 @@
{
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"types": ["reflect-metadata", "node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*.ts", "tests/**/*.ts"]
}

View File

@@ -1,17 +0,0 @@
import { defineConfig } from "vitest/config";
import { fileURLToPath, URL } from "node:url";
export default defineConfig({
test: {
globals: true,
coverage: {
provider: "v8",
reportsDirectory: "./tests/coverage",
},
},
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});

View File

@@ -1,345 +0,0 @@
# @repo/ui -- Atomic Design Component Library
## Purpose
Shared UI component library built with shadcn/ui patterns, Tailwind CSS v4, and organized by Atomic Design levels. All components have co-located Storybook stories. This package is consumed by all frontend apps (`apps/web-next`, `apps/web-tanstack`).
## Atomic Design Classification Guide
| Level | Definition | Examples | Import Rules |
|---|---|---|---|
| Atom | Single HTML element, cannot be broken down further | Button, Input, Label, Badge, Separator, Icon | Can import: `lib/`, `styles/`. NEVER import: molecules, organisms, templates |
| Molecule | 2-3 atoms composed together, single responsibility | FormField, SearchBar, Tooltip, Select, Dropdown | Can import: `atoms/`, `lib/`. NEVER import: organisms, templates |
| Organism | Complex self-contained section with multiple molecules/atoms | DataTable, Dialog, Header, Sidebar, Card, Navbar | Can import: `atoms/`, `molecules/`, `lib/`. NEVER import: templates |
| Template | Page-level layout shell, content-agnostic (uses children/slots) | DashboardLayout, AuthLayout, MarketingLayout | Can import: `atoms/`, `molecules/`, `organisms/`, `lib/` |
| Page | Template filled with real data | **LIVES IN `apps/`, NOT IN THIS PACKAGE** | N/A |
## Import Rules Table
| Level | Can import from | NEVER import from |
|---|---|---|
| Atoms | `lib/`, `hooks/`, `styles/` | `molecules/`, `organisms/`, `templates/` |
| Molecules | `atoms/`, `lib/`, `hooks/` | `organisms/`, `templates/` |
| Organisms | `atoms/`, `molecules/`, `lib/`, `hooks/` | `templates/` |
| Templates | `atoms/`, `molecules/`, `organisms/`, `lib/`, `hooks/` | (nothing above -- top level) |
## Component Rules Per Level
- **Atoms:** No margins or positioning (consumer controls layout). No internal state. No business logic. Accept `className` prop for composition. Use `forwardRef` for DOM elements.
- **Molecules:** Single responsibility. Minimal controlled state (e.g., open/closed). Compose atoms only. Accept `className` for outer container.
- **Organisms:** Can have internal state and sub-components. Can fetch context. Self-contained sections of a page.
- **Templates:** Use `children` or named slots (`sidebar`, `header`, etc.) for content injection. NEVER hard-code content or data.
## File Structure
```
packages/ui/
src/
lib/
utils.ts # cn() utility (clsx + twMerge)
styles/
globals.css # Tailwind v4 @theme tokens, @import "tailwindcss"
atoms/
button/
button.tsx # Button component (5 variants, 3 sizes)
button.stories.tsx # Storybook stories
index.ts # Barrel export
input/
input.tsx # Input component
input.stories.tsx
index.ts
label/
label.tsx # Label component
index.ts
index.ts # Barrel: re-exports all atoms
molecules/
form-field/
form-field.tsx # FormField = Label + Input + error/description
form-field.stories.tsx
index.ts
index.ts # Barrel: re-exports all molecules
organisms/
index.ts # Barrel (empty -- no organisms yet)
templates/
index.ts # Barrel (empty -- no templates yet)
index.ts # Package entry: re-exports cn + all levels
package.json
AGENTS.md
```
## Existing Components
### Button (Atom)
- **Variants:** `default`, `secondary`, `destructive`, `outline`, `ghost`
- **Sizes:** `sm` (h-9), `default` (h-10), `lg` (h-11)
- **Props:** Extends `ButtonHTMLAttributes<HTMLButtonElement>` plus `variant` and `size`
- **Uses:** `forwardRef`, `cn()` for class merging
### Input (Atom)
- **Props:** Extends `InputHTMLAttributes<HTMLInputElement>`
- **Uses:** `forwardRef`, `cn()` for class merging
- Full styling: border, focus ring, disabled state, file input support
### Label (Atom)
- **Props:** Extends `LabelHTMLAttributes<HTMLLabelElement>`
- **Uses:** `forwardRef`, `cn()` for class merging
- Handles `peer-disabled` state
### FormField (Molecule)
- **Props:** Extends `InputProps` plus `label` (string), `error?` (string), `description?` (string)
- **Composes:** Label + Input
- Auto-generates `id` from label text if not provided
## cn() Utility
The `cn()` function merges Tailwind classes safely using `clsx` + `tailwind-merge`:
```typescript
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
```
Usage in components:
```tsx
<button
className={cn(
"inline-flex items-center justify-center rounded-md", // base classes
variantStyles[variant], // variant classes
sizeStyles[size], // size classes
className // consumer override
)}
/>
```
`cn()` ensures that consumer-provided classes properly override component defaults (e.g., `cn("bg-red-500", "bg-blue-500")` yields `"bg-blue-500"`).
## Tailwind v4 CSS-First Configuration
This project uses **Tailwind CSS v4**, which replaces `tailwind.config.ts` with CSS-first configuration. There is no `tailwind.config.ts` file. All design tokens are defined in `src/styles/globals.css` using the `@theme` directive:
```css
@import "tailwindcss";
@theme {
--color-background: hsl(0 0% 100%);
--color-foreground: hsl(240 10% 3.9%);
--color-primary: hsl(240 5.9% 10%);
--color-primary-foreground: hsl(0 0% 98%);
--color-secondary: hsl(240 4.8% 95.9%);
--color-destructive: hsl(0 84.2% 60.2%);
--color-border: hsl(240 5.9% 90%);
--color-input: hsl(240 5.9% 90%);
--color-ring: hsl(240 5.9% 10%);
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
}
```
To add new tokens, add them inside the `@theme { }` block in `globals.css`. Classes like `bg-primary`, `text-destructive`, `rounded-lg` reference these tokens automatically.
## shadcn/ui Workflow
When adding a new shadcn/ui component:
1. **Install:** `pnpm dlx shadcn@latest add [component]` -- it lands in `atoms/` by default
2. **Classify:** Check the Atomic Design classification table above
3. **Relocate:** If the component is not an atom, move it to the correct level directory
4. **Story:** Create a `.stories.tsx` file next to the component
5. **Export:** Add to the level's `index.ts` barrel file
## Storybook MCP Integration
Before creating any new UI component, query the Storybook MCP to check for existing components:
- **`list-all-documentation`** -- discover all existing components and their stories
- **`get-documentation`** -- understand existing component props, variants, and usage
- **`run-story-tests`** -- validate your new story renders correctly after creation
Storybook MCP is available at `http://localhost:6006/mcp` when Storybook is running.
## Complete Story File Template
```tsx
import type { Meta, StoryObj } from "@storybook/react";
import { MyComponent } from "./my-component";
const meta = {
title: "{Level}/{ComponentName}", // e.g., "Atoms/Button", "Molecules/FormField"
component: MyComponent,
tags: ["autodocs"],
argTypes: {
// Define controls for interactive props
variant: {
control: "select",
options: ["default", "secondary"],
},
size: {
control: "select",
options: ["sm", "default", "lg"],
},
disabled: { control: "boolean" },
},
} satisfies Meta<typeof MyComponent>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
children: "Default",
},
};
export const AnotherVariant: Story = {
args: {
children: "Another Variant",
variant: "secondary",
},
};
```
## Recipe: Adding a New Component (8 Steps)
This example adds a `Badge` atom component.
### Step 1: Check Storybook MCP for existing components
Query `list-all-documentation` to confirm no Badge component exists.
### Step 2: Classify the component
Badge is a single HTML element displaying a short label -- it is an **Atom**.
### Step 3: Create the component directory
```
src/atoms/badge/
badge.tsx
badge.stories.tsx
index.ts
```
### Step 4: Write the component
`src/atoms/badge/badge.tsx`:
```tsx
import { type HTMLAttributes } from "react";
import { cn } from "../../lib/utils";
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: "default" | "secondary" | "destructive" | "outline";
}
const variantStyles: Record<NonNullable<BadgeProps["variant"]>, string> = {
default: "bg-primary text-primary-foreground",
secondary: "bg-secondary text-secondary-foreground",
destructive: "bg-destructive text-destructive-foreground",
outline: "border border-input bg-background text-foreground",
};
export function Badge({
className,
variant = "default",
...props
}: BadgeProps) {
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors",
variantStyles[variant],
className
)}
{...props}
/>
);
}
```
### Step 5: Create the barrel export
`src/atoms/badge/index.ts`:
```typescript
export { Badge, type BadgeProps } from "./badge";
```
### Step 6: Write the story
`src/atoms/badge/badge.stories.tsx`:
```tsx
import type { Meta, StoryObj } from "@storybook/react";
import { Badge } from "./badge";
const meta = {
title: "Atoms/Badge",
component: Badge,
tags: ["autodocs"],
argTypes: {
variant: {
control: "select",
options: ["default", "secondary", "destructive", "outline"],
},
},
} satisfies Meta<typeof Badge>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { children: "Badge" },
};
export const Secondary: Story = {
args: { children: "Secondary", variant: "secondary" },
};
export const Destructive: Story = {
args: { children: "Error", variant: "destructive" },
};
export const Outline: Story = {
args: { children: "Outline", variant: "outline" },
};
```
### Step 7: Add to atoms barrel
Edit `src/atoms/index.ts`:
```typescript
export { Button, type ButtonProps } from "./button/index";
export { Input, type InputProps } from "./input/index";
export { Label, type LabelProps } from "./label/index";
export { Badge, type BadgeProps } from "./badge/index"; // <-- add
```
### Step 8: Validate
Run `run-story-tests` via Storybook MCP to confirm the stories render correctly.
## Dependencies
| Dependency | Purpose |
|---|---|
| `react` | JSX runtime |
| `clsx` | Conditional class string builder |
| `tailwind-merge` | Intelligent Tailwind class merging (deduplication) |
| `tailwindcss` (devDep) | Tailwind CSS v4 engine |
## Cross-References
- **Storybook app:** `apps/storybook/` -- see `apps/storybook/AGENTS.md`
- **Consumed by Next.js:** `apps/web-next/` -- see `apps/web-next/AGENTS.md`
- **Consumed by TanStack:** `apps/web-tanstack/` -- see `apps/web-tanstack/AGENTS.md`

View File

@@ -1,26 +0,0 @@
{
"name": "@repo/ui",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "echo 'typechecked by consuming app bundler'",
"lint": "eslint .",
"test": "vitest run --passWithNoTests",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"clsx": "^2.1.0",
"react": "^19.0.0",
"tailwind-merge": "^3.0.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/react": "^19.0.0",
"tailwindcss": "^4.1.0",
"vitest": "^3.1.0"
}
}

View File

@@ -1,38 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./button";
const meta = {
title: "Atoms/Button",
component: Button,
tags: ["autodocs"],
argTypes: {
variant: {
control: "select",
options: ["default", "secondary", "destructive", "outline", "ghost"],
},
size: { control: "select", options: ["sm", "default", "lg"] },
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { children: "Button", variant: "default" },
};
export const Secondary: Story = {
args: { children: "Secondary", variant: "secondary" },
};
export const Destructive: Story = {
args: { children: "Destructive", variant: "destructive" },
};
export const Outline: Story = {
args: { children: "Outline", variant: "outline" },
};
export const Ghost: Story = {
args: { children: "Ghost", variant: "ghost" },
};

View File

@@ -1,41 +0,0 @@
import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cn } from "../../lib/utils";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "secondary" | "destructive" | "outline" | "ghost";
size?: "sm" | "default" | "lg";
}
const variantStyles: Record<NonNullable<ButtonProps["variant"]>, string> = {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
};
const sizeStyles: Record<NonNullable<ButtonProps["size"]>, string> = {
sm: "h-9 px-3 text-sm",
default: "h-10 px-4 py-2",
lg: "h-11 px-8 text-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "default", ...props }, ref) => {
return (
<button
className={cn(
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
variantStyles[variant],
sizeStyles[size],
className
)}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";

View File

@@ -1 +0,0 @@
export { Button, type ButtonProps } from "./button";

View File

@@ -1,3 +0,0 @@
export { Button, type ButtonProps } from "./button/index";
export { Input, type InputProps } from "./input/index";
export { Label, type LabelProps } from "./label/index";

View File

@@ -1 +0,0 @@
export { Input, type InputProps } from "./input";

View File

@@ -1,19 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Input } from "./input";
const meta = {
title: "Atoms/Input",
component: Input,
tags: ["autodocs"],
} satisfies Meta<typeof Input>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { placeholder: "Enter text..." },
};
export const Disabled: Story = {
args: { placeholder: "Disabled", disabled: true },
};

Some files were not shown because too many files have changed in this diff Show More