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:
2026-07-12 21:06:38 +02:00
parent d7534b9926
commit 46250c819f
8 changed files with 220 additions and 139 deletions

274
AGENTS.md
View File

@@ -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. > **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 ## Agent-driven development
@@ -29,17 +31,32 @@ See `docs/guides/runbook.md` for the full workflow.
## Package Map ## Package Map
| Package | Tag | Purpose | | Package | Tag | Purpose |
| ----------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `@repo/core-shared` | core | Generic primitives (Zod, env, Payload hooks/fields/blocks, tRPC init/context) | | `@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) **optional**, scaffold via `pnpm turbo gen core-package ui` | | `@repo/core-ui` | core | Design system (atoms, molecules, generic organisms, templates) the home of Veect's monochrome "ink instrument" language |
| `@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-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-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-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/core-trpc` | core-composition | Frontend tRPC client + Next.js provider |
| `@repo/auth` | feature | Users collection + sign-in/up/out | | `@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) | | `@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-typescript` | tooling | Shared TypeScript base configs + Vitest base |
| `@repo/core-testing` | tooling | Shared test utilities (defineFactory, defineContractSuite, renderWithProviders, payload mocks) | | `@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 ### Five tags
- **app** (3 packages) `apps/web-next`, `apps/cms`, `apps/storybook` - **app** (3 packages) `apps/web-next`, `apps/cms`, `apps/storybook`
- **core-composition** (3 packages) `packages/core-api`, `core-cms`, `core-trpc` - **core-composition** (3 packages) `packages/core-api`, `core-cms` (must-have); `core-trpc` (optional, scaffolded)
- **core** (12 packages) `packages/core-shared`; `core-ui` is optional (scaffold with `pnpm turbo gen core-package ui`) - **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` - **feature** (1 package) `packages/auth`
- **tooling** (3 packages) `packages/core-eslint`, `core-typescript`, `core-testing` - **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 ### Four enforcement layers
1. **`package.json` dependencies** only allowed deps are declared; illegal imports fail at install time. 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/`: 3. **ESLint `eslint-plugin-boundaries`** (lint-time) configured in `packages/core-eslint/`:
- Enforces the five-tag rules at linting - 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`. - 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. **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 ## Key Commands
```bash ```bash
pnpm install # Install all dependencies 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 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 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 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 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 # 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) pnpm build # Build all packages (Turborepo)
docker compose up -d # Start PostgreSQL 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 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 ## 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): Inside `src/` files, import from sibling layers using relative paths (no `.js` extension modern Node/Vitest resolves without it):
```typescript ```typescript
// packages/blog/src/application/use-cases/get-articles.use-case.ts // packages/auth/src/application/use-cases/sign-in.use-case.ts
import type { IArticlesRepository } from "../repositories/articles.repository.interface"; import type { IUsersRepository } from "../repositories/users.repository.interface";
import { BLOG_SYMBOLS } from "../../di/symbols"; import type { IAuthenticationService } from "../services/authentication.service.interface";
import type { Article } from "../../entities/models/article"; 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`. 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. 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/`: Test files (`*.test.ts`) use the `@/` alias to import from `src/`:
```typescript ```typescript
// packages/blog/src/application/use-cases/get-articles.use-case.test.ts // packages/auth/src/application/use-cases/sign-in.use-case.test.ts
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case"; import { signInUseCase } from "@/application/use-cases/sign-in.use-case";
``` ```
### vitest.config.ts MUST declare @/ alias ### 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. 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 ```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 { z } from "zod";
import { articleSchema } from "../../entities/models/article"; import { cookieSchema } from "../../entities/models/cookie";
import type { IArticlesRepository } from "../repositories/articles.repository.interface"; import { sessionSchema } from "../../entities/models/session";
import type { IUsersRepository } from "../repositories/users.repository.interface";
import type { IAuthenticationService } from "../services/authentication.service.interface";
// ── Input ──────────────────────────────────────────────────────────────── // ── Input ────────────────────────────────────────────────────────────────
export const getArticlesInputSchema = z export const signInInputSchema = z
.object({ status: z.string().optional(), limit: z.number().int().optional() }) .object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
clientIp: z.string().optional(),
})
.strict(); .strict();
export type GetArticlesInput = z.infer<typeof getArticlesInputSchema>; export type SignInInput = z.infer<typeof signInInputSchema>;
// ── Output ─────────────────────────────────────────────────────────────── // ── Output ───────────────────────────────────────────────────────────────
export const getArticlesOutputSchema = z.array(articleSchema); export const signInOutputSchema = z.object({
export type GetArticlesOutput = z.infer<typeof getArticlesOutputSchema>; session: sessionSchema,
cookie: cookieSchema,
});
export type SignInOutput = z.infer<typeof signInOutputSchema>;
// ── Use case ───────────────────────────────────────────────────────────── // ── Use case ─────────────────────────────────────────────────────────────
export type IGetArticlesUseCase = ReturnType<typeof getArticlesUseCase>; export type ISignInUseCase = ReturnType<typeof signInUseCase>;
export const getArticlesUseCase = export const signInUseCase =
(articlesRepository: IArticlesRepository) => (
async (input: GetArticlesInput): Promise<GetArticlesOutput> => { usersRepository: IUsersRepository,
const result = await articlesRepository.getArticles(input); authenticationService: IAuthenticationService,
return getArticlesOutputSchema.parse(result); 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: Tests inject mocks directly no container rebinding:
```typescript ```typescript
const repo = new MockArticlesRepository([]); const users = new MockUsersRepository([]);
const useCase = getArticlesUseCase(repo); const auth = new MockAuthenticationService(users);
const articles = await useCase({ status: "published" }); const useCase = signInUseCase(users, auth, new NoopRateLimit());
const result = await useCase({ username: "alice", password: "testpassword" });
``` ```
### Controllers receive `unknown` + presenter ### 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. 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 ```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 { InputParseError } from "../../entities/errors/common";
import { import {
getArticlesInputSchema, signInInputSchema,
type GetArticlesOutput, type ISignInUseCase,
type IGetArticlesUseCase, type SignInOutput,
} from "../../application/use-cases/get-articles.use-case"; } from "../../application/use-cases/sign-in.use-case";
function presenter(value: GetArticlesOutput) { function presenter(value: SignInOutput) {
return value; return value.cookie;
} }
export type IGetArticlesController = ReturnType<typeof getArticlesController>; export type ISignInController = ReturnType<typeof signInController>;
export const getArticlesController = export const signInController =
(getArticlesUseCase: IGetArticlesUseCase) => (signInUseCase: ISignInUseCase) =>
async (input: unknown): Promise<ReturnType<typeof presenter>> => { async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = getArticlesInputSchema.safeParse(input); const parsed = signInInputSchema.safeParse(input);
if (!parsed.success) { 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. 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 ```typescript
bind<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase).toDynamicValue( // packages/auth/src/di/bind-production.ts (excerpt)
(ctx) => const wrappedSignIn = wireUseCase({
getArticlesUseCase(ctx.container.get(BLOG_SYMBOLS.IArticlesRepository)), 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 ### 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. 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 ```typescript
// packages/blog/src/integrations/api/procedures.ts // packages/auth/src/integrations/api/procedures.ts
import { t } from "@repo/core-shared/trpc/init"; import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; 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"; import { InputParseError } from "../../entities/errors/common";
export const blogProcedure = t.procedure.use( export const authProcedure = t.procedure.use(
defineErrorMiddleware([ defineErrorMiddleware([
[InputParseError, "BAD_REQUEST"], [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 ### 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 | | `./ui` | Hooks (`useX`), components, query builders (`queryOptions`) | App packages |
| `./api` | tRPC router (`xRouter` + `XRouter` type) | `@repo/core-api` only | | `./api` | tRPC router (`xRouter` + `XRouter` type) | `@repo/core-api` only |
| `./cms` | Payload collections | `@repo/core-cms` 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-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/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. 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) <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 ### Payload-backed features use constructor injection
Feature packages that need Payload receive the `SanitizedConfig` via constructor, not via `@repo/core-cms` dependency: Feature packages that need Payload receive the `SanitizedConfig` via constructor, not via `@repo/core-cms` dependency:
```typescript ```typescript
// packages/blog/src/infrastructure/repositories/articles.repository.ts // packages/auth/src/infrastructure/repositories/users.repository.ts
@injectable() @injectable()
export class ArticlesRepository implements IArticlesRepository { export class UsersRepository implements IUsersRepository {
constructor(private config: SanitizedConfig) {} constructor(
config: SanitizedConfig,
tracer: ITracer = new NoopTracer(),
logger: ILogger = new NoopLogger(),
) {
this.config = config;
this.tracer = tracer;
this.logger = logger;
}
async getArticles(options?: { async getUserByUsername(username: string): Promise<User | undefined> {
status?: string;
limit?: number;
}): Promise<Article[]> {
const payload = await getPayload({ config: this.config }); 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 ### 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`) - `USE_DEV_SEED === "true"` dev seed (explicit override; works in any `NODE_ENV`)
- `NODE_ENV === "production"` production (real Payload) - `NODE_ENV === "production"` production (real Payload)
- otherwise dev seed (developer default; `pnpm dev` boots without Payload) - otherwise dev seed (developer default; `pnpm dev` boots without Payload)
```typescript ```typescript
// apps/web-next/src/server/bind-production.ts // apps/web-next/src/server/bind-production.ts (condensed)
// 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.
import type { BindProductionContext, BindContext } from "@repo/core-shared/di"; import type { BindProductionContext, BindContext } from "@repo/core-shared/di";
export async function bindAllProduction(): Promise<void> { 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 resolvedConfig = await config;
const ctx: BindProductionContext = { const ctx: BindProductionContext = {
config: resolvedConfig, config: resolvedConfig,
tracer, tracer,
logger, logger,
queue,
rateLimit: new NoopRateLimit(),
}; };
bindProductionAuth(ctx); bindProductionAuth(ctx);
@@ -385,18 +452,13 @@ export async function bindAllProduction(): Promise<void> {
export async function bindAllDevSeed(): Promise<void> { export async function bindAllDevSeed(): Promise<void> {
const { tracer, logger } = resolveInstrumentation(); const { tracer, logger } = resolveInstrumentation();
const { queue } = resolveJobsDevSeed(); // InMemoryJobQueue
const ctx: BindContext< const ctx: BindContext = {
IEventBus,
IRealtimeBroadcaster,
IRealtimeHandlerRegistry
> = {
tracer, tracer,
logger, logger,
bus,
queue, queue,
realtime, rateLimit: new NoopRateLimit(),
realtimeRegistry,
}; };
await bindDevSeedAuth(ctx); 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`. 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). - **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. - **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`. 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 ```ts
const wrappedUC = withSpan( const wrappedUC = withSpan(
tracer, tracer,
{ name: "blog.getArticles", op: "use-case" }, { name: "auth.signIn", op: "use-case" },
withCapture( withCapture(
logger, logger,
{ feature: "blog", layer: "use-case", name: "blog.getArticles" }, { feature: "auth", layer: "use-case", name: "auth.signIn" },
getArticlesUseCase(repo), signInUseCase(repo, authService, rateLimit),
), ),
); );
const wrappedCtrl = withSpan( const wrappedCtrl = withSpan(
tracer, tracer,
{ name: "blog.getArticles", op: "controller" }, { name: "auth.signIn", op: "controller" },
withCapture( withCapture(
logger, logger,
{ feature: "blog", layer: "controller", name: "blog.getArticles" }, { feature: "auth", layer: "controller", name: "auth.signIn" },
getArticlesController(wrappedUC), 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`): **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 ## 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 - **Vertical Feature Spec** `docs/architecture/vertical-feature-spec.md` full design, rationale, decision log
- **Architecture Overview** `docs/architecture/overview.md` package responsibilities, data flow - **Architecture Overview** `docs/architecture/overview.md` package responsibilities, data flow
- **Dependency Flow** `docs/architecture/dependency-flow.md` allowed directions and composition pattern - **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`: Per-package documentation lives in each `AGENTS.md`:
- `packages/core-shared/AGENTS.md` - `packages/core-shared/AGENTS.md`
- `packages/core-api/AGENTS.md`, `core-cms/AGENTS.md`, `core-trpc/AGENTS.md` - `packages/core-api/AGENTS.md`, `core-cms/AGENTS.md` (`core-trpc` has none yet)
- `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-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/auth/AGENTS.md`
- `packages/core-eslint/AGENTS.md`, `core-typescript/AGENTS.md`, `core-testing/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` - `apps/cms/AGENTS.md`, `web-next/AGENTS.md`, `storybook/AGENTS.md`

View File

@@ -13,8 +13,8 @@ packages/
core-cms/ Composition only: assembles feature CMS exports into one Payload config core-cms/ Composition only: assembles feature CMS exports into one Payload config
core-api/ Composition only: aggregates feature tRPC routers into one appRouter core-api/ Composition only: aggregates feature tRPC routers into one appRouter
# Optional cross-cutting cores (scaffold on demand via `pnpm turbo gen core-package <name>`) # Optional cross-cutting cores (all currently scaffolded; new ones via `pnpm turbo gen core-package <name>`)
core-trpc/ Frontend tRPC client + per-framework providers (Next.js, TanStack) core-trpc/ Frontend tRPC client + Next.js provider
core-ui/ Design-system primitives (atoms, molecules, generic organisms, templates) core-ui/ Design-system primitives (atoms, molecules, generic organisms, templates)
core-realtime/ Socket.IO server + broadcaster + handler registry (ADR-016) core-realtime/ Socket.IO server + broadcaster + handler registry (ADR-016)
core-events/ In-memory + Payload-backed event bus + job queue (ADR-015) core-events/ In-memory + Payload-backed event bus + job queue (ADR-015)
@@ -25,10 +25,9 @@ packages/
# Business capabilities # Business capabilities
auth/ Users + sign-in/sign-up/sign-out + session/cookie domain auth/ Users + sign-in/sign-up/sign-out + session/cookie domain
blog/ Articles collection + publishing flow (control-plane identity, ADR-027; the Veect features —
media/ Media upload collection (skeleton; expand with optimization, CDN, etc.) workspaces, projects, discovery, design-doc, editor —
marketing-pages/ Pages collection + SiteSettings global land as sibling feature packages)
navigation/ Header global + menu items
# Tooling # Tooling
core-eslint/ Shared ESLint flat config + conformance rules + boundary rules core-eslint/ Shared ESLint flat config + conformance rules + boundary rules
@@ -42,7 +41,7 @@ See `docs/architecture/template-tiers.md` for the must-have/optional split and t
``` ```
React component React component
↓ useQuery(trpc.blog.articleBySlug.queryOptions({slug})) ← @repo/<feature>/ui (queries) ↓ useQuery(trpc.<feature>.<procedure>.queryOptions({...})) ← @repo/<feature>/ui (queries)
HTTP /api/trpc HTTP /api/trpc
tRPC procedure (xProcedure.input(xInputSchema)) ← integrations/api/router.ts tRPC procedure (xProcedure.input(xInputSchema)) ← integrations/api/router.ts
@@ -106,10 +105,10 @@ Both use the same five-tag model; see "Five tags" section below.
The workspace is organized into five mutually exclusive tags: The workspace is organized into five mutually exclusive tags:
- **app** (4 packages): `apps/cms`, `apps/web-next`, `apps/web-tanstack`, `apps/storybook` - **app** (3 packages): `apps/cms`, `apps/web-next`, `apps/storybook`
- **core-composition** (2 must-have): `packages/core-api`, `packages/core-cms`. Plus `packages/core-trpc` when scaffolded via `pnpm turbo gen core-package trpc` (optional). - **core-composition** (3 packages): `packages/core-api`, `packages/core-cms` (must-have). Plus `packages/core-trpc` (optional, currently scaffolded).
- **core** (1 must-have): `packages/core-shared`. Plus `core-ui`, `core-realtime`, `core-events`, `core-audit`, `core-analytics`, `core-consent`, `core-dsr` when scaffolded via `pnpm turbo gen core-package <name>` (optional). - **core** (8 packages): `packages/core-shared` (must-have). Plus the optional cores `core-ui`, `core-realtime`, `core-events`, `core-audit`, `core-analytics`, `core-consent`, `core-dsr` (all currently scaffolded; new ones via `pnpm turbo gen core-package <name>`).
- **feature** (5 packages): `packages/auth`, `packages/blog`, `packages/media`, `packages/marketing-pages`, `packages/navigation` - **feature** (1 package): `packages/auth`
- **tooling** (3 packages): `packages/core-eslint`, `packages/core-typescript`, `packages/core-testing` - **tooling** (3 packages): `packages/core-eslint`, `packages/core-typescript`, `packages/core-testing`
See `docs/architecture/template-tiers.md` for the must-have/optional split and the scaffold commands. See `docs/architecture/template-tiers.md` for the must-have/optional split and the scaffold commands.

View File

@@ -76,7 +76,7 @@ const wrapped = withSpan(
**`sentry/init-server.ts` + `sentry/init-client.ts`:** centralized init helpers (Next.js flavor). `init-server.ts` calls `Sentry.init` with `sendDefaultPii: false` (R31) — no `beforeSend` hook (PII scrubbed at OTel layer). `init-client.ts` retains `beforeSend`/`beforeSendTransaction` because browser does not use the OTel pipeline. **`sentry/init-server.ts` + `sentry/init-client.ts`:** centralized init helpers (Next.js flavor). `init-server.ts` calls `Sentry.init` with `sendDefaultPii: false` (R31) — no `beforeSend` hook (PII scrubbed at OTel layer). `init-client.ts` retains `beforeSend`/`beforeSendTransaction` because browser does not use the OTel pipeline.
**`sentry/init-server-node.ts` + `sentry/init-client-react.ts`:** Vite/non-Next variants used by `apps/web-tanstack`. Same posture as their Next.js counterparts. **`sentry/init-server-node.ts` + `sentry/init-client-react.ts`:** Vite/non-Next variants — no app consumes them since `apps/web-tanstack` was deleted (ADR-027 retrofit); kept for a future Vite/React runtime. Same posture as their Next.js counterparts.
**`di/bind-noop-instrumentation.ts` + `bind-otel-instrumentation.ts`:** bind ITracer + ILogger + IMetrics symbols to a Container. Returns the resolved instances so callers can use them without container lookup. `bindSentryInstrumentation` kept as a deprecated alias for one release. **`di/bind-noop-instrumentation.ts` + `bind-otel-instrumentation.ts`:** bind ITracer + ILogger + IMetrics symbols to a Container. Returns the resolved instances so callers can use them without container lookup. `bindSentryInstrumentation` kept as a deprecated alias for one release.
@@ -87,8 +87,8 @@ const wrapped = withSpan(
- `./instrumentation/otel/init-server-node``initOtelServerNode` (app bootstrap, server-side OTel SDK) - `./instrumentation/otel/init-server-node``initOtelServerNode` (app bootstrap, server-side OTel SDK)
- `./instrumentation/sentry/init-server` — Next.js server Sentry.init helper - `./instrumentation/sentry/init-server` — Next.js server Sentry.init helper
- `./instrumentation/sentry/init-client` — Next.js browser Sentry.init helper - `./instrumentation/sentry/init-client` — Next.js browser Sentry.init helper
- `./instrumentation/sentry/init-server-node``@sentry/node` server init (TanStack Start) - `./instrumentation/sentry/init-server-node``@sentry/node` server init (Vite/non-Next runtimes; currently unconsumed)
- `./instrumentation/sentry/init-client-react``@sentry/react` browser init (TanStack Start) - `./instrumentation/sentry/init-client-react``@sentry/react` browser init (Vite/non-Next runtimes; currently unconsumed)
**Boundaries:** **Boundaries:**

View File

@@ -8,7 +8,7 @@ import type { ITracer, ILogger, IMetrics } from "../index";
export type BindOtelOpts = { export type BindOtelOpts = {
dsn: string; dsn: string;
app: "web-next" | "cms" | "web-tanstack"; app: "web-next" | "cms";
release?: string; release?: string;
}; };
@@ -16,8 +16,8 @@ export type BindOtelOpts = {
* Binds OtelTracer, OtelLogger, and OtelMetrics to the DI container. * Binds OtelTracer, OtelLogger, and OtelMetrics to the DI container.
* *
* NOTE: The OTel NodeSDK is NOT initialized here. It is initialized by each * NOTE: The OTel NodeSDK is NOT initialized here. It is initialized by each
* app's instrumentation.ts `register()` hook (Next.js convention / server-entry * app's instrumentation.ts `register()` hook (Next.js convention) so that
* hook for TanStack) so that PII scrub processors are active before the very * PII scrub processors are active before the very
* first request handler runs — before bindAll() fires. Calling initOtelServerNode * first request handler runs — before bindAll() fires. Calling initOtelServerNode
* here as well would create a second SDK init path and reintroduce the startup * here as well would create a second SDK init path and reintroduce the startup
* window vulnerability (C1 fix). * window vulnerability (C1 fix).
@@ -41,8 +41,14 @@ export function bindOtelInstrumentation(
if (container.isBound(INSTRUMENTATION_SYMBOLS.METRICS)) { if (container.isBound(INSTRUMENTATION_SYMBOLS.METRICS)) {
container.unbind(INSTRUMENTATION_SYMBOLS.METRICS); container.unbind(INSTRUMENTATION_SYMBOLS.METRICS);
} }
container.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); container
container.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); .bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
container.bind<IMetrics>(INSTRUMENTATION_SYMBOLS.METRICS).toConstantValue(metrics); .toConstantValue(tracer);
container
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
.toConstantValue(logger);
container
.bind<IMetrics>(INSTRUMENTATION_SYMBOLS.METRICS)
.toConstantValue(metrics);
return { tracer, logger, metrics }; return { tracer, logger, metrics };
} }

View File

@@ -28,14 +28,14 @@ describe("initSentryClientReact", () => {
}); });
it("calls SentryReact.init with sendDefaultPii: false", () => { it("calls SentryReact.init with sendDefaultPii: false", () => {
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" }); initSentryClientReact({ dsn: "https://x@y/1", app: "web-next" });
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>; .calls[0]![0] as Record<string, unknown>;
expect(call["sendDefaultPii"]).toBe(false); expect(call["sendDefaultPii"]).toBe(false);
}); });
it("attaches replay integration with mask flags", () => { it("attaches replay integration with mask flags", () => {
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" }); initSentryClientReact({ dsn: "https://x@y/1", app: "web-next" });
expect(replayIntegration).toHaveBeenCalledTimes(1); expect(replayIntegration).toHaveBeenCalledTimes(1);
const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>; .calls[0]![0] as Record<string, unknown>;
@@ -45,7 +45,7 @@ describe("initSentryClientReact", () => {
}); });
it("defaults replay sample rates", () => { it("defaults replay sample rates", () => {
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" }); initSentryClientReact({ dsn: "https://x@y/1", app: "web-next" });
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>; .calls[0]![0] as Record<string, unknown>;
expect(call["replaysSessionSampleRate"]).toBe(0.0); expect(call["replaysSessionSampleRate"]).toBe(0.0);
@@ -53,7 +53,7 @@ describe("initSentryClientReact", () => {
}); });
it("attaches beforeSend + beforeSendTransaction scrubbers", () => { it("attaches beforeSend + beforeSendTransaction scrubbers", () => {
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" }); initSentryClientReact({ dsn: "https://x@y/1", app: "web-next" });
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>; .calls[0]![0] as Record<string, unknown>;
expect(typeof call["beforeSend"]).toBe("function"); expect(typeof call["beforeSend"]).toBe("function");
@@ -61,19 +61,19 @@ describe("initSentryClientReact", () => {
}); });
it("is a no-op when dsn is missing", () => { it("is a no-op when dsn is missing", () => {
initSentryClientReact({ dsn: "", app: "web-tanstack" }); initSentryClientReact({ dsn: "", app: "web-next" });
expect(SentryReact.init).not.toHaveBeenCalled(); expect(SentryReact.init).not.toHaveBeenCalled();
}); });
it("attaches feedbackIntegration when SentryReact.feedbackIntegration is available", () => { it("attaches feedbackIntegration when SentryReact.feedbackIntegration is available", () => {
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" }); initSentryClientReact({ dsn: "https://x@y/1", app: "web-next" });
expect(feedbackIntegration).toHaveBeenCalledTimes(1); expect(feedbackIntegration).toHaveBeenCalledTimes(1);
}); });
it("passes styleNonce and scriptNonce to feedbackIntegration when nonce provided", () => { it("passes styleNonce and scriptNonce to feedbackIntegration when nonce provided", () => {
initSentryClientReact({ initSentryClientReact({
dsn: "https://x@y/1", dsn: "https://x@y/1",
app: "web-tanstack", app: "web-next",
nonce: "abc123", nonce: "abc123",
}); });
const feedbackOpts = (feedbackIntegration as ReturnType<typeof vi.fn>).mock const feedbackOpts = (feedbackIntegration as ReturnType<typeof vi.fn>).mock
@@ -83,7 +83,7 @@ describe("initSentryClientReact", () => {
}); });
it("omits nonce props from feedbackIntegration when nonce not provided", () => { it("omits nonce props from feedbackIntegration when nonce not provided", () => {
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" }); initSentryClientReact({ dsn: "https://x@y/1", app: "web-next" });
const feedbackOpts = (feedbackIntegration as ReturnType<typeof vi.fn>).mock const feedbackOpts = (feedbackIntegration as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>; .calls[0]![0] as Record<string, unknown>;
expect(feedbackOpts["styleNonce"]).toBeUndefined(); expect(feedbackOpts["styleNonce"]).toBeUndefined();

View File

@@ -1,5 +1,6 @@
// packages/core-shared/src/instrumentation/sentry/init-client-react.ts // packages/core-shared/src/instrumentation/sentry/init-client-react.ts
// Browser-side Sentry init for Vite/React runtimes (TanStack Start). PII scrubbing is // Browser-side Sentry init for Vite/React runtimes (currently unconsumed —
// kept for a future non-Next runtime). PII scrubbing is
// applied via beforeSend/beforeSendTransaction because browser does NOT use the OTel pipeline. // applied via beforeSend/beforeSendTransaction because browser does NOT use the OTel pipeline.
// PII field lists imported from otel/pii-fields.ts (vendor-neutral). // PII field lists imported from otel/pii-fields.ts (vendor-neutral).
import * as SentryReact from "@sentry/react"; import * as SentryReact from "@sentry/react";
@@ -64,7 +65,7 @@ function scrubUrl(url: string): string {
} }
/** /**
* Client-side init for non-Next.js (Vite/React) runtimes (TanStack Start). * Client-side init for non-Next.js (Vite/React) runtimes.
* Mirrors init-client.ts but uses @sentry/react directly. Same PII, * Mirrors init-client.ts but uses @sentry/react directly. Same PII,
* replay, and scrubbing requirements apply. * replay, and scrubbing requirements apply.
*/ */

View File

@@ -14,7 +14,7 @@ import {
export type InitClientOpts = { export type InitClientOpts = {
dsn: string | undefined; dsn: string | undefined;
app: "web-next" | "cms" | "web-tanstack"; app: "web-next" | "cms";
release?: string; release?: string;
nonce?: string; nonce?: string;
}; };

View File

@@ -328,6 +328,15 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void {
// Adds an entry to .release-please-manifest.json and a per-package // Adds an entry to .release-please-manifest.json and a per-package
// block to release-please-config.json. Idempotent + sorted so reruns // block to release-please-config.json. Idempotent + sorted so reruns
// are stable. Throws if either file is missing. // are stable. Throws if either file is missing.
//
// WARNING (ADR-027 retrofit): this repo now tracks a SINGLE root
// product version with plain `v*` tags (see release-please-config.json
// — only "." is tracked). This step still registers the new feature as
// a per-package release-please component, whose component-prefixed tags
// would collide with the root-only `v*` policy. Until the generator is
// updated, revert the changes this step makes to
// release-please-config.json / .release-please-manifest.json after
// scaffolding. See AGENTS.md § "Adding a Feature".
function registerInReleasePlease( function registerInReleasePlease(
answers: Record<string, unknown>, answers: Record<string, unknown>,
): string { ): string {