docs: extend all 18 AGENTS.md files with comprehensive context, code examples, and recipes
This commit is contained in:
594
AGENTS.md
594
AGENTS.md
@@ -1,71 +1,555 @@
|
||||
# Agent Instructions
|
||||
# AGENTS.md -- Root Monorepo
|
||||
|
||||
This is a Turborepo + pnpm monorepo implementing Clean Architecture (Uncle Bob / Lazar Nikolov). It supports Next.js 15 and TanStack Start as frontend frameworks, Payload CMS v3 for content management, and tRPC v11 for type-safe API communication.
|
||||
|
||||
---
|
||||
|
||||
## Monorepo Package Map
|
||||
|
||||
| Package | Purpose |
|
||||
|---|---|
|
||||
| `@repo/core` | Clean architecture: entities, use cases, interfaces, DI |
|
||||
| `@repo/api` | tRPC router definitions (calls core controllers) |
|
||||
| `@repo/api-client` | Shared React Query hooks + ApiProvider |
|
||||
| `@repo/cms-core` | Payload CMS config, collections, hooks, globals |
|
||||
| `@repo/cms-client` | Dual-mode Payload client (local + HTTP) |
|
||||
| `@repo/ui` | shadcn/ui + Atomic Design component library |
|
||||
| `@repo/eslint-config` | Shared ESLint flat configs |
|
||||
| `@repo/typescript-config` | Shared TypeScript configs |
|
||||
| Package | Path | Purpose | Depends On |
|
||||
|---|---|---|---|
|
||||
| `@repo/core` | `packages/core` | Clean Architecture business logic: entities, use cases, repository/service interfaces, controllers, InversifyJS DI container | `zod`, `inversify`, `reflect-metadata` |
|
||||
| `@repo/api` | `packages/api` | tRPC v11 routers that call `@repo/core` controllers | `@repo/core`, `@trpc/server`, `zod` |
|
||||
| `@repo/api-client` | `packages/api-client` | Shared React Query hooks, `ApiProvider`, and `useTRPC` for frontend apps | `@repo/api`, `@trpc/client`, `@trpc/tanstack-react-query`, `@tanstack/react-query` |
|
||||
| `@repo/cms-core` | `packages/cms-core` | Payload CMS config, collections (Users, Articles, Media), globals (SiteSettings), hooks | `payload`, `@payloadcms/db-postgres`, `@payloadcms/richtext-lexical` |
|
||||
| `@repo/cms-client` | `packages/cms-client` | Dual-mode Payload client (local via Payload instance, HTTP via REST API). STANDALONE -- no monorepo deps | `payload` (types only) |
|
||||
| `@repo/ui` | `packages/ui` | Atomic Design component library (atoms/molecules/organisms/templates) built with shadcn/ui patterns + Tailwind v4 | `clsx`, `tailwind-merge`, `react` |
|
||||
| `@repo/eslint-config` | `packages/eslint-config` | Shared ESLint 9 flat configs for the entire monorepo | (tooling) |
|
||||
| `@repo/typescript-config` | `packages/typescript-config` | Shared `tsconfig` base configs (`base.json`) with `experimentalDecorators` + `emitDecoratorMetadata` | (tooling) |
|
||||
| `@repo/web-next` | `apps/web-next` | Next.js 15 App Router frontend (port 3000) | `@repo/api`, `@repo/api-client`, `@repo/ui` |
|
||||
| `@repo/web-tanstack` | `apps/web-tanstack` | TanStack Start frontend (port 3002) | `@repo/api`, `@repo/api-client`, `@repo/ui` |
|
||||
| `@repo/cms` | `apps/cms` | Thin Next.js shell hosting the Payload Admin UI (port 3001) | `@repo/cms-core`, `@payloadcms/next`, `@payloadcms/ui` |
|
||||
| `@repo/storybook` | `apps/storybook` | Storybook 8 for `@repo/ui` components (port 6006) | `@repo/ui` |
|
||||
|
||||
| App | Purpose |
|
||||
|---|---|
|
||||
| `apps/web-next` | Next.js 15 reference app |
|
||||
| `apps/web-tanstack` | TanStack Start reference app |
|
||||
| `apps/cms` | Thin Next.js shell for Payload admin |
|
||||
| `apps/storybook` | Centralized Storybook instance |
|
||||
---
|
||||
|
||||
## Dependency Flow (one direction only)
|
||||
## Dependency Flow Diagram
|
||||
|
||||
```
|
||||
apps/web-next, apps/web-tanstack → @repo/api-client → @repo/api → @repo/core
|
||||
apps/cms → @repo/cms-core → @repo/core/application (hooks only)
|
||||
@repo/core/infrastructure → @repo/cms-client (standalone)
|
||||
@repo/ui (standalone)
|
||||
+-----------------+ +-----------------+
|
||||
| apps/web-next | | apps/web-tanstack|
|
||||
+--------+--------+ +--------+--------+
|
||||
| |
|
||||
+---------+-----------+-----------+
|
||||
| |
|
||||
+-----v------+ +------v-------+
|
||||
| @repo/api- | | @repo/ui |
|
||||
| client | | (Atomic |
|
||||
+-----+------+ | Design) |
|
||||
| +--------------+
|
||||
+-----v------+
|
||||
| @repo/api | +----------+ +------------+
|
||||
| (tRPC v11) | | apps/cms | | apps/ |
|
||||
+-----+------+ +----+-----+ | storybook |
|
||||
| | +------+-----+
|
||||
+-----v------+ +------v-------+ |
|
||||
| @repo/core | | @repo/ | +-----v------+
|
||||
| (Clean | | cms-core | | @repo/ui |
|
||||
| Arch) | | (Payload | +-----------+
|
||||
+-----+------+ | collections) |
|
||||
| +--------------+
|
||||
|
|
||||
+-----v----------+
|
||||
| @repo/ |
|
||||
| cms-client |
|
||||
| (optional, |
|
||||
| standalone) |
|
||||
+----------------+
|
||||
|
||||
@repo/eslint-config ---------> used by all packages (devDependency)
|
||||
@repo/typescript-config -----> used by all packages (devDependency)
|
||||
```
|
||||
|
||||
## HARD RULES — NEVER VIOLATE
|
||||
Key rule: arrows point DOWN. A package may only depend on packages below it in this diagram. Apps sit at the top; `@repo/core` and `@repo/cms-client` sit at the bottom.
|
||||
|
||||
- **NEVER:** packages/core → apps/*
|
||||
- **NEVER:** apps/cms → packages/core/infrastructure
|
||||
- **NEVER:** packages/cms-client → apps/cms or packages/core or packages/cms-core
|
||||
- **NEVER:** packages/cms-core → packages/cms-client
|
||||
- **NEVER:** core/entities → anything
|
||||
- **NEVER:** core/application → core/infrastructure
|
||||
---
|
||||
|
||||
## How to Add a New Feature (end-to-end)
|
||||
## Complete Data Flow
|
||||
|
||||
1. Define entity in `packages/core/src/entities/models/`
|
||||
2. Define repository interface in `packages/core/src/application/repositories/`
|
||||
3. Write use case in `packages/core/src/application/use-cases/{domain}/`
|
||||
4. Write controller in `packages/core/src/interface-adapters/controllers/{domain}/`
|
||||
5. Write infrastructure implementation in `packages/core/src/infrastructure/`
|
||||
6. Register in DI container (`packages/core/src/di/`)
|
||||
7. Add tRPC router in `packages/api/src/router/`
|
||||
8. Add React Query hook in `packages/api-client/src/hooks/`
|
||||
9. If CMS collection needed: add to `packages/cms-core/src/collections/`
|
||||
10. Build UI component in `packages/ui/src/` (classify atomic level)
|
||||
11. Write Storybook story (co-located `.stories.tsx`)
|
||||
12. Write tests (unit in packages/core/tests/, E2E in tests/e2e/)
|
||||
Every user interaction follows this path:
|
||||
|
||||
## How to Add a New UI Component
|
||||
```
|
||||
UI Component (React)
|
||||
|
|
||||
v
|
||||
useTRPC().content.listArticles.useQuery() <-- @repo/api-client hook
|
||||
|
|
||||
v
|
||||
tRPC Router Procedure (.query / .mutation) <-- @repo/api router
|
||||
|
|
||||
v
|
||||
Controller (Zod safeParse -> InputParseError) <-- @repo/core interface-adapters
|
||||
|
|
||||
v
|
||||
Use Case (business logic + getInjection()) <-- @repo/core application
|
||||
|
|
||||
v
|
||||
Repository / Service Interface <-- @repo/core application (abstract)
|
||||
|
|
||||
v
|
||||
Implementation (@injectable class) <-- @repo/core infrastructure (concrete)
|
||||
|
|
||||
v
|
||||
Data Store (Payload CMS / in-memory mock)
|
||||
```
|
||||
|
||||
1. Classify: atom (single element), molecule (2-3 atoms), organism (complex section), template (layout)
|
||||
2. Create folder: `packages/ui/src/{level}/{component-name}/`
|
||||
3. Create: `{name}.tsx`, `{name}.stories.tsx`, `index.ts`
|
||||
4. Story title: `"{Level}/{ComponentName}"` (e.g., `"Atoms/Button"`)
|
||||
5. Export from level's `index.ts` barrel file
|
||||
6. Import rules: atoms ← nothing | molecules ← atoms | organisms ← atoms+molecules | templates ← all
|
||||
Example -- listing articles end-to-end:
|
||||
|
||||
## How to Add a Payload CMS Collection
|
||||
```typescript
|
||||
// 1. UI: apps/web-next -- a React Server Component or client component
|
||||
const trpc = useTRPC();
|
||||
const articles = trpc.content.listArticles.useQuery({ status: "published" });
|
||||
|
||||
1. Create folder: `packages/cms-core/src/collections/{name}/`
|
||||
2. Create: `index.ts` (CollectionConfig), `fields.ts`, optionally `hooks/`, `access/`
|
||||
3. Import in `packages/cms-core/src/payload.config.ts` collections array
|
||||
4. Export from `packages/cms-core/src/index.ts`
|
||||
5. Hooks that contain business logic must delegate to use cases in `@repo/core/application`
|
||||
// 2. tRPC router: packages/api/src/router/content.router.ts
|
||||
contentRouter = router({
|
||||
listArticles: publicProcedure
|
||||
.input(z.object({ status: z.string().optional(), /* ... */ }).optional())
|
||||
.query(async ({ input }) => {
|
||||
return await getArticlesController(input ?? {});
|
||||
}),
|
||||
});
|
||||
|
||||
// 3. Controller: packages/core/src/interface-adapters/controllers/content/articles.controller.ts
|
||||
export async function getArticlesController(input) {
|
||||
const { data, error } = getInputSchema.safeParse(input);
|
||||
if (error) throw new InputParseError("Invalid data", { cause: error });
|
||||
return await getArticlesUseCase(data);
|
||||
}
|
||||
|
||||
// 4. Use Case: packages/core/src/application/use-cases/content/get-articles.use-case.ts
|
||||
export async function getArticlesUseCase(options) {
|
||||
const articlesRepository = getInjection("IArticlesRepository");
|
||||
return await articlesRepository.getArticles(options);
|
||||
}
|
||||
|
||||
// 5. Repository: resolved at runtime via InversifyJS DI container
|
||||
// Mock: packages/core/src/infrastructure/repositories/mock-articles.repository.ts
|
||||
// Production: a PayloadArticlesRepository using @repo/cms-client (future)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hard Rules
|
||||
|
||||
| # | Rule | Reason |
|
||||
|---|---|---|
|
||||
| 1 | `@repo/core` NEVER imports from apps/* or framework packages (Next.js, TanStack) | Core business logic must be framework-agnostic. It must be portable across any UI framework or transport layer. |
|
||||
| 2 | `@repo/cms-core` NEVER imports from `@repo/core` or `@repo/infrastructure` | CMS config is Payload-native. The bridge between Payload and Clean Architecture is `@repo/cms-client`, consumed in `@repo/core`'s infrastructure layer. |
|
||||
| 3 | `@repo/cms-client` NEVER imports from any other `@repo/*` package | `cms-client` is standalone. It defines a `PayloadClient` interface with `local` and `http` modes. It has zero monorepo dependencies so it can be used anywhere. |
|
||||
| 4 | `@repo/core`'s `entities/` layer NEVER imports from `application/`, `infrastructure/`, `interface-adapters/`, or `di/` | Entities are the innermost layer of Clean Architecture. They define pure domain types and errors with zero dependencies. |
|
||||
| 5 | `@repo/core`'s `application/` layer NEVER imports from `infrastructure/` | Use cases and interfaces depend on abstractions (interfaces), never on concrete implementations. The DI container resolves implementations at runtime. |
|
||||
| 6 | `@repo/core`'s `interface-adapters/` layer NEVER imports from `infrastructure/` | Controllers validate input and delegate to use cases. They must not know about concrete data access or external services. |
|
||||
|
||||
---
|
||||
|
||||
## How to Add a New Feature (End-to-End Recipe)
|
||||
|
||||
This recipe walks through adding a "comments" feature. Follow every step in order.
|
||||
|
||||
### Step 1: Define the Entity (packages/core/src/entities/models/comment.ts)
|
||||
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
|
||||
export const commentSchema = z.object({
|
||||
id: z.string(),
|
||||
articleId: z.string(),
|
||||
authorId: z.string(),
|
||||
body: z.string().min(1).max(2000),
|
||||
createdAt: z.date(),
|
||||
});
|
||||
|
||||
export type Comment = z.infer<typeof commentSchema>;
|
||||
```
|
||||
|
||||
Export it from `packages/core/src/entities/models/index.ts`:
|
||||
|
||||
```typescript
|
||||
export { commentSchema, type Comment } from "./comment";
|
||||
```
|
||||
|
||||
The barrel export chain is: `models/index.ts` -> `entities/index.ts` -> `core/src/index.ts`. Only `models/index.ts` needs updating; the other two already re-export with `*`.
|
||||
|
||||
### Step 2: Define the Repository Interface (packages/core/src/application/repositories/comments.repository.interface.ts)
|
||||
|
||||
```typescript
|
||||
import type { Comment } from "@/entities/models/comment";
|
||||
|
||||
export interface ICommentsRepository {
|
||||
getComment(id: string): Promise<Comment | undefined>;
|
||||
getComments(options?: {
|
||||
articleId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Comment[]>;
|
||||
createComment(input: Comment): Promise<Comment>;
|
||||
}
|
||||
```
|
||||
|
||||
Export from `packages/core/src/application/repositories/index.ts`:
|
||||
|
||||
```typescript
|
||||
export type { ICommentsRepository } from "./comments.repository.interface";
|
||||
```
|
||||
|
||||
### Step 3: Create Mock Implementation (packages/core/src/infrastructure/repositories/mock-comments.repository.ts)
|
||||
|
||||
```typescript
|
||||
import { injectable } from "inversify";
|
||||
|
||||
import type { ICommentsRepository } from "@/application/repositories/comments.repository.interface";
|
||||
import type { Comment } from "@/entities/models/comment";
|
||||
|
||||
@injectable()
|
||||
export class MockCommentsRepository implements ICommentsRepository {
|
||||
private _comments: Comment[] = [];
|
||||
|
||||
async getComment(id: string): Promise<Comment | undefined> {
|
||||
return this._comments.find((c) => c.id === id);
|
||||
}
|
||||
|
||||
async getComments(options?: {
|
||||
articleId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Comment[]> {
|
||||
let result = [...this._comments];
|
||||
if (options?.articleId) {
|
||||
result = result.filter((c) => c.articleId === options.articleId);
|
||||
}
|
||||
const offset = options?.offset ?? 0;
|
||||
const limit = options?.limit ?? 50;
|
||||
return result.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
async createComment(input: Comment): Promise<Comment> {
|
||||
this._comments.push(input);
|
||||
return input;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Critical: the `@injectable()` decorator is required for InversifyJS. Without it, the container cannot resolve this class.
|
||||
|
||||
### Step 4: Register in DI (packages/core/src/di/)
|
||||
|
||||
**4a. Add symbol to `types.ts`:**
|
||||
|
||||
```typescript
|
||||
import type { ICommentsRepository } from "@/application/repositories/comments.repository.interface";
|
||||
|
||||
// Add to DI_SYMBOLS:
|
||||
export const DI_SYMBOLS = {
|
||||
// ...existing...
|
||||
ICommentsRepository: Symbol.for("ICommentsRepository"),
|
||||
};
|
||||
|
||||
// Add to DI_RETURN_TYPES:
|
||||
export interface DI_RETURN_TYPES {
|
||||
// ...existing...
|
||||
ICommentsRepository: ICommentsRepository;
|
||||
}
|
||||
```
|
||||
|
||||
**4b. Create module `modules/comments.module.ts`:**
|
||||
|
||||
```typescript
|
||||
import { ContainerModule, interfaces } from "inversify";
|
||||
|
||||
import type { ICommentsRepository } from "@/application/repositories/comments.repository.interface";
|
||||
import { MockCommentsRepository } from "@/infrastructure/repositories/mock-comments.repository";
|
||||
import { DI_SYMBOLS } from "../types";
|
||||
|
||||
const initializeModule = (bind: interfaces.Bind) => {
|
||||
bind<ICommentsRepository>(DI_SYMBOLS.ICommentsRepository).to(
|
||||
MockCommentsRepository
|
||||
);
|
||||
};
|
||||
|
||||
export const CommentsModule = new ContainerModule(initializeModule);
|
||||
```
|
||||
|
||||
**4c. Load module in `container.ts`:**
|
||||
|
||||
```typescript
|
||||
import { CommentsModule } from "./modules/comments.module";
|
||||
|
||||
export const initializeContainer = () => {
|
||||
ApplicationContainer.load(AuthModule);
|
||||
ApplicationContainer.load(ContentModule);
|
||||
ApplicationContainer.load(CommentsModule); // <-- add
|
||||
};
|
||||
|
||||
export const destroyContainer = () => {
|
||||
ApplicationContainer.unload(AuthModule);
|
||||
ApplicationContainer.unload(ContentModule);
|
||||
ApplicationContainer.unload(CommentsModule); // <-- add
|
||||
};
|
||||
```
|
||||
|
||||
### Step 5: Create Use Case (packages/core/src/application/use-cases/content/create-comment.use-case.ts)
|
||||
|
||||
```typescript
|
||||
import type { Comment } from "@/entities/models/comment";
|
||||
import { getInjection } from "@/di/container";
|
||||
|
||||
export async function createCommentUseCase(input: {
|
||||
articleId: string;
|
||||
authorId: string;
|
||||
body: string;
|
||||
}): Promise<Comment> {
|
||||
const commentsRepository = getInjection("ICommentsRepository");
|
||||
|
||||
const now = new Date();
|
||||
const comment: Comment = {
|
||||
id: crypto.randomUUID(),
|
||||
articleId: input.articleId,
|
||||
authorId: input.authorId,
|
||||
body: input.body,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
return await commentsRepository.createComment(comment);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Create Controller (packages/core/src/interface-adapters/controllers/content/comments.controller.ts)
|
||||
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
import type { Comment } from "@/entities/models/comment";
|
||||
import { createCommentUseCase } from "@/application/use-cases/content/create-comment.use-case";
|
||||
|
||||
const createInputSchema = z.object({
|
||||
articleId: z.string(),
|
||||
authorId: z.string(),
|
||||
body: z.string().min(1).max(2000),
|
||||
});
|
||||
|
||||
export async function createCommentController(
|
||||
input: Partial<z.infer<typeof createInputSchema>>
|
||||
): Promise<Comment> {
|
||||
const { data, error: inputParseError } = createInputSchema.safeParse(input);
|
||||
|
||||
if (inputParseError) {
|
||||
throw new InputParseError("Invalid data", { cause: inputParseError });
|
||||
}
|
||||
|
||||
return await createCommentUseCase(data);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Export from core (packages/core/src/index.ts)
|
||||
|
||||
```typescript
|
||||
export { createCommentController } from "./interface-adapters/controllers/content/comments.controller";
|
||||
export { createCommentUseCase } from "./application/use-cases/content/create-comment.use-case";
|
||||
```
|
||||
|
||||
### Step 8: Create tRPC Router Procedure (packages/api/src/router/content.router.ts)
|
||||
|
||||
Add to the existing content router:
|
||||
|
||||
```typescript
|
||||
import { createCommentController } from "@repo/core";
|
||||
|
||||
// Inside contentRouter:
|
||||
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 9: Use from UI (apps/web-next)
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { useTRPC } from "@repo/api-client";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
export function AddCommentForm({ articleId }: { articleId: string }) {
|
||||
const trpc = useTRPC();
|
||||
const mutation = trpc.content.createComment.useMutation();
|
||||
|
||||
const handleSubmit = (body: string) => {
|
||||
mutation.mutate({ articleId, authorId: "current-user-id", body });
|
||||
};
|
||||
|
||||
// ... render form using @repo/ui components
|
||||
}
|
||||
```
|
||||
|
||||
### Step 10: Add Payload Collection (optional -- packages/cms-core)
|
||||
|
||||
If the data is CMS-managed, create a collection. See "How to Add a Payload Collection" below.
|
||||
|
||||
---
|
||||
|
||||
## How to Add a UI Component
|
||||
|
||||
### Classification Guide
|
||||
|
||||
Components live in `packages/ui/src/` organized by Atomic Design:
|
||||
|
||||
| Level | Directory | Description | Examples |
|
||||
|---|---|---|---|
|
||||
| **Atoms** | `src/atoms/{name}/` | Smallest building blocks. Single HTML element wrappers. No composition of other atoms. | `Button`, `Input`, `Label` |
|
||||
| **Molecules** | `src/molecules/{name}/` | Combine 2+ atoms into a reusable unit. | `FormField` (Label + Input + error text) |
|
||||
| **Organisms** | `src/organisms/{name}/` | Complex UI sections combining molecules/atoms. May contain local state. | `LoginForm`, `ArticleCard` |
|
||||
| **Templates** | `src/templates/{name}/` | Page-level layout structures with slots for organisms/molecules. No data fetching. | `DashboardLayout`, `AuthLayout` |
|
||||
|
||||
### File Structure for Each Component
|
||||
|
||||
```
|
||||
src/atoms/my-component/
|
||||
my-component.tsx # Component implementation
|
||||
my-component.stories.tsx # Storybook story
|
||||
index.ts # Barrel export
|
||||
```
|
||||
|
||||
### Import Rules
|
||||
|
||||
| From | Can Import | NEVER Import |
|
||||
|---|---|---|
|
||||
| Atoms | `lib/utils` only | Other atoms, molecules, organisms, templates |
|
||||
| Molecules | Atoms, `lib/utils` | Other molecules, organisms, templates |
|
||||
| Organisms | Atoms, Molecules, `lib/utils` | Other organisms, templates |
|
||||
| Templates | Atoms, Molecules, Organisms, `lib/utils` | Other templates |
|
||||
| Apps | Any `@repo/ui` export | Internal `@repo/ui` paths (always use package export) |
|
||||
|
||||
### Export Chain
|
||||
|
||||
1. Export from `src/atoms/{name}/index.ts`
|
||||
2. Re-export from `src/atoms/index.ts`
|
||||
3. Everything flows through `src/index.ts` which re-exports all levels
|
||||
|
||||
### Component Pattern
|
||||
|
||||
Use `cn()` from `lib/utils` for className merging. Use `forwardRef` for atoms wrapping native elements:
|
||||
|
||||
```typescript
|
||||
import { forwardRef, type ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export const MyButton = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<button className={cn("base-classes", className)} ref={ref} {...props} />
|
||||
)
|
||||
);
|
||||
MyButton.displayName = "MyButton";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to Add a Payload Collection
|
||||
|
||||
### Step 1: Create the collection (packages/cms-core/src/collections/{name}/index.ts)
|
||||
|
||||
```typescript
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { myFields } from "./fields";
|
||||
|
||||
export const MyCollection: CollectionConfig = {
|
||||
slug: "my-collection",
|
||||
admin: {
|
||||
useAsTitle: "title",
|
||||
},
|
||||
fields: myFields,
|
||||
};
|
||||
```
|
||||
|
||||
### Step 2: Define fields (packages/cms-core/src/collections/{name}/fields.ts)
|
||||
|
||||
```typescript
|
||||
import type { Field } from "payload";
|
||||
|
||||
export const myFields: Field[] = [
|
||||
{ name: "title", type: "text", required: true },
|
||||
{ name: "content", type: "richText" },
|
||||
];
|
||||
```
|
||||
|
||||
### Step 3: Register in payload.config.ts
|
||||
|
||||
```typescript
|
||||
import { MyCollection } from "./collections/my-collection";
|
||||
|
||||
export default buildConfig({
|
||||
collections: [Users, Articles, Media, MyCollection], // add here
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### Step 4: Export from index.ts
|
||||
|
||||
```typescript
|
||||
export { MyCollection } from "./collections/my-collection";
|
||||
```
|
||||
|
||||
### Hook Rules
|
||||
|
||||
- Hooks live in `collections/{name}/hooks/` with descriptive names like `before-change.ts`
|
||||
- Hook types: `beforeChange`, `afterChange`, `beforeRead`, `afterRead`, `beforeDelete`, `afterDelete`, `beforeValidate`, `afterValidate`
|
||||
- Hooks receive `({ data, operation, req })` and must return `data` (for before hooks)
|
||||
- Hooks MUST NOT import from `@repo/core`. The CMS is independent. If you need to sync with core business logic, use the `@repo/cms-client` bridge in the infrastructure layer, not hooks calling core directly.
|
||||
- Example hook:
|
||||
|
||||
```typescript
|
||||
import type { CollectionBeforeChangeHook } from "payload";
|
||||
|
||||
export const myBeforeChangeHook: CollectionBeforeChangeHook = ({ data, operation }) => {
|
||||
if (operation === "create" && data && !data.slug) {
|
||||
data.slug = data.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
return data;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Commands
|
||||
|
||||
```bash
|
||||
pnpm install # Install all dependencies
|
||||
pnpm dev # Start all dev servers (Next.js :3000, CMS :3001, Storybook :6006)
|
||||
pnpm build # Build all packages (via Turborepo)
|
||||
pnpm test # Run all tests (via Turborepo)
|
||||
pnpm typecheck # Type-check all packages
|
||||
pnpm lint # Lint all packages
|
||||
pnpm format # Format all files with Prettier
|
||||
pnpm format:check # Check formatting without writing
|
||||
docker compose up -d # Start PostgreSQL (required for CMS)
|
||||
|
||||
# Filtered commands
|
||||
pnpm dev --filter @repo/web-next # Only start Next.js app
|
||||
pnpm test --filter @repo/core # Only test core package
|
||||
pnpm dev --filter @repo/storybook # Only start Storybook
|
||||
|
||||
# Core package direct commands
|
||||
cd packages/core && pnpm vitest run # Run core unit tests
|
||||
cd packages/core && pnpm vitest --ui # Run tests with UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-References
|
||||
|
||||
Each package and key directory has its own `AGENTS.md` with domain-specific rules and recipes:
|
||||
|
||||
- `packages/core/AGENTS.md` -- Clean Architecture layers, import rules, DI resolution
|
||||
- `packages/core/src/entities/AGENTS.md` -- Entity models and errors
|
||||
- `packages/core/src/application/AGENTS.md` -- Use cases and interfaces
|
||||
- `packages/core/src/infrastructure/AGENTS.md` -- Concrete implementations
|
||||
- `packages/core/src/interface-adapters/controllers/AGENTS.md` -- Controllers
|
||||
- `packages/core/src/di/AGENTS.md` -- InversifyJS container configuration
|
||||
- `packages/core/src/application/use-cases/auth/AGENTS.md` -- Auth domain rules
|
||||
- `packages/core/src/application/use-cases/content/AGENTS.md` -- Content domain rules
|
||||
|
||||
@@ -1,18 +1,108 @@
|
||||
# apps/cms — Payload CMS Admin Shell
|
||||
# apps/cms -- Payload CMS Admin Shell
|
||||
|
||||
Thin Next.js shell that imports config from `@repo/cms-core` and serves the Payload admin panel. Contains almost no custom code.
|
||||
## Purpose
|
||||
|
||||
## Rules
|
||||
**THIN SHELL ONLY** -- this app exists solely to serve the Payload CMS admin panel via Next.js. All CMS logic (collections, globals, hooks, access control, `payload.config.ts`) lives in `@repo/cms-core`. This app contains no custom CMS code beyond Next.js routing boilerplate that Payload generates automatically.
|
||||
|
||||
- All collections, hooks, globals, and payload.config.ts live in `@repo/cms-core`
|
||||
- This app only contains Next.js routing boilerplate for the admin panel
|
||||
- Import `@payload-config` which resolves to `@repo/cms-core/src/payload.config.ts`
|
||||
- NEVER import from `@repo/core/infrastructure`
|
||||
|
||||
## Development
|
||||
## Port: 3001
|
||||
|
||||
```bash
|
||||
pnpm dev --filter @repo/cms # Starts on port 3001
|
||||
pnpm dev --filter @repo/cms # http://localhost:3001/admin
|
||||
```
|
||||
|
||||
Requires PostgreSQL running (via `docker compose up postgres`).
|
||||
Requires PostgreSQL running first:
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres # Starts PostgreSQL on port 5432
|
||||
```
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- **NEVER** add collections, globals, hooks, or access control in this app -- put them in `@repo/cms-core`
|
||||
- **NEVER** import from `@repo/core/infrastructure`
|
||||
- **NEVER** modify auto-generated files (marked with "THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD")
|
||||
- All CMS configuration changes go in `packages/cms-core/`
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `next.config.mjs` | Next.js config wrapped with `withPayload()` from `@payloadcms/next` |
|
||||
| `tsconfig.json` | TypeScript config with `@payload-config` path alias |
|
||||
| `src/app/(payload)/layout.tsx` | Auto-generated Payload root layout (DO NOT MODIFY) |
|
||||
| `src/app/(payload)/admin/[[...segments]]/page.tsx` | Auto-generated catch-all admin page (DO NOT MODIFY) |
|
||||
| `src/app/(payload)/admin/[[...segments]]/not-found.tsx` | Auto-generated 404 page (DO NOT MODIFY) |
|
||||
| `src/app/(payload)/importMap.js` | Auto-generated Payload import map (DO NOT MODIFY) |
|
||||
| `src/app/(payload)/custom.scss` | Custom SCSS overrides for admin panel styling |
|
||||
|
||||
## @payload-config Alias
|
||||
|
||||
The `tsconfig.json` defines a path alias that points to the config in `@repo/cms-core`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@payload-config": [
|
||||
"../../packages/cms-core/src/payload.config.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When Payload or auto-generated files import `@payload-config`, it resolves to `packages/cms-core/src/payload.config.ts`. This is how the thin shell delegates all configuration to `@repo/cms-core`.
|
||||
|
||||
## next.config.mjs
|
||||
|
||||
The Next.js config is minimal -- just the `withPayload` wrapper:
|
||||
|
||||
```javascript
|
||||
import { withPayload } from "@payloadcms/next/withPayload";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default withPayload(nextConfig);
|
||||
```
|
||||
|
||||
`withPayload()` adds the necessary webpack aliases, module resolution, and middleware for Payload to work within Next.js.
|
||||
|
||||
## Auto-Generated Files
|
||||
|
||||
The files under `src/app/(payload)/` are generated by Payload and should NOT be manually edited:
|
||||
|
||||
- **`layout.tsx`** -- Wraps the admin panel with `RootLayout` from `@payloadcms/next/layouts`, injects config and importMap
|
||||
- **`admin/[[...segments]]/page.tsx`** -- Catch-all route that renders `RootPage` from `@payloadcms/next/views`
|
||||
- **`admin/[[...segments]]/not-found.tsx`** -- 404 handler using `NotFoundPage` from `@payloadcms/next/views`
|
||||
- **`importMap.js`** -- Maps Payload component paths for the admin UI
|
||||
|
||||
If you need to regenerate these files, Payload will do so automatically during dev/build.
|
||||
|
||||
## Type Generation
|
||||
|
||||
To regenerate Payload TypeScript types after changing collections/globals:
|
||||
|
||||
```bash
|
||||
cd apps/cms && pnpm generate:types
|
||||
# Equivalent to: payload generate:types
|
||||
# Output goes to: packages/cms-core/src/payload-types.ts
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `@repo/cms-core` | All Payload configuration (collections, globals, hooks, config) |
|
||||
| `@payloadcms/next` | Next.js integration for Payload (withPayload, admin UI views) |
|
||||
| `@payloadcms/ui` | Payload admin panel React components |
|
||||
| `payload` | Payload CMS core |
|
||||
| `next` | Next.js 15 framework |
|
||||
| `react` / `react-dom` | React 19 runtime |
|
||||
| `sharp` | Image processing for Payload uploads |
|
||||
|
||||
## Cross-References
|
||||
|
||||
- **ALL CMS configuration:** `packages/cms-core/` -- see `packages/cms-core/AGENTS.md`
|
||||
- **CMS client for querying data:** `packages/cms-client/` -- see `packages/cms-client/AGENTS.md`
|
||||
- **Core business logic:** `packages/core/` -- see `packages/core/AGENTS.md`
|
||||
|
||||
@@ -1,24 +1,135 @@
|
||||
# apps/storybook — Centralized Storybook
|
||||
# apps/storybook -- Centralized Storybook
|
||||
|
||||
Pulls stories from `packages/ui/src/**/*.stories.tsx`. Uses `@storybook/react-vite` with `@tailwindcss/vite` plugin.
|
||||
## Purpose
|
||||
|
||||
## Development
|
||||
Centralized Storybook instance that pulls and renders all stories from `packages/ui`. Provides a visual development environment, component documentation, and MCP integration for AI agents.
|
||||
|
||||
## Port: 6006
|
||||
|
||||
```bash
|
||||
pnpm dev --filter @repo/storybook # Starts on port 6006
|
||||
pnpm dev --filter @repo/storybook # http://localhost:6006
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
- `.storybook/main.ts` — Framework config, story globs, Tailwind vite plugin
|
||||
- `.storybook/preview.ts` — Global CSS import, control matchers
|
||||
### `.storybook/main.ts`
|
||||
|
||||
```typescript
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
framework: "@storybook/react-vite",
|
||||
stories: ["../../../packages/ui/src/**/*.stories.@(ts|tsx)"],
|
||||
addons: ["@storybook/addon-essentials"],
|
||||
docs: {
|
||||
autodocs: "tag",
|
||||
},
|
||||
async viteFinal(config) {
|
||||
const { mergeConfig } = await import("vite");
|
||||
const tailwindPlugin = await import("@tailwindcss/vite");
|
||||
|
||||
return mergeConfig(config, {
|
||||
plugins: [tailwindPlugin.default()],
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Key configuration details:
|
||||
- **`stories`** glob reaches into `packages/ui/src/` to find all `.stories.tsx` files
|
||||
- **`viteFinal`** adds the `@tailwindcss/vite` plugin so Tailwind v4 classes render correctly in stories
|
||||
- **`autodocs: "tag"`** generates documentation pages for stories tagged with `"autodocs"`
|
||||
- **`@storybook/addon-essentials`** includes Controls, Actions, Backgrounds, Viewport, Docs
|
||||
|
||||
### `.storybook/preview.ts`
|
||||
|
||||
```typescript
|
||||
import type { Preview } from "@storybook/react";
|
||||
import "../../../packages/ui/src/styles/globals.css";
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Key details:
|
||||
- Imports `globals.css` from `@repo/ui` so all Tailwind v4 `@theme` tokens are available
|
||||
- Control matchers auto-detect color and date props for appropriate editor widgets
|
||||
|
||||
## Story Organization
|
||||
|
||||
Stories are organized by Atomic Design level via title:
|
||||
- `"Atoms/Button"`, `"Molecules/FormField"`, `"Organisms/DataTable"`
|
||||
Stories are organized by Atomic Design level via the `title` field in story metadata. The title determines the sidebar hierarchy in Storybook.
|
||||
|
||||
## MCP
|
||||
### Story Title Convention
|
||||
|
||||
When running, Storybook MCP is available at `http://localhost:6006/mcp`.
|
||||
Install `@storybook/addon-mcp` to enable.
|
||||
| Level | Title format | Example | Sidebar path |
|
||||
|---|---|---|---|
|
||||
| Atom | `"Atoms/{ComponentName}"` | `"Atoms/Button"` | Atoms > Button |
|
||||
| Molecule | `"Molecules/{ComponentName}"` | `"Molecules/FormField"` | Molecules > FormField |
|
||||
| Organism | `"Organisms/{ComponentName}"` | `"Organisms/DataTable"` | Organisms > DataTable |
|
||||
| Template | `"Templates/{ComponentName}"` | `"Templates/DashboardLayout"` | Templates > DashboardLayout |
|
||||
|
||||
### Existing Stories
|
||||
|
||||
| Story title | Component | Location in `@repo/ui` |
|
||||
|---|---|---|
|
||||
| `Atoms/Button` | Button (5 variants: Default, Secondary, Destructive, Outline, Ghost) | `src/atoms/button/button.stories.tsx` |
|
||||
| `Atoms/Input` | Input (Default, Disabled) | `src/atoms/input/input.stories.tsx` |
|
||||
| `Molecules/FormField` | FormField (Default, WithDescription, WithError) | `src/molecules/form-field/form-field.stories.tsx` |
|
||||
|
||||
## MCP Integration
|
||||
|
||||
When Storybook is running, the MCP (Model Context Protocol) endpoint is available at:
|
||||
|
||||
```
|
||||
http://localhost:6006/mcp
|
||||
```
|
||||
|
||||
### Available MCP tools:
|
||||
|
||||
- **`list-all-documentation`** -- Lists all documented components and their stories
|
||||
- **`get-documentation`** -- Gets detailed documentation for a specific component (props, variants, usage)
|
||||
- **`run-story-tests`** -- Runs visual tests on stories to validate rendering
|
||||
|
||||
### Installing addon-mcp
|
||||
|
||||
If `@storybook/addon-mcp` is not already installed:
|
||||
|
||||
```bash
|
||||
npx storybook add @storybook/addon-mcp
|
||||
```
|
||||
|
||||
This adds the addon to `.storybook/main.ts` and enables the MCP endpoint.
|
||||
|
||||
### Using MCP in workflows
|
||||
|
||||
Always query MCP before creating new UI components:
|
||||
|
||||
1. **Before creating:** `list-all-documentation` to check if a similar component exists
|
||||
2. **Before extending:** `get-documentation` to understand existing props and variants
|
||||
3. **After creating:** `run-story-tests` to validate the new story renders correctly
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `@repo/ui` | Source of all component stories |
|
||||
| `@storybook/react-vite` | Storybook framework using Vite bundler |
|
||||
| `@storybook/addon-essentials` | Controls, Actions, Docs, Backgrounds, Viewport |
|
||||
| `@tailwindcss/vite` | Vite plugin for Tailwind CSS v4 |
|
||||
| `storybook` | Storybook core CLI and dev server |
|
||||
| `tailwindcss` | Tailwind CSS v4 engine |
|
||||
| `vite` | Build tool / dev server |
|
||||
| `react` / `react-dom` | React 19 runtime |
|
||||
|
||||
## Cross-References
|
||||
|
||||
- **Component source:** `packages/ui/` -- see `packages/ui/AGENTS.md`
|
||||
- **Tailwind tokens:** `packages/ui/src/styles/globals.css`
|
||||
|
||||
@@ -1,16 +1,203 @@
|
||||
# apps/web-next — Next.js 15 Reference App
|
||||
# apps/web-next -- Next.js 15 Reference App
|
||||
|
||||
Thin Next.js app consuming `@repo/api-client` for data and `@repo/ui` for components.
|
||||
## Purpose
|
||||
|
||||
Next.js 15 reference application using App Router. Demonstrates how to consume `@repo/api-client` for tRPC data fetching, `@repo/ui` for components, and `@repo/api` for the tRPC HTTP endpoint. This is a thin app -- business logic lives in `@repo/core`, UI components live in `@repo/ui`.
|
||||
|
||||
## Port: 3000
|
||||
|
||||
```bash
|
||||
pnpm dev --filter @repo/web-next # http://localhost:3000
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/app/api/trpc/[trpc]/route.ts` — tRPC HTTP endpoint (fetch adapter)
|
||||
- `src/app/providers.tsx` — Wraps with `<ApiProvider>`
|
||||
- `src/app/layout.tsx` — Root layout
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `src/app/layout.tsx` | Root layout -- wraps children with `<Providers>`, sets HTML metadata |
|
||||
| `src/app/providers.tsx` | Client component that wraps the app with `<ApiProvider trpcUrl="/api/trpc">` |
|
||||
| `src/app/page.tsx` | Home page (server component by default) |
|
||||
| `src/app/api/trpc/[trpc]/route.ts` | tRPC HTTP endpoint using the Next.js fetch adapter |
|
||||
|
||||
## Rules
|
||||
## tRPC Endpoint Setup
|
||||
|
||||
- Use `@repo/api-client` hooks for all data fetching
|
||||
- Use `@repo/ui` components for all UI
|
||||
- tRPC endpoint imports `appRouter` from `@repo/api`
|
||||
- Payload instance initialization goes in `src/lib/payload.ts`
|
||||
The file `src/app/api/trpc/[trpc]/route.ts` creates a catch-all API route that handles all tRPC requests:
|
||||
|
||||
```typescript
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { appRouter } from "@repo/api";
|
||||
|
||||
const handler = (req: Request) =>
|
||||
fetchRequestHandler({
|
||||
endpoint: "/api/trpc",
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: () => ({}),
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
```
|
||||
|
||||
How it works:
|
||||
1. Next.js catch-all route `[trpc]` matches any path under `/api/trpc/`
|
||||
2. `fetchRequestHandler` from tRPC's fetch adapter processes the request
|
||||
3. `appRouter` from `@repo/api` contains all registered routers
|
||||
4. `createContext` provides the context object to all procedures (currently empty `{}`)
|
||||
5. Both GET (for queries) and POST (for mutations/batched queries) are exported
|
||||
|
||||
## Provider Setup
|
||||
|
||||
The `<ApiProvider>` from `@repo/api-client` must wrap the entire app. Since it uses React hooks, it lives in a `"use client"` component:
|
||||
|
||||
```tsx
|
||||
// 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
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Recipe: Adding a New Page with Data Fetching
|
||||
|
||||
This example adds an `/articles` page that lists published articles.
|
||||
|
||||
### Step 1: Create the page route
|
||||
|
||||
Create `src/app/articles/page.tsx`:
|
||||
|
||||
```tsx
|
||||
import { ArticleList } from "./article-list";
|
||||
|
||||
export default function ArticlesPage() {
|
||||
return (
|
||||
<main>
|
||||
<h1>Articles</h1>
|
||||
<ArticleList />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Create the client component with data fetching
|
||||
|
||||
Create `src/app/articles/article-list.tsx`:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useTRPC } from "@repo/api-client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@repo/ui";
|
||||
|
||||
export function ArticleList() {
|
||||
const trpc = useTRPC();
|
||||
const { data, isLoading, error } = useQuery(
|
||||
trpc.content.listArticles.queryOptions({ status: "published", limit: 20 })
|
||||
);
|
||||
|
||||
if (isLoading) return <p>Loading articles...</p>;
|
||||
if (error) return <p>Error loading articles: {error.message}</p>;
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{data?.map((article) => (
|
||||
<li key={article.id}>
|
||||
<h2>{article.title}</h2>
|
||||
<Button variant="outline" size="sm">
|
||||
Read more
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Key patterns:
|
||||
- The page component (`page.tsx`) is a server component by default -- no `"use client"` needed
|
||||
- Data-fetching components that use `useTRPC()` must be client components (`"use client"`)
|
||||
- Import UI components from `@repo/ui`, never recreate them locally
|
||||
|
||||
## Payload Initialization Pattern (Server-Side Local API)
|
||||
|
||||
For server-side access to Payload CMS data (e.g., in server components, API routes, or server actions), create a Payload client initializer:
|
||||
|
||||
```typescript
|
||||
// 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;
|
||||
}
|
||||
```
|
||||
|
||||
Usage in a server component:
|
||||
|
||||
```tsx
|
||||
// src/app/articles/page.tsx (server component)
|
||||
import { getPayloadClient } from "@/lib/payload";
|
||||
|
||||
export default async function ArticlesPage() {
|
||||
const client = await getPayloadClient();
|
||||
const result = await client.find("articles", {
|
||||
where: { status: { equals: "published" } },
|
||||
sort: "-publishedAt",
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Articles</h1>
|
||||
<ul>
|
||||
{result.docs.map((article) => (
|
||||
<li key={article.id}>{article.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `@repo/api` | `appRouter` for the tRPC HTTP endpoint |
|
||||
| `@repo/api-client` | `ApiProvider` + `useTRPC()` for client-side data fetching |
|
||||
| `@repo/ui` | Shared UI components (Button, Input, Label, FormField, etc.) |
|
||||
| `next` | Next.js 15 framework with App Router |
|
||||
| `react` / `react-dom` | React 19 runtime |
|
||||
|
||||
## Cross-References
|
||||
|
||||
- **tRPC routers:** `packages/api/` -- see `packages/api/AGENTS.md`
|
||||
- **tRPC client/hooks:** `packages/api-client/` -- see `packages/api-client/AGENTS.md`
|
||||
- **UI components:** `packages/ui/` -- see `packages/ui/AGENTS.md`
|
||||
- **CMS client:** `packages/cms-client/` -- see `packages/cms-client/AGENTS.md`
|
||||
- **CMS config:** `packages/cms-core/` -- see `packages/cms-core/AGENTS.md`
|
||||
|
||||
@@ -1,15 +1,158 @@
|
||||
# apps/web-tanstack — TanStack Start Reference App
|
||||
# apps/web-tanstack -- TanStack Start Reference App
|
||||
|
||||
TanStack Start app consuming `@repo/api-client` for data and `@repo/ui` for components.
|
||||
## Purpose
|
||||
|
||||
TanStack Start reference application using TanStack Router with file-based routing. Demonstrates how to consume `@repo/api-client` for tRPC data fetching and `@repo/ui` for components. Like `apps/web-next`, this is a thin app -- business logic lives in `@repo/core`, UI components live in `@repo/ui`.
|
||||
|
||||
## Port: 3002
|
||||
|
||||
```bash
|
||||
pnpm dev --filter @repo/web-tanstack # http://localhost:3002
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/routes/__root.tsx` — Root layout with `<ApiProvider>`
|
||||
- `src/routes/index.tsx` — Home page
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `src/routes/__root.tsx` | Root layout -- creates the root route, wraps with `<ApiProvider>` and `<Outlet>` |
|
||||
| `src/routes/index.tsx` | Home page route (`/`) |
|
||||
|
||||
## Rules
|
||||
## File-Based Routing
|
||||
|
||||
- Use `@repo/api-client` hooks for all data fetching
|
||||
- Use `@repo/ui` components for all UI
|
||||
- File-based routing via TanStack Router (`src/routes/`)
|
||||
- Payload instance initialization goes in `src/lib/payload.ts`
|
||||
TanStack Router uses file-based routing where file paths in `src/routes/` map directly to URL paths:
|
||||
|
||||
| File | URL | Description |
|
||||
|---|---|---|
|
||||
| `src/routes/__root.tsx` | (all routes) | Root layout, wraps all child routes |
|
||||
| `src/routes/index.tsx` | `/` | Home page |
|
||||
| `src/routes/about.tsx` | `/about` | Static page |
|
||||
| `src/routes/articles/index.tsx` | `/articles` | Article listing |
|
||||
| `src/routes/articles/$id.tsx` | `/articles/:id` | Single article (dynamic param) |
|
||||
|
||||
### Naming conventions:
|
||||
- `__root.tsx` -- special root layout file, always wraps all routes
|
||||
- `index.tsx` -- index route for its directory (e.g., `/articles/index.tsx` matches `/articles`)
|
||||
- `$paramName.tsx` -- dynamic route segment (e.g., `$id.tsx` captures `:id`)
|
||||
- Nested folders create nested URL segments
|
||||
|
||||
## Provider Setup
|
||||
|
||||
The `<ApiProvider>` wraps the entire app in `__root.tsx`:
|
||||
|
||||
```tsx
|
||||
// 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>
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
Note: The `trpcUrl` points to the Next.js app's tRPC endpoint at `http://localhost:3000/api/trpc`. In production, this should be configured via environment variables.
|
||||
|
||||
## Recipe: Adding a New Route with Data Fetching
|
||||
|
||||
This example adds an `/articles` route that lists published articles.
|
||||
|
||||
### Step 1: Create the route file
|
||||
|
||||
Create `src/routes/articles/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useTRPC } from "@repo/api-client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@repo/ui";
|
||||
|
||||
export const Route = createFileRoute("/articles/")({
|
||||
component: ArticlesPage,
|
||||
});
|
||||
|
||||
function ArticlesPage() {
|
||||
const trpc = useTRPC();
|
||||
const { data, isLoading, error } = useQuery(
|
||||
trpc.content.listArticles.queryOptions({ status: "published", limit: 20 })
|
||||
);
|
||||
|
||||
if (isLoading) return <p>Loading articles...</p>;
|
||||
if (error) return <p>Error: {error.message}</p>;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Articles</h1>
|
||||
<ul>
|
||||
{data?.map((article) => (
|
||||
<li key={article.id}>
|
||||
<h2>{article.title}</h2>
|
||||
<Button variant="outline" size="sm">
|
||||
Read more
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Add a dynamic route for individual articles
|
||||
|
||||
Create `src/routes/articles/$id.tsx`:
|
||||
|
||||
```tsx
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useTRPC } from "@repo/api-client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export const Route = createFileRoute("/articles/$id")({
|
||||
component: ArticlePage,
|
||||
});
|
||||
|
||||
function ArticlePage() {
|
||||
const { id } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
|
||||
// Use the article ID from the URL parameter
|
||||
// (Assuming a getArticle procedure exists on the content router)
|
||||
const { data, isLoading } = useQuery(
|
||||
trpc.content.listArticles.queryOptions({ limit: 1 })
|
||||
);
|
||||
|
||||
if (isLoading) return <p>Loading...</p>;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Article {id}</h1>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Key patterns:
|
||||
- Every route file exports a `Route` created via `createFileRoute(path)(...)`
|
||||
- The `component` property defines the React component for that route
|
||||
- Use `Route.useParams()` to access dynamic parameters (e.g., `$id`)
|
||||
- Data fetching uses the same `useTRPC()` + `useQuery()` pattern as Next.js
|
||||
- Import UI components from `@repo/ui`, never recreate them locally
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `@repo/api` | `AppRouter` type (transitive via `@repo/api-client`) |
|
||||
| `@repo/api-client` | `ApiProvider` + `useTRPC()` for client-side data fetching |
|
||||
| `@repo/ui` | Shared UI components |
|
||||
| `@tanstack/react-router` | TanStack Router for file-based routing |
|
||||
| `react` / `react-dom` | React 19 runtime |
|
||||
|
||||
## Cross-References
|
||||
|
||||
- **tRPC routers:** `packages/api/` -- see `packages/api/AGENTS.md`
|
||||
- **tRPC client/hooks:** `packages/api-client/` -- see `packages/api-client/AGENTS.md`
|
||||
- **UI components:** `packages/ui/` -- see `packages/ui/AGENTS.md`
|
||||
- **Next.js app (serves the tRPC endpoint):** `apps/web-next/` -- see `apps/web-next/AGENTS.md`
|
||||
|
||||
@@ -1,27 +1,166 @@
|
||||
# @repo/api-client — Shared React Query Hooks
|
||||
# @repo/api-client -- Framework-Agnostic tRPC + React Query Provider
|
||||
|
||||
Framework-agnostic tRPC + React Query provider consumed by all apps.
|
||||
## Purpose
|
||||
|
||||
## Rules
|
||||
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.
|
||||
|
||||
- NEVER import framework-specific code (no next/, no tanstack/)
|
||||
- NEVER put business logic in hooks
|
||||
- Hooks use `useTRPC()` from `./trpc.ts`
|
||||
- Both Next.js and TanStack Start apps use the same `<ApiProvider>`
|
||||
## Hard Rules
|
||||
|
||||
## Usage in Apps
|
||||
- **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)
|
||||
|
||||
```tsx
|
||||
import { ApiProvider, useTRPC } from "@repo/api-client";
|
||||
## File Structure
|
||||
|
||||
// Root layout:
|
||||
<ApiProvider trpcUrl="/api/trpc">{children}</ApiProvider>
|
||||
|
||||
// In components:
|
||||
const trpc = useTRPC();
|
||||
const articles = trpc.content.listArticles.useQuery({});
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
## Adding a New Hook (optional wrapper)
|
||||
## How Apps Consume This Package
|
||||
|
||||
Custom hooks are optional — `useTRPC()` provides typed access to all procedures directly. Only create wrapper hooks if you need shared query logic across multiple components.
|
||||
### 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`
|
||||
|
||||
@@ -1,17 +1,144 @@
|
||||
# @repo/api — tRPC Router Definitions
|
||||
# @repo/api -- tRPC v11 Router Definitions
|
||||
|
||||
tRPC routers that call controllers from `@repo/core`. Each procedure validates input and delegates to a controller.
|
||||
## Purpose
|
||||
|
||||
## Rules
|
||||
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.
|
||||
|
||||
- NEVER put business logic in routers — delegate to controllers
|
||||
- Input validation uses Zod schemas
|
||||
- Each domain gets its own router file
|
||||
## Hard Rules
|
||||
|
||||
## Adding a New tRPC Router
|
||||
- **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`
|
||||
|
||||
1. Create `src/router/{domain}.router.ts`
|
||||
2. Import `router` and `publicProcedure` from `../trpc.js`
|
||||
3. Define procedures (`.query()` for reads, `.mutation()` for writes)
|
||||
4. Each procedure calls a controller from `@repo/core`
|
||||
5. Add router to root `appRouter` in `src/router/index.ts`
|
||||
## 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`
|
||||
|
||||
@@ -1,39 +1,217 @@
|
||||
# @repo/cms-client — Dual-Mode Payload Client
|
||||
# @repo/cms-client -- Dual-Mode Payload Client
|
||||
|
||||
Provides typed access to Payload CMS via Local API (primary) or HTTP REST (fallback).
|
||||
## Purpose
|
||||
|
||||
## THIS PACKAGE IS STANDALONE
|
||||
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`, `apps/*`
|
||||
- The Payload instance is INJECTED, not imported
|
||||
- Types are generated via `payload generate:types`
|
||||
## **NEVER import from `@repo/cms-core`, `@repo/core`, or any `apps/*` package.**
|
||||
|
||||
## Initialization
|
||||
This package is completely standalone. The Payload instance is INJECTED at app startup, never imported by this package.
|
||||
|
||||
| Context | Mode | How |
|
||||
|---|---|---|
|
||||
| apps/cms server | Local | `getPayload({config})` from @repo/cms-core |
|
||||
| apps/web-next server | Local | `getPayload({config})` from @repo/cms-core |
|
||||
| apps/web-tanstack server | Local | `getPayload({config})` from @repo/cms-core |
|
||||
| Client-side (browser) | N/A | Goes through tRPC — server handles it |
|
||||
| External services | HTTP | `createPayloadClient({mode:"http",baseURL})` |
|
||||
## 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
|
||||
// In app startup (e.g., apps/web-next/src/lib/payload.ts):
|
||||
// Example: apps/web-next/src/lib/payload.ts
|
||||
import { getPayload } from "payload";
|
||||
import { config } from "@repo/cms-core";
|
||||
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,
|
||||
});
|
||||
```
|
||||
|
||||
## Available Methods
|
||||
### HTTP Mode (fallback -- used for external services without direct DB access)
|
||||
|
||||
All methods support full Payload query capabilities (where, sort, limit, depth, page, populate):
|
||||
HTTP mode makes REST calls to the Payload API. It only needs the base URL:
|
||||
|
||||
- `find(collection, options)` — paginated query
|
||||
- `findByID(collection, id, options)` — single document
|
||||
- `create(collection, data, options)` — create document
|
||||
- `update(collection, id, data, options)` — update document
|
||||
- `delete(collection, id)` — delete document
|
||||
```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`
|
||||
|
||||
@@ -1,40 +1,323 @@
|
||||
# @repo/cms-core — Payload CMS Definition
|
||||
# @repo/cms-core -- ALL Payload CMS Configuration
|
||||
|
||||
All Payload configuration lives here: payload.config.ts, collections, globals, hooks, access control. The `apps/cms` app is a thin shell that imports this config.
|
||||
## 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 | Location | Examples |
|
||||
|---|---|---|
|
||||
| CMS-operational | Stay in hook | Slugify, image resize, default values |
|
||||
| Business logic | Delegate to use case | Notifications, validation, cross-domain updates |
|
||||
| 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 thin (max 5-10 lines)
|
||||
- Import use cases from `@repo/core/application`
|
||||
- Map Payload hook args to use case input types
|
||||
- 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`
|
||||
- Put business validation in hooks
|
||||
- Call external services directly from hooks
|
||||
- Duplicate logic that exists in a use case
|
||||
- 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)
|
||||
|
||||
## Adding a New Collection
|
||||
**Rule of thumb:** If deleting the hook would break a business requirement, the logic must live in a use case in `@repo/core`.
|
||||
|
||||
1. Create folder: `src/collections/{name}/`
|
||||
2. Create: `index.ts` (CollectionConfig), `fields.ts`
|
||||
3. Optionally: `hooks/`, `access/`
|
||||
4. Import in `src/payload.config.ts` collections array
|
||||
5. Export from `src/index.ts`
|
||||
## Recipe: Adding a New Collection
|
||||
|
||||
## Adding a Hook That Calls a Use Case
|
||||
This example adds a `Tags` collection.
|
||||
|
||||
1. Create `src/collections/{name}/hooks/{hook-name}.ts`
|
||||
2. Import use case from `@repo/core` (application layer only)
|
||||
3. Map Payload's hook args to use case input
|
||||
4. Call use case, return data
|
||||
### Step 1: Create the collection folder and fields
|
||||
|
||||
**Rule of thumb:** If deleting the hook would break a business requirement, the logic must be in a use case.
|
||||
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`
|
||||
|
||||
425
packages/cms-core/src/payload-types.ts
Normal file
425
packages/cms-core/src/payload-types.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
/* 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 {}
|
||||
}
|
||||
@@ -1,48 +1,249 @@
|
||||
# @repo/core — Clean Architecture Core
|
||||
# @repo/core -- Clean Architecture Core Package
|
||||
|
||||
Business logic package. All use cases, entities, interfaces, and DI live here.
|
||||
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.
|
||||
|
||||
## Layers (dependencies point inward only)
|
||||
**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.
|
||||
|
||||
```
|
||||
entities/ → NOTHING (innermost, zero deps)
|
||||
application/ → entities/ only
|
||||
interface-adapters/→ application/, entities/
|
||||
infrastructure/ → application/, entities/, @repo/cms-client, external libs
|
||||
di/ → all internal layers
|
||||
+------------------------------------------------------------------+
|
||||
| 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 |
|
||||
| Layer | Can Import From | NEVER Import From |
|
||||
|---|---|---|
|
||||
| entities/ | NOTHING | Everything else |
|
||||
| application/ | entities/ only | infrastructure/, interface-adapters/ |
|
||||
| interface-adapters/ | application/, entities/ | infrastructure/ |
|
||||
| infrastructure/ | application/, entities/, @repo/cms-client | interface-adapters/, apps/* |
|
||||
| di/ | All internal layers | apps/* |
|
||||
| `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 | Interface | Production | Mock |
|
||||
| Symbol Key | Interface | Production Implementation | Mock Implementation |
|
||||
|---|---|---|---|
|
||||
| IUsersRepository | IUsersRepository | PayloadUsersRepository (Plan 3) | MockUsersRepository |
|
||||
| IArticlesRepository | IArticlesRepository | PayloadArticlesRepository (Plan 3) | MockArticlesRepository |
|
||||
| IAuthenticationService | IAuthenticationService | BetterAuthService (future) | MockAuthenticationService |
|
||||
| ITelemetryService | ITelemetryService | OTelSentryService (future) | MockTelemetryService |
|
||||
| `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
|
||||
|
||||
- Models: `{name}.ts` with Zod schema + type export
|
||||
- Errors: `{domain}.ts` with Error subclasses
|
||||
- Interfaces: `{name}.repository.interface.ts` or `{name}.service.interface.ts`
|
||||
- Use cases: `{verb}-{noun}.use-case.ts`
|
||||
- Controllers: `{noun}.controller.ts`
|
||||
- Infra: `{provider}-{name}.repository.ts` or `mock-{name}.repository.ts`
|
||||
- DI modules: `{domain}.module.ts`
|
||||
| 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
|
||||
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
|
||||
|
||||
@@ -1,28 +1,340 @@
|
||||
# Application Layer — Use Cases + Interfaces
|
||||
# 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
|
||||
|
||||
- Imports from entities/ ONLY
|
||||
- NEVER imports from infrastructure/ or interface-adapters/
|
||||
- Repository interfaces define data access contracts
|
||||
- Service interfaces define external service contracts
|
||||
- Use cases get dependencies via `getInjection()` — never direct import of implementations
|
||||
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).
|
||||
|
||||
## Adding a New Use Case
|
||||
---
|
||||
|
||||
1. Create `src/application/use-cases/{domain}/{verb}-{noun}.use-case.ts`
|
||||
2. Get dependencies via DI: `const repo = getInjection("IMyRepository")`
|
||||
3. Implement business logic using entities and interfaces only
|
||||
4. Write test in `tests/unit/use-cases/{domain}/` using `initializeContainer()`/`destroyContainer()` pattern
|
||||
## Existing Repository Interfaces
|
||||
|
||||
## Adding a New Repository Interface
|
||||
| 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)` |
|
||||
|
||||
1. Create `src/application/repositories/{name}.repository.interface.ts`
|
||||
2. Define interface methods returning entity types
|
||||
3. Export from `src/application/repositories/index.ts`
|
||||
4. Create mock implementation in infrastructure/
|
||||
5. Register in DI container (add symbol, module binding)
|
||||
## Existing Service Interfaces
|
||||
|
||||
## Adding a New Service Interface
|
||||
| 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)` |
|
||||
|
||||
Same as repository, but in `src/application/services/`
|
||||
## 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
|
||||
|
||||
@@ -1,30 +1,208 @@
|
||||
# Auth Domain — Business Rules
|
||||
# Auth Domain -- Use Cases
|
||||
|
||||
## Responsibility
|
||||
**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.
|
||||
|
||||
Authentication and authorization: sign-in, sign-up, sign-out, session management.
|
||||
---
|
||||
|
||||
## Business Rules
|
||||
## Complete Business Rules
|
||||
|
||||
- Passwords are hashed via IAuthenticationService (never stored plain)
|
||||
- Sessions expire after 7 days (configured in mock, real impl may differ)
|
||||
- Sign-up requires unique username
|
||||
- Sign-in verifies password via IAuthenticationService.verifyPassword()
|
||||
- Sign-out invalidates session and returns blank cookie
|
||||
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
|
||||
|
||||
- `AuthenticationError` — wrong credentials (sign-in) or username taken (sign-up)
|
||||
- `UnauthenticatedError` — invalid/expired session
|
||||
| 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` — user lookup and creation
|
||||
- `IAuthenticationService` — password hashing, session management
|
||||
### IUsersRepository
|
||||
|
||||
## Adding a New Auth Use Case
|
||||
Provides user data access. Methods used by auth use cases:
|
||||
|
||||
1. Create `{verb}-{noun}.use-case.ts` in this folder
|
||||
2. Get deps via `getInjection("IUsersRepository")`, `getInjection("IAuthenticationService")`
|
||||
3. Write test first in `tests/unit/use-cases/auth/`
|
||||
4. Use `initializeContainer()`/`destroyContainer()` pattern in tests
|
||||
| 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
|
||||
|
||||
@@ -1,29 +1,240 @@
|
||||
# Content Domain — Business Rules
|
||||
# Content Domain -- Use Cases
|
||||
|
||||
## Responsibility
|
||||
**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.
|
||||
|
||||
Article management: creation, retrieval, publishing workflow.
|
||||
---
|
||||
|
||||
## Business Rules
|
||||
## Complete Business Rules
|
||||
|
||||
- Articles must have a title and content
|
||||
- Slugs auto-generated from title if not provided
|
||||
- New articles default to "draft" status
|
||||
- Filtering by status, authorId supported
|
||||
- Pagination via limit/offset
|
||||
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
|
||||
|
||||
- `NotFoundError` — article doesn't exist (future: update/delete)
|
||||
- `UnauthorizedError` — user can't edit article (future)
|
||||
- `InputParseError` — missing required fields (handled by controller)
|
||||
| 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` — article CRUD operations
|
||||
### IArticlesRepository
|
||||
|
||||
## Adding a New Content Use Case
|
||||
Provides article data access. All methods:
|
||||
|
||||
1. Create `{verb}-{noun}.use-case.ts` in this folder
|
||||
2. Get deps via `getInjection("IArticlesRepository")`
|
||||
3. Write test first in `tests/unit/use-cases/content/`
|
||||
| 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)
|
||||
|
||||
@@ -1,37 +1,246 @@
|
||||
# DI — InversifyJS Container
|
||||
# DI -- InversifyJS Dependency Injection Container
|
||||
|
||||
## Resolution Table
|
||||
**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.
|
||||
|
||||
| Symbol Key | Interface | Production | Mock |
|
||||
|---|---|---|---|
|
||||
| IUsersRepository | IUsersRepository | (future) | MockUsersRepository |
|
||||
| IArticlesRepository | IArticlesRepository | (future) | MockArticlesRepository |
|
||||
| IAuthenticationService | IAuthenticationService | (future) | MockAuthenticationService |
|
||||
| ITelemetryService | ITelemetryService | (future) | MockTelemetryService |
|
||||
---
|
||||
|
||||
## How to Register a New Dependency
|
||||
## Complete Resolution Table
|
||||
|
||||
1. Add Symbol to `types.ts` → `DI_SYMBOLS`
|
||||
2. Add return type to `DI_RETURN_TYPES` interface
|
||||
3. Create module in `modules/{domain}.module.ts`
|
||||
4. Bind interface to implementation (production) and mock (test)
|
||||
5. Load module in `container.ts` → `initializeContainer()` and `destroyContainer()`
|
||||
| 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: `initializeContainer()` runs automatically (not in test env)
|
||||
- Tests: Call `initializeContainer()` in `beforeEach`, `destroyContainer()` in `afterEach`
|
||||
- Test environments swap to mock implementations via module bindings
|
||||
### 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
|
||||
|
||||
- Import from apps/*
|
||||
- Import framework-specific code (Next.js, TanStack, etc.)
|
||||
- Use the container outside of this package — expose via `getInjection()` only
|
||||
| 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 (DO NOT REMOVE)
|
||||
---
|
||||
|
||||
- `experimentalDecorators: true`
|
||||
- `emitDecoratorMetadata: true`
|
||||
- `types: ["reflect-metadata"]`
|
||||
- `import "reflect-metadata"` at top of container.ts
|
||||
## 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
|
||||
|
||||
@@ -1,33 +1,212 @@
|
||||
# Entities Layer — Innermost, Zero Dependencies
|
||||
# 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
|
||||
|
||||
- NEVER import from application/, infrastructure/, interface-adapters/, or di/
|
||||
- NEVER import external libraries except Zod (for schema validation)
|
||||
- Everything here is pure — no side effects, no I/O, no async
|
||||
- Models are Zod schemas with inferred TypeScript types
|
||||
- Errors are custom Error subclasses with domain-specific semantics
|
||||
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`.
|
||||
|
||||
## Adding a New Model
|
||||
---
|
||||
|
||||
1. Create `src/entities/models/{name}.ts`
|
||||
2. Define Zod schema and export inferred type:
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
export const {name}Schema = z.object({ ... });
|
||||
export type {Name} = z.infer<typeof {name}Schema>;
|
||||
```
|
||||
3. Export from `src/entities/models/index.ts`
|
||||
## Existing Models
|
||||
|
||||
## Adding a New Error
|
||||
| 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) |
|
||||
|
||||
1. Create or edit `src/entities/errors/{domain}.ts`
|
||||
2. Extend Error with constructor accepting message + options:
|
||||
```typescript
|
||||
export class {Name}Error extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
}
|
||||
}
|
||||
```
|
||||
3. Export from `src/entities/errors/index.ts`
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
@@ -1,24 +1,261 @@
|
||||
# Infrastructure Layer — Implementations
|
||||
# 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
|
||||
|
||||
- Implements interfaces from application/
|
||||
- Imports from application/ and entities/
|
||||
- NEVER imported by application/ or entities/
|
||||
- Can import external libraries (Drizzle, Better Auth, Sentry, etc.)
|
||||
- Can import @repo/cms-client
|
||||
- Always provide a mock implementation for every real implementation
|
||||
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.
|
||||
|
||||
## Naming
|
||||
---
|
||||
|
||||
- Real: `{provider}-{name}.repository.ts` (e.g., `payload-users.repository.ts`)
|
||||
- Mock: `mock-{name}.repository.ts`
|
||||
- All implementations must use `@injectable()` decorator for InversifyJS
|
||||
## Existing Implementations
|
||||
|
||||
## Adding a New Implementation
|
||||
| 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) |
|
||||
|
||||
1. Create `src/infrastructure/repositories/{provider}-{name}.repository.ts`
|
||||
2. Implement the interface from application/
|
||||
3. Add `@injectable()` decorator
|
||||
4. Create corresponding mock: `mock-{name}.repository.ts`
|
||||
5. Register both in DI module (production binds real, test binds mock)
|
||||
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
|
||||
|
||||
@@ -1,29 +1,279 @@
|
||||
# Controllers — Interface Adapters
|
||||
# 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
|
||||
|
||||
- Controllers validate input using Zod schemas from entities/
|
||||
- Controllers call use cases — NEVER contain business logic themselves
|
||||
- Controllers handle error mapping (domain errors to appropriate responses)
|
||||
- Import from application/ and entities/ only
|
||||
- NEVER import from infrastructure/
|
||||
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`.
|
||||
|
||||
## Pattern
|
||||
---
|
||||
|
||||
## 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
|
||||
const inputSchema = z.object({ ... });
|
||||
// src/interface-adapters/controllers/{domain}/{name}.controller.ts
|
||||
import { z } from "zod";
|
||||
|
||||
export async function myController(input: Partial<z.infer<typeof inputSchema>>) {
|
||||
const { data, error } = inputSchema.safeParse(input);
|
||||
if (error) throw new InputParseError("Invalid data", { cause: error });
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
## Adding a New Controller
|
||||
---
|
||||
|
||||
1. Create `src/interface-adapters/controllers/{domain}/{name}.controller.ts`
|
||||
2. Define Zod input schema
|
||||
3. Validate input with `safeParse`, throw `InputParseError` on failure
|
||||
4. Call use case and return result
|
||||
5. Write test in `tests/unit/controllers/{domain}/`
|
||||
## 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
|
||||
|
||||
@@ -1,59 +1,345 @@
|
||||
# @repo/ui — Atomic Design Component Library
|
||||
# @repo/ui -- Atomic Design Component Library
|
||||
|
||||
shadcn/ui + Tailwind CSS v4 + Atomic Design. Components organized by level with co-located stories.
|
||||
## Purpose
|
||||
|
||||
## Atomic Classification Guide
|
||||
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`).
|
||||
|
||||
| Level | Definition | Examples |
|
||||
|---|---|---|
|
||||
| Atom | Single element, can't break down further | Button, Input, Label, Badge, Separator |
|
||||
| Molecule | 2-3 atoms, single responsibility | FormField, SearchBar, Tooltip, Select |
|
||||
| Organism | Complex section, self-contained | DataTable, Dialog, Header, Sidebar, Card |
|
||||
| Template | Page layout, content-agnostic | DashboardLayout, AuthLayout |
|
||||
| Page | Template + real data | **LIVES IN apps/, NOT HERE** |
|
||||
## Atomic Design Classification Guide
|
||||
|
||||
## Import Rules
|
||||
| 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/ | (top level) |
|
||||
| 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
|
||||
## Component Rules Per Level
|
||||
|
||||
- **Atoms:** No margins/positioning, no state, no business logic
|
||||
- **Molecules:** Single responsibility, minimal controlled state
|
||||
- **Organisms:** Can have internal state and sub-components
|
||||
- **Templates:** Use children/slots, NEVER hard-code content
|
||||
- **All:** Co-locate `.stories.tsx` next to component
|
||||
- **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
|
||||
|
||||
1. `pnpm dlx shadcn@latest add [component]` — lands in atoms/ by default
|
||||
2. Check classification guide above
|
||||
3. If not atom → move to correct directory
|
||||
4. Create `.stories.tsx` with title: `"{Level}/{ComponentName}"`
|
||||
5. Update level's `index.ts` barrel
|
||||
When adding a new shadcn/ui component:
|
||||
|
||||
## Story Template
|
||||
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.js";
|
||||
import { MyComponent } from "./my-component";
|
||||
|
||||
const meta = {
|
||||
title: "{Level}/{ComponentName}",
|
||||
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",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Storybook MCP
|
||||
## Recipe: Adding a New Component (8 Steps)
|
||||
|
||||
Before creating UI components, query Storybook MCP:
|
||||
- `list-all-documentation` — check for existing components
|
||||
- `get-documentation` — understand props/variants
|
||||
- After creating: `run-story-tests` to validate
|
||||
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`
|
||||
|
||||
Reference in New Issue
Block a user