docs(agents): rewrite package map and conventions for Veect
AGENTS.md now describes the Veect control plane (ADR-027/028/029,
docs/product/ authority table, glossary Veect-domain vocabulary), with
the package map, boundary tags, and per-package conventions verified
against the filesystem (auth + 11 core + 3 tooling packages;
web-next/cms/storybook apps) and code examples drawn from the real auth
feature. Adds warning notes for known generator staleness — the
release-please per-feature registration that would collide with the
root-only v* tag policy, the pre-shipped trace overwrite hazard (zod
incident, restored in e4a3b65), the trpc template's removed
@trpc/react-query dep — and records the accepted warn-severity lint
backlog (~93 findings).
Per story amendments, the same slice prunes the dead "web-tanstack"
member from core-shared's app-tag unions (bind-otel-instrumentation.ts,
sentry/init-client.ts; tests retargeted to "web-next") and fixes the
stale app/feature tag lists in docs/architecture/overview.md. A warning
comment mirrors the release-please note at the generator call site; no
functional generator changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
282
AGENTS.md
282
AGENTS.md
@@ -1,12 +1,14 @@
|
||||
# AGENTS.md — Vertical Feature Monorepo
|
||||
# AGENTS.md — Veect Control-Plane Monorepo
|
||||
|
||||
This is a **Turborepo + pnpm monorepo** organized by vertical features. Each feature package owns its own Clean Architecture layers (entities, application, infrastructure, interface-adapters) and integrations (CMS collections, tRPC routers, UI components). Core packages provide foundation: primitives, design system, CMS composition, API aggregation, and tRPC client platform.
|
||||
This repo is **Veect's control plane** — the hosted, multi-tenant half of a design-to-code SaaS ([ADR-027](./docs/decisions/adr-027-hosted-saas-and-runner-split.md)). Veect connects to a team's code repository, discovers its real components and design tokens, lets a designer compose screens on a canvas constrained to that system, and publishes real TSX as an ordinary pull request. The control plane owns auth, tenancy, workspace/project metadata, design-doc persistence, AI proxying, and orchestration of **workspace runners** (**cloud runner**: one isolated container per workspace; **local runner**: a CLI the developer runs against a local checkout), joined by a single **runner protocol**. The board renders repo components through the **iframe canvas** served by the runner's preview adapter ([ADR-028](./docs/decisions/adr-028-iframe-canvas.md)); **DesignDoc v1** (`design.veect.json`) is the committed schema and the editor is rebuilt under template conventions ([ADR-029](./docs/decisions/adr-029-designdoc-v1-and-editor-rebuild.md)). The authoritative product spec bundle lives in [`docs/product/`](./docs/product/README.md) — its README defines which document wins when two disagree.
|
||||
|
||||
> **Vocabulary:** Every cross-cutting term used in this repo (feature, use case, manifest, slice, conformance, dispatch, etc.) is defined in [`docs/glossary.md`](./docs/glossary.md). When in doubt about what a term means **here**, check the glossary first — it's the single source for shared vocabulary between humans and agents.
|
||||
Structurally this is a **Turborepo + pnpm monorepo** organized by vertical features. Each feature package owns its own Clean Architecture layers (entities, application, infrastructure, interface-adapters) and integrations (CMS collections, tRPC routers, UI components). Core packages provide foundation: primitives, design system, CMS composition, API aggregation, tRPC client platform, events, realtime, audit, analytics, consent, and DSR.
|
||||
|
||||
> **Vocabulary:** Every cross-cutting term used in this repo (feature, use case, manifest, slice, conformance, dispatch, etc.) is defined in [`docs/glossary.md`](./docs/glossary.md) — including the **"Veect product domain"** section (control plane, workspace, project, runner, runner protocol, iframe canvas, Playground, registry, checkpoint, publish). When in doubt about what a term means **here**, check the glossary first; its Veect section wins over the product docs where they disagree.
|
||||
|
||||
> **Commits:** Every commit message follows [Conventional Commits](https://www.conventionalcommits.org/): `<type>(<scope>): <imperative subject>` (≤72 chars). Types: `feat | fix | docs | style | refactor | test | chore | perf | ci | build | revert`. Use `!` for breaking changes. The sandcastle implementer + reviewer prompts enforce this; agents authoring autonomously MUST honor it.
|
||||
|
||||
> **Releases:** Versioning is hybrid (ADR-021) — root template + 5 feature packages version independently from `0.1.0`. release-please reads Conventional Commits and opens a rolling release PR on every merge to main; merging it cuts tagged releases. See [`docs/guides/releasing.md`](./docs/guides/releasing.md).
|
||||
> **Releases:** Versioning is root-only (ADR-021, amended by the ADR-027 retrofit) — release-please tracks a single root product version from `0.1.0` with plain `v*` tags. It opens a rolling release PR on every merge to main; merging it cuts the tag + GitHub release. There are no per-package versions or tags. See [`docs/guides/releasing.md`](./docs/guides/releasing.md).
|
||||
|
||||
## Agent-driven development
|
||||
|
||||
@@ -28,18 +30,33 @@ See `docs/guides/runbook.md` for the full workflow.
|
||||
|
||||
## Package Map
|
||||
|
||||
| Package | Tag | Purpose |
|
||||
| ----------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `@repo/core-shared` | core | Generic primitives (Zod, env, Payload hooks/fields/blocks, tRPC init/context) |
|
||||
| `@repo/core-ui` | core | Design system (atoms, molecules, generic organisms, templates) — **optional**, scaffold via `pnpm turbo gen core-package ui` |
|
||||
| `@repo/core-audit` | core | DPA-compliant audit logging (4 impls, GDPR erasure, OTel correlation) — **optional**, scaffold via `pnpm turbo gen core-package audit` |
|
||||
| `@repo/core-api` | core-composition | tRPC router aggregator — imports `@repo/<feature>/api` only |
|
||||
| `@repo/core-cms` | core-composition | Payload config aggregator — imports `@repo/<feature>/cms` only |
|
||||
| `@repo/core-trpc` | core-composition | Frontend tRPC client + framework-specific providers (Next.js, TanStack) |
|
||||
| `@repo/auth` | feature | Users collection + sign-in/up/out |
|
||||
| `@repo/core-eslint` | tooling | Shared ESLint 9 flat configs (base, next, react-internal, boundaries) |
|
||||
| `@repo/core-typescript` | tooling | Shared TypeScript base configs + Vitest base |
|
||||
| `@repo/core-testing` | tooling | Shared test utilities (defineFactory, defineContractSuite, renderWithProviders, payload mocks) |
|
||||
| Package | Tag | Purpose |
|
||||
| ----------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `@repo/core-shared` | core | Generic primitives (Zod, env, Payload hooks/fields/blocks, tRPC init/context, instrumentation interfaces, jobs, rate-limit, security headers, DI bind contexts) |
|
||||
| `@repo/core-ui` | core | Design system (atoms, molecules, generic organisms, templates) — the home of Veect's monochrome "ink instrument" language |
|
||||
| `@repo/core-events` | core | `IEventBus` — `InMemoryEventBus` (dev/test) + `PayloadJobsEventBus` (prod) (ADR-015) |
|
||||
| `@repo/core-realtime` | core | Socket.IO server + `IRealtimeBroadcaster` + handler registry (ADR-016) |
|
||||
| `@repo/core-audit` | core | DPA-compliant audit logging (GDPR erasure, OTel correlation) (ADR-018) |
|
||||
| `@repo/core-analytics` | core | Product analytics capture channel (`IAnalytics`) (ADR-024) |
|
||||
| `@repo/core-consent` | core | Consent-category runtime gate + cookie banner (ADR-025) |
|
||||
| `@repo/core-dsr` | core | Data-subject rights: export, delete, rectify, restrict (ADR-025) |
|
||||
| `@repo/core-api` | core-composition | tRPC router aggregator — imports `@repo/<feature>/api` only |
|
||||
| `@repo/core-cms` | core-composition | Payload config aggregator — imports `@repo/<feature>/cms` only |
|
||||
| `@repo/core-trpc` | core-composition | Frontend tRPC client + Next.js provider |
|
||||
| `@repo/auth` | feature | Users collection + sign-in/up/out + sessions — control-plane identity (ADR-027: email/password accounts; repo access is a per-workspace credential, not a user identity) |
|
||||
| `@repo/core-eslint` | tooling | Shared ESLint 9 flat configs (base, next, react-internal, boundaries) + the 16 conformance rules |
|
||||
| `@repo/core-typescript` | tooling | Shared TypeScript base configs + Vitest base |
|
||||
| `@repo/core-testing` | tooling | Shared test utilities (defineFactory, defineContractSuite, renderWithProviders, payload mocks, `Recording*` doubles) |
|
||||
|
||||
Apps:
|
||||
|
||||
| App | Port | Purpose |
|
||||
| ---------------- | ---- | ---------------------------------------------------------------------------------------------- |
|
||||
| `apps/web-next` | 3000 | Next.js — the hosted editor shell + landing page; custom `server.ts` hosts Next.js + Socket.IO |
|
||||
| `apps/cms` | 3001 | Payload admin |
|
||||
| `apps/storybook` | 6006 | Storybook — component workshop for `core-ui` + feature UI |
|
||||
|
||||
`auth` is the only feature package today. The Veect control-plane features (workspaces, projects, discovery, design-doc, editor, …) land as sibling packages under `packages/` following the same shape. Per ADR-029, `packages/editor` starts empty — the prototype codebase under `docs/product/reference/` is reference material, never vendored.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,8 +65,8 @@ See `docs/guides/runbook.md` for the full workflow.
|
||||
### Five tags
|
||||
|
||||
- **app** (3 packages) — `apps/web-next`, `apps/cms`, `apps/storybook`
|
||||
- **core-composition** (3 packages) — `packages/core-api`, `core-cms`, `core-trpc`
|
||||
- **core** (1–2 packages) — `packages/core-shared`; `core-ui` is optional (scaffold with `pnpm turbo gen core-package ui`)
|
||||
- **core-composition** (3 packages) — `packages/core-api`, `core-cms` (must-have); `core-trpc` (optional, scaffolded)
|
||||
- **core** (8 packages) — `packages/core-shared` (must-have); `core-ui`, `core-events`, `core-realtime`, `core-audit`, `core-analytics`, `core-consent`, `core-dsr` (optional cores, all currently scaffolded — new ones via `pnpm turbo gen core-package <name>`)
|
||||
- **feature** (1 package) — `packages/auth`
|
||||
- **tooling** (3 packages) — `packages/core-eslint`, `core-typescript`, `core-testing`
|
||||
|
||||
@@ -74,7 +91,7 @@ No other cross-package boundary deviations are permitted.
|
||||
### Four enforcement layers
|
||||
|
||||
1. **`package.json` dependencies** — only allowed deps are declared; illegal imports fail at install time.
|
||||
2. **`exports` maps** — feature packages expose `.`, `./ui`, `./cms`, `./api`, `./di/bind-production`, `./di/bind-dev-seed` only; no deep source paths exist.
|
||||
2. **`exports` maps** — feature packages expose `.`, `./ui`, `./cms`, `./api`, `./di/bind-production`, `./di/bind-dev-seed` (plus `./di/container` + `./di/symbols` for app-side controller resolution) only; no deep source paths exist.
|
||||
3. **ESLint `eslint-plugin-boundaries`** (lint-time) — configured in `packages/core-eslint/`:
|
||||
- Enforces the five-tag rules at linting
|
||||
- Feature packages may import from `core`, tooling, and other features' public exports (the `@repo/<feature>` contract barrel — e.g. an event contract a consumer subscribes to). They must not reach another feature's internals (the `exports` map seals those) or call its use cases directly — cross-feature behaviour flows through `IEventBus`.
|
||||
@@ -102,21 +119,29 @@ The generator does NOT wire aggregators or emit Payload CMS templates / faker fa
|
||||
|
||||
**Manual path.** When the generator's scope doesn't fit (multiple entities/use cases, custom layout, extending an existing feature), follow `docs/guides/adding-a-feature.md` — a step-by-step walkthrough covering folder structure, Clean Architecture layers, Payload + tRPC integration, core wiring, and testing / lint validation.
|
||||
|
||||
### Known generator staleness (warnings, not yet fixed)
|
||||
|
||||
The generators predate the ADR-027 retrofit. Three known traps — check the generator's output before committing:
|
||||
|
||||
- **release-please registration collides with the root-only policy.** `pnpm turbo gen feature` calls `turbo/generators/lib/release-please-utils.ts`, which registers the new feature as a release-please component (`packages/<name>` manifest entry + per-package config block → component-prefixed tags). This repo now tracks a **single root version with plain `v*` tags** — per-feature components would collide with it. Until the generator is updated, revert the changes it makes to `release-please-config.json` / `.release-please-manifest.json` after scaffolding.
|
||||
- **Pre-shipped library traces can clobber curated ones.** `pnpm turbo gen core-package <name>` force-writes its pre-shipped traces into `docs/library-decisions/` and has overwritten an enriched trace before (the zod trace lost its `last-revalidated` / sub-processor / socket-risk fields during story 05; restored in commit `e4a3b65`). After any core-package run, `git diff docs/library-decisions/` and restore curated fields the templates dropped.
|
||||
- **The trpc core-package template is stale.** `turbo/generators/templates/core-package/trpc/` still emits the removed `@trpc/react-query` dependency (`package.json.hbs`, trace templates, snapshot — self-consistent but stale). The scaffolded `packages/core-trpc` in this repo is already correct; don't re-run the trpc generator expecting current deps.
|
||||
|
||||
---
|
||||
|
||||
## Key Commands
|
||||
|
||||
```bash
|
||||
pnpm install # Install all dependencies
|
||||
pnpm dev # Start all dev servers (Next.js :3000, CMS :3001, Storybook :6006)
|
||||
pnpm dev # Start all dev servers (web-next :3000, CMS :3001, Storybook :6006)
|
||||
pnpm typecheck # Type-check all packages
|
||||
pnpm lint # Lint all packages (ESLint boundaries enforced)
|
||||
pnpm lint # Lint all packages (ESLint boundaries + the 16 conformance rules)
|
||||
pnpm turbo boundaries # Validate workspace dependency graph (Turbo boundaries)
|
||||
pnpm turbo gen feature # Scaffold a new feature package (see docs/guides/scaffolding-a-feature.md)
|
||||
pnpm turbo gen core-package # Scaffold an optional core package back (realtime, events, trpc, ui — see docs/guides/scaffolding-core-package.md)
|
||||
pnpm turbo gen core-package # Scaffold an optional core package (see docs/guides/scaffolding-core-package.md)
|
||||
pnpm turbo gen core-ui-component # Scaffold a core-ui atomic-design component (atom/molecule/organism — see docs/guides/scaffolding-core-ui-component.md)
|
||||
pnpm test # Run all unit + integration tests (Vitest)
|
||||
pnpm test:e2e # Run e2e tests (Playwright across both apps)
|
||||
pnpm test:e2e # Run e2e tests (Playwright, web-next)
|
||||
pnpm build # Build all packages (Turborepo)
|
||||
docker compose up -d # Start PostgreSQL
|
||||
|
||||
@@ -128,6 +153,8 @@ pnpm typecheck --filter @repo/auth # Only auth feature
|
||||
pnpm test --filter @repo/auth # Only auth unit/integration tests
|
||||
```
|
||||
|
||||
> **Known warn-severity lint backlog:** `pnpm lint` currently reports ~93 warnings repo-wide (99 when flagged during the story-03 review), all from warn-by-design rules — `conformance/pii-declaration-must-be-complete` on test fixtures and `turbo/no-undeclared-env-vars` on test-only env keys. This is noted, accepted debt: don't treat it as a failure signal, and don't mass-fix it as a side effect of unrelated work. Note that lint-staged runs `--max-warnings=0` on **staged files only**, so touching a file that carries one of these warnings means clearing that file's warnings in the same commit.
|
||||
|
||||
---
|
||||
|
||||
## Per-Package Conventions
|
||||
@@ -140,14 +167,15 @@ pnpm test --filter @repo/auth # Only auth unit/integration tests
|
||||
Inside `src/` files, import from sibling layers using relative paths (no `.js` extension — modern Node/Vitest resolves without it):
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/application/use-cases/get-articles.use-case.ts
|
||||
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
|
||||
import { BLOG_SYMBOLS } from "../../di/symbols";
|
||||
import type { Article } from "../../entities/models/article";
|
||||
// packages/auth/src/application/use-cases/sign-in.use-case.ts
|
||||
import type { IUsersRepository } from "../repositories/users.repository.interface";
|
||||
import type { IAuthenticationService } from "../services/authentication.service.interface";
|
||||
import { AuthenticationError } from "../../entities/errors/auth";
|
||||
import { sessionSchema } from "../../entities/models/session";
|
||||
```
|
||||
|
||||
Entity models live at `entities/models/<x>.ts`; domain errors at `entities/errors/<domain>.ts`; the shared `InputParseError` at `entities/errors/common.ts`.
|
||||
Mock siblings use the `.mock.ts` suffix (`<x>.repository.mock.ts`); real repository impls drop the `Payload` prefix (`articles.repository.ts`); interface filenames are dot-separated (`articles.repository.interface.ts`).
|
||||
Mock siblings use the `.mock.ts` suffix (`<x>.repository.mock.ts`); real repository impls drop the `Payload` prefix (`users.repository.ts`); interface filenames are dot-separated (`users.repository.interface.ts`).
|
||||
|
||||
This keeps source code portable and avoids circular alias issues.
|
||||
|
||||
@@ -156,8 +184,8 @@ This keeps source code portable and avoids circular alias issues.
|
||||
Test files (`*.test.ts`) use the `@/` alias to import from `src/`:
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/application/use-cases/get-articles.use-case.test.ts
|
||||
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";
|
||||
// packages/auth/src/application/use-cases/sign-in.use-case.test.ts
|
||||
import { signInUseCase } from "@/application/use-cases/sign-in.use-case";
|
||||
```
|
||||
|
||||
### vitest.config.ts MUST declare @/ alias
|
||||
@@ -199,29 +227,42 @@ TypeScript configs must set `"rootDir": "."` to allow both `src/` and test files
|
||||
Every use-case file exports its Zod schemas and inferred types. The use case body validates its output before returning — a misbehaving repository fails loudly at the layer that owns the contract.
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/application/use-cases/get-articles.use-case.ts
|
||||
// packages/auth/src/application/use-cases/sign-in.use-case.ts (condensed)
|
||||
import { z } from "zod";
|
||||
import { articleSchema } from "../../entities/models/article";
|
||||
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
|
||||
import { cookieSchema } from "../../entities/models/cookie";
|
||||
import { sessionSchema } from "../../entities/models/session";
|
||||
import type { IUsersRepository } from "../repositories/users.repository.interface";
|
||||
import type { IAuthenticationService } from "../services/authentication.service.interface";
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────────────
|
||||
export const getArticlesInputSchema = z
|
||||
.object({ status: z.string().optional(), limit: z.number().int().optional() })
|
||||
export const signInInputSchema = z
|
||||
.object({
|
||||
username: z.string().min(3).max(31),
|
||||
password: z.string().min(6).max(255),
|
||||
clientIp: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
export type GetArticlesInput = z.infer<typeof getArticlesInputSchema>;
|
||||
export type SignInInput = z.infer<typeof signInInputSchema>;
|
||||
|
||||
// ── Output ───────────────────────────────────────────────────────────────
|
||||
export const getArticlesOutputSchema = z.array(articleSchema);
|
||||
export type GetArticlesOutput = z.infer<typeof getArticlesOutputSchema>;
|
||||
export const signInOutputSchema = z.object({
|
||||
session: sessionSchema,
|
||||
cookie: cookieSchema,
|
||||
});
|
||||
export type SignInOutput = z.infer<typeof signInOutputSchema>;
|
||||
|
||||
// ── Use case ─────────────────────────────────────────────────────────────
|
||||
export type IGetArticlesUseCase = ReturnType<typeof getArticlesUseCase>;
|
||||
export type ISignInUseCase = ReturnType<typeof signInUseCase>;
|
||||
|
||||
export const getArticlesUseCase =
|
||||
(articlesRepository: IArticlesRepository) =>
|
||||
async (input: GetArticlesInput): Promise<GetArticlesOutput> => {
|
||||
const result = await articlesRepository.getArticles(input);
|
||||
return getArticlesOutputSchema.parse(result);
|
||||
export const signInUseCase =
|
||||
(
|
||||
usersRepository: IUsersRepository,
|
||||
authenticationService: IAuthenticationService,
|
||||
rateLimit: IRateLimit,
|
||||
) =>
|
||||
async (input: SignInInput): Promise<SignInOutput> => {
|
||||
// … rate-limit consumption + credential checks (throw domain errors) …
|
||||
return signInOutputSchema.parse({ session, cookie });
|
||||
};
|
||||
```
|
||||
|
||||
@@ -230,9 +271,10 @@ Void-input use cases use `z.object({}).strict()` and accept `_input: XInput`. Vo
|
||||
Tests inject mocks directly — no container rebinding:
|
||||
|
||||
```typescript
|
||||
const repo = new MockArticlesRepository([]);
|
||||
const useCase = getArticlesUseCase(repo);
|
||||
const articles = await useCase({ status: "published" });
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const useCase = signInUseCase(users, auth, new NoopRateLimit());
|
||||
const result = await useCase({ username: "alice", password: "testpassword" });
|
||||
```
|
||||
|
||||
### Controllers receive `unknown` + presenter
|
||||
@@ -240,40 +282,52 @@ const articles = await useCase({ status: "published" });
|
||||
Controllers `safeParse(xInputSchema)` from the use-case file and throw `InputParseError` on failure. Every non-void controller defines a top-level `function presenter(value: XOutput)` and returns `Promise<ReturnType<typeof presenter>>`. Identity is fine — `return value` — but the function form is always present so adding a transform later is a one-line edit.
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/interface-adapters/controllers/get-articles.controller.ts
|
||||
// packages/auth/src/interface-adapters/controllers/sign-in.controller.ts
|
||||
import { InputParseError } from "../../entities/errors/common";
|
||||
import {
|
||||
getArticlesInputSchema,
|
||||
type GetArticlesOutput,
|
||||
type IGetArticlesUseCase,
|
||||
} from "../../application/use-cases/get-articles.use-case";
|
||||
signInInputSchema,
|
||||
type ISignInUseCase,
|
||||
type SignInOutput,
|
||||
} from "../../application/use-cases/sign-in.use-case";
|
||||
|
||||
function presenter(value: GetArticlesOutput) {
|
||||
return value;
|
||||
function presenter(value: SignInOutput) {
|
||||
return value.cookie;
|
||||
}
|
||||
|
||||
export type IGetArticlesController = ReturnType<typeof getArticlesController>;
|
||||
export type ISignInController = ReturnType<typeof signInController>;
|
||||
|
||||
export const getArticlesController =
|
||||
(getArticlesUseCase: IGetArticlesUseCase) =>
|
||||
export const signInController =
|
||||
(signInUseCase: ISignInUseCase) =>
|
||||
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||||
const parsed = getArticlesInputSchema.safeParse(input);
|
||||
const parsed = signInInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new InputParseError("Invalid input", { cause: parsed.error });
|
||||
throw new InputParseError("Invalid sign-in input", {
|
||||
cause: parsed.error,
|
||||
});
|
||||
}
|
||||
return presenter(await getArticlesUseCase(parsed.data));
|
||||
const result = await signInUseCase(parsed.data);
|
||||
return presenter(result);
|
||||
};
|
||||
```
|
||||
|
||||
Void controllers (e.g. `signOutController`) return `Promise<void>` and skip the presenter entirely. One controller file per use case — no multi-method controller files.
|
||||
|
||||
DI binds each factory with `.toDynamicValue()`:
|
||||
DI binds each factory with `.toDynamicValue()`. In practice the binders go through the `wireUseCase` helper (`@repo/core-shared/conformance/wire-use-case`), which composes the instrumentation wrappers and binds the branded result:
|
||||
|
||||
```typescript
|
||||
bind<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase).toDynamicValue(
|
||||
(ctx) =>
|
||||
getArticlesUseCase(ctx.container.get(BLOG_SYMBOLS.IArticlesRepository)),
|
||||
);
|
||||
// packages/auth/src/di/bind-production.ts (excerpt)
|
||||
const wrappedSignIn = wireUseCase({
|
||||
container: authContainer,
|
||||
symbol: AUTH_SYMBOLS.ISignInUseCase,
|
||||
factory: signInUseCase,
|
||||
deps: [repo, authService, ctx.rateLimit ?? new NoopRateLimit()],
|
||||
feature: "auth",
|
||||
layer: "use-case",
|
||||
name: "signIn",
|
||||
tracer,
|
||||
logger,
|
||||
rateLimit: ctx.rateLimit ?? new NoopRateLimit(),
|
||||
});
|
||||
```
|
||||
|
||||
### Feature-scoped tRPC error mapping
|
||||
@@ -281,21 +335,29 @@ bind<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase).toDynamicValue(
|
||||
Each feature owns `integrations/api/procedures.ts` that wires domain errors to tRPC codes. `core-shared` provides the `defineErrorMiddleware` factory but never enumerates feature error classes.
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/integrations/api/procedures.ts
|
||||
// packages/auth/src/integrations/api/procedures.ts
|
||||
import { t } from "@repo/core-shared/trpc/init";
|
||||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||||
import { ArticleNotFoundError } from "../../entities/errors/article";
|
||||
import {
|
||||
AuthenticationError,
|
||||
UnauthenticatedError,
|
||||
UnauthorizedError,
|
||||
TooManyRequestsError,
|
||||
} from "../../entities/errors/auth";
|
||||
import { InputParseError } from "../../entities/errors/common";
|
||||
|
||||
export const blogProcedure = t.procedure.use(
|
||||
export const authProcedure = t.procedure.use(
|
||||
defineErrorMiddleware([
|
||||
[InputParseError, "BAD_REQUEST"],
|
||||
[ArticleNotFoundError, "NOT_FOUND"],
|
||||
[AuthenticationError, "UNAUTHORIZED"],
|
||||
[UnauthenticatedError, "UNAUTHORIZED"],
|
||||
[UnauthorizedError, "FORBIDDEN"],
|
||||
[TooManyRequestsError, "TOO_MANY_REQUESTS"],
|
||||
]),
|
||||
);
|
||||
```
|
||||
|
||||
The router then uses `blogProcedure.input(xInputSchema)` for every procedure — schemas are imported from the use-case file, never redefined inline. Unmapped errors still surface as `TRPCError(code: INTERNAL_SERVER_ERROR)`; the original domain error is preserved as `.cause`.
|
||||
The router then uses `authProcedure.input(xInputSchema)` for every procedure — schemas are imported from the use-case file, never redefined inline. Unmapped errors still surface as `TRPCError(code: INTERNAL_SERVER_ERROR)`; the original domain error is preserved as `.cause`.
|
||||
|
||||
### Per-feature public-API surface
|
||||
|
||||
@@ -307,9 +369,11 @@ Each feature package exposes exactly these subpath exports:
|
||||
| `./ui` | Hooks (`useX`), components, query builders (`queryOptions`) | App packages |
|
||||
| `./api` | tRPC router (`xRouter` + `XRouter` type) | `@repo/core-api` only |
|
||||
| `./cms` | Payload collections | `@repo/core-cms` only |
|
||||
| `./reader` | `I<Feature>Reader` type (cross-feature domain query contract) | Other feature packages |
|
||||
| `./reader` | `I<Feature>Reader` type (cross-feature domain query contract; no feature exposes one yet) | Other feature packages |
|
||||
| `./di/bind-production` | App boot side-effect — swaps mock for real Payload impl | App packages only |
|
||||
| `./di/bind-dev-seed` | App boot side-effect — swaps empty mock for populated mock | App packages, storybook |
|
||||
| `./di/container` | The feature's Inversify container (controller resolution at the app edge) | App packages only |
|
||||
| `./di/symbols` | The feature's DI symbol table | App packages only |
|
||||
|
||||
Apps import schemas/types from `@repo/<feature>` (root) and hooks/components from `@repo/<feature>/ui`. Deep source paths are not accessible — the `exports` map enforces this.
|
||||
|
||||
@@ -329,55 +393,58 @@ src/ui/
|
||||
<entity>-card.tsx # Presentational (receives props)
|
||||
```
|
||||
|
||||
Server components (`.server.tsx`) are the public API — the barrel exports them under clean names (`ArticleList`, not `ArticleListServer`). Client components (`.client.tsx`) are internal — only imported by their `.server` counterpart. Server components resolve controllers from DI, prefetch data, and wrap client components in `HydrationBoundary` for SSR + instant hydration. App pages just import and render: `<ArticleList />`, `<PageContent slug="about" />`. See [`docs/guides/building-feature-ui.md`](./docs/guides/building-feature-ui.md) for the full guide.
|
||||
Server components (`.server.tsx`) are the public API — the barrel exports them under clean names (`UserList`, not `UserListServer`). Client components (`.client.tsx`) are internal — only imported by their `.server` counterpart. Server components resolve controllers from DI, prefetch data, and wrap client components in `HydrationBoundary` for SSR + instant hydration. App pages just import and render: `<UserList />`. See [`docs/guides/building-feature-ui.md`](./docs/guides/building-feature-ui.md) for the full guide. (`auth`'s `src/ui/` currently ships only the barrel + query builders; this layout is the convention new feature UI follows.)
|
||||
|
||||
### Payload-backed features use constructor injection
|
||||
|
||||
Feature packages that need Payload receive the `SanitizedConfig` via constructor, not via `@repo/core-cms` dependency:
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/infrastructure/repositories/articles.repository.ts
|
||||
// packages/auth/src/infrastructure/repositories/users.repository.ts
|
||||
@injectable()
|
||||
export class ArticlesRepository implements IArticlesRepository {
|
||||
constructor(private config: SanitizedConfig) {}
|
||||
export class UsersRepository implements IUsersRepository {
|
||||
constructor(
|
||||
config: SanitizedConfig,
|
||||
tracer: ITracer = new NoopTracer(),
|
||||
logger: ILogger = new NoopLogger(),
|
||||
) {
|
||||
this.config = config;
|
||||
this.tracer = tracer;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
async getArticles(options?: {
|
||||
status?: string;
|
||||
limit?: number;
|
||||
}): Promise<Article[]> {
|
||||
async getUserByUsername(username: string): Promise<User | undefined> {
|
||||
const payload = await getPayload({ config: this.config });
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Class names carry no `Payload` prefix — `ArticlesRepository`, `PagesRepository`, `HeaderRepository`, etc. The config comes from the app at boot time (see below).
|
||||
Class names carry no `Payload` prefix — `UsersRepository`, not `PayloadUsersRepository`. The config comes from the app at boot time (see below).
|
||||
|
||||
### Apps call `bindAll()` per feature at boot
|
||||
|
||||
Each app (`web-next`, `cms`) imports both binders per feature and uses a small dispatcher (`bindAll()`) that picks based on environment:
|
||||
Any app that resolves feature controllers (today: `web-next`; `cms` is Payload admin only and doesn't) imports both binders per feature and uses a small dispatcher (`bindAll()`) that picks based on environment:
|
||||
|
||||
- `USE_DEV_SEED === "true"` → dev seed (explicit override; works in any `NODE_ENV`)
|
||||
- `NODE_ENV === "production"` → production (real Payload)
|
||||
- otherwise → dev seed (developer default; `pnpm dev` boots without Payload)
|
||||
|
||||
```typescript
|
||||
// apps/web-next/src/server/bind-production.ts
|
||||
// Slim default template — no optional packages scaffolded yet.
|
||||
// After running e.g. `pnpm turbo gen core-package events`, the full
|
||||
// IEventBus type can be plugged via the generic args
|
||||
// `BindProductionContext<IEventBus, ...>` and the bus/queue construction
|
||||
// (resolveEventsAndJobsProduction) wires back in per the printed next-steps.
|
||||
// apps/web-next/src/server/bind-production.ts (condensed)
|
||||
import type { BindProductionContext, BindContext } from "@repo/core-shared/di";
|
||||
|
||||
export async function bindAllProduction(): Promise<void> {
|
||||
const { tracer, logger } = resolveInstrumentation();
|
||||
const { tracer, logger } = resolveInstrumentation(); // Rule 0: DSN → OTel+Sentry vs Noop
|
||||
const { queue } = await resolveJobsProduction(); // PayloadJobQueue
|
||||
const resolvedConfig = await config;
|
||||
|
||||
const ctx: BindProductionContext = {
|
||||
config: resolvedConfig,
|
||||
tracer,
|
||||
logger,
|
||||
queue,
|
||||
rateLimit: new NoopRateLimit(),
|
||||
};
|
||||
|
||||
bindProductionAuth(ctx);
|
||||
@@ -385,18 +452,13 @@ export async function bindAllProduction(): Promise<void> {
|
||||
|
||||
export async function bindAllDevSeed(): Promise<void> {
|
||||
const { tracer, logger } = resolveInstrumentation();
|
||||
const { queue } = resolveJobsDevSeed(); // InMemoryJobQueue
|
||||
|
||||
const ctx: BindContext<
|
||||
IEventBus,
|
||||
IRealtimeBroadcaster,
|
||||
IRealtimeHandlerRegistry
|
||||
> = {
|
||||
const ctx: BindContext = {
|
||||
tracer,
|
||||
logger,
|
||||
bus,
|
||||
queue,
|
||||
realtime,
|
||||
realtimeRegistry,
|
||||
rateLimit: new NoopRateLimit(),
|
||||
};
|
||||
|
||||
await bindDevSeedAuth(ctx);
|
||||
@@ -404,11 +466,11 @@ export async function bindAllDevSeed(): Promise<void> {
|
||||
}
|
||||
```
|
||||
|
||||
Actual function name: `bindProductionAuth`.
|
||||
The dispatcher does not construct an `IEventBus` or realtime deps yet. When a feature needs them, construct them here and thread them via `ctx.bus` / `ctx.realtime` / `ctx.realtimeRegistry` — `core-events` and `core-realtime` are scaffolded and ready.
|
||||
|
||||
Each feature binder signature is `(ctx: BindProductionContext): void` for production and `(ctx: BindContext): Promise<void>` for dev-seed. Required ctx fields: `tracer`, `logger`. Production-only: `config`. Optional: `bus`, `queue`, `realtime`, `realtimeRegistry`.
|
||||
|
||||
**Cross-feature readers:** Features that expose domain queries return a reader from their binder: `bindProductionAuth(ctx)` returns `{ reader: IAuthReader }`. Consuming features accept readers as a second parameter: `bindProductionBlog(ctx, { authReader: authResult.reader })`. Ordering in `bindAll()` is explicit — owning feature first, consumers after. Reader cycles are a design error (rule Q3). Readers live at `integrations/readers/`, exported via `./reader` subpath. See the cross-feature readers ADR for full design.
|
||||
**Cross-feature readers:** Features that expose domain queries return a reader from their binder: `bindProductionAuth(ctx)` would return `{ reader: IAuthReader }`. Consuming features accept readers as a second parameter: `bindProductionX(ctx, { authReader: authResult.reader })`. Ordering in `bindAll()` is explicit — owning feature first, consumers after. Reader cycles are a design error (rule Q3). Readers live at `integrations/readers/`, exported via `./reader` subpath (no feature exposes one yet; `pnpm turbo gen reader` scaffolds it). See the cross-feature readers ADR (ADR-026) for full design.
|
||||
|
||||
---
|
||||
|
||||
@@ -436,7 +498,7 @@ Three rules:
|
||||
- **E1:** Event contracts are exported from the publisher's root; handlers are private to the consumer's bind-\* files (never re-exported, ESLint-enforced).
|
||||
- **J0:** Jobs are for _deferred_ work, not abstraction. Synchronous code stays synchronous.
|
||||
|
||||
`@repo/core-events` provides `IEventBus` (`InMemoryEventBus` for dev/test, `PayloadJobsEventBus` for prod). `@repo/core-shared/jobs` provides `IJobQueue` (`InMemoryJobQueue` / `PayloadJobQueue`). Both are swapped by `bindAll()` using the same `USE_DEV_SEED` / `NODE_ENV` rules as repositories.
|
||||
`@repo/core-events` provides `IEventBus` (`InMemoryEventBus` for dev/test, `PayloadJobsEventBus` for prod). `@repo/core-shared/jobs` provides `IJobQueue` (`InMemoryJobQueue` / `PayloadJobQueue`). Both follow the same `USE_DEV_SEED` / `NODE_ENV` swap rules as repositories; `bindAll()` constructs the queue today and constructs the bus once a feature consumes it (see the `bindAll()` note above).
|
||||
|
||||
Per-feature folders (all optional): `events/<x>.event.ts`, `events/handlers/on-<publisher>-<event>.handler.ts`, `jobs/<x>.job.ts`, `integrations/cms/jobs/<x>.task.ts`.
|
||||
|
||||
@@ -508,25 +570,25 @@ return this.tracer.startSpan(
|
||||
```ts
|
||||
const wrappedUC = withSpan(
|
||||
tracer,
|
||||
{ name: "blog.getArticles", op: "use-case" },
|
||||
{ name: "auth.signIn", op: "use-case" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "use-case", name: "blog.getArticles" },
|
||||
getArticlesUseCase(repo),
|
||||
{ feature: "auth", layer: "use-case", name: "auth.signIn" },
|
||||
signInUseCase(repo, authService, rateLimit),
|
||||
),
|
||||
);
|
||||
const wrappedCtrl = withSpan(
|
||||
tracer,
|
||||
{ name: "blog.getArticles", op: "controller" },
|
||||
{ name: "auth.signIn", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "controller", name: "blog.getArticles" },
|
||||
getArticlesController(wrappedUC),
|
||||
{ feature: "auth", layer: "controller", name: "auth.signIn" },
|
||||
signInController(wrappedUC),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
`withSpan` is outermost; `withCapture` is between span and factory so the error is captured before the span closes with error status. Bodies stay vendor-clean — neither use cases nor controllers call `tracer` / `logger` inline.
|
||||
`withSpan` is outermost; `withCapture` is between span and factory so the error is captured before the span closes with error status. Bodies stay vendor-clean — neither use cases nor controllers call `tracer` / `logger` inline. In feature binders, use cases go through the `wireUseCase` helper from `@repo/core-shared/conformance` (which applies this composition plus the manifest-driven wrappers); controllers compose `withSpan(withCapture(...))` by hand as above.
|
||||
|
||||
**Capture rules** (each error captured exactly once via the `__sentryReported` flag from `core-shared/instrumentation/reported-flag.ts`):
|
||||
|
||||
@@ -555,6 +617,10 @@ The vendor-neutral API packages (`@opentelemetry/api`, `@opentelemetry/api-logs`
|
||||
|
||||
## Specification & Guides
|
||||
|
||||
- **Product spec bundle** — `docs/product/README.md` — the authoritative Veect product/design/technical specification; its authority table decides which document wins (amended by ADR-027/028/029)
|
||||
- **ADR-027** — `docs/decisions/adr-027-hosted-saas-and-runner-split.md` — hosted SaaS + control-plane/runner split
|
||||
- **ADR-028** — `docs/decisions/adr-028-iframe-canvas.md` — iframe canvas; canvas protocol; Playground boundary
|
||||
- **ADR-029** — `docs/decisions/adr-029-designdoc-v1-and-editor-rebuild.md` — DesignDoc v1 schema; editor rebuild
|
||||
- **Vertical Feature Spec** — `docs/architecture/vertical-feature-spec.md` — full design, rationale, decision log
|
||||
- **Architecture Overview** — `docs/architecture/overview.md` — package responsibilities, data flow
|
||||
- **Dependency Flow** — `docs/architecture/dependency-flow.md` — allowed directions and composition pattern
|
||||
@@ -568,8 +634,8 @@ The vendor-neutral API packages (`@opentelemetry/api`, `@opentelemetry/api-logs`
|
||||
Per-package documentation lives in each `AGENTS.md`:
|
||||
|
||||
- `packages/core-shared/AGENTS.md`
|
||||
- `packages/core-api/AGENTS.md`, `core-cms/AGENTS.md`, `core-trpc/AGENTS.md`
|
||||
- `packages/core-ui/AGENTS.md` (optional — generated by `pnpm turbo gen core-package ui`; see `turbo/generators/templates/core-package/ui/AGENTS.md.hbs`)
|
||||
- `packages/core-api/AGENTS.md`, `core-cms/AGENTS.md` (`core-trpc` has none yet)
|
||||
- `packages/core-ui/AGENTS.md`, `core-events/AGENTS.md`, `core-realtime/AGENTS.md`, `core-audit/AGENTS.md`, `core-analytics/AGENTS.md`, `core-consent/AGENTS.md`, `core-dsr/AGENTS.md`
|
||||
- `packages/auth/AGENTS.md`
|
||||
- `packages/core-eslint/AGENTS.md`, `core-typescript/AGENTS.md`, `core-testing/AGENTS.md`
|
||||
- `apps/cms/AGENTS.md`, `web-next/AGENTS.md`, `storybook/AGENTS.md`
|
||||
|
||||
Reference in New Issue
Block a user