diff --git a/AGENTS.md b/AGENTS.md index 3051710..6c0d62b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -320,36 +320,52 @@ Each app (`web-next`, `web-tanstack`, `cms`) imports both binders per feature an ```typescript // apps/web-next/src/server/bind-production.ts -import { bindProductionBlog } from "@repo/blog/di/bind-production"; -import { bindProductionAuth } from "@repo/auth/di/bind-production"; -import { bindProductionMarketingPages } from "@repo/marketing-pages/di/bind-production"; -import { bindProductionNavigation } from "@repo/navigation/di/bind-production"; -import { bindProductionMedia } from "@repo/media/di/bind-production"; -import { bindDevSeedBlog } from "@repo/blog/di/bind-dev-seed"; -import { bindDevSeedAuth } from "@repo/auth/di/bind-dev-seed"; -import { bindDevSeedMarketingPages } from "@repo/marketing-pages/di/bind-dev-seed"; -import { bindDevSeedNavigation } from "@repo/navigation/di/bind-dev-seed"; -import { bindDevSeedMedia } from "@repo/media/di/bind-dev-seed"; -import config from "@repo/core-cms"; +import type { BindProductionContext, BindContext } from "@repo/core-shared/di"; +import type { IEventBus } from "@repo/core-events"; +import type { IRealtimeBroadcaster, IRealtimeHandlerRegistry } from "@repo/core-realtime"; -export async function bindAll(): Promise { - if (process.env.USE_DEV_SEED === "true") return bindAllDevSeed(); - if (process.env.NODE_ENV === "production") return bindAllProduction(); - return bindAllDevSeed(); +export async function bindAllProduction(deps: BindAllDeps): Promise { + const { tracer, logger } = resolveInstrumentation(); + const { bus, queue } = await resolveEventsAndJobsProduction(); + const resolvedConfig = await config; + const { realtime, realtimeRegistry } = deps; + + const ctx: BindProductionContext = { + config: resolvedConfig, + tracer, + logger, + bus, + queue, + realtime, + realtimeRegistry, + }; + + bindProductionAuth(ctx); + bindProductionBlog(ctx); + bindProductionMarketingPages(ctx); + bindProductionNavigation(ctx); + bindProductionMedia(ctx); } -export async function bindAllProduction(): Promise { - const resolvedConfig = await config; - bindProductionAuth(resolvedConfig); - bindProductionBlog(resolvedConfig); - bindProductionMarketingPages(resolvedConfig); - bindProductionNavigation(resolvedConfig); - bindProductionMedia(resolvedConfig); +export async function bindAllDevSeed(deps: BindAllDeps): Promise { + const { tracer, logger } = resolveInstrumentation(); + const { bus, queue } = resolveEventsAndJobsDevSeed(); + const { realtime, realtimeRegistry } = deps; + + const ctx: BindContext = { + tracer, logger, bus, queue, realtime, realtimeRegistry, + }; + + await bindDevSeedAuth(ctx); + await bindDevSeedBlog(ctx); + // ... (same for marketing-pages, navigation, media) } ``` Actual function names: `bindProductionAuth`, `bindProductionBlog`, `bindProductionMarketingPages`, `bindProductionNavigation`, `bindProductionMedia`. +Each feature binder signature is `(ctx: BindProductionContext): void` for production and `(ctx: BindContext): Promise` for dev-seed. Required ctx fields: `tracer`, `logger`. Production-only: `config`. Optional: `bus`, `queue`, `realtime`, `realtimeRegistry`. + --- ### Cross-feature events and background jobs (Plan 10, ADR-015) diff --git a/CLAUDE.md b/CLAUDE.md index f640cb4..be754fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,7 @@ Turborepo + pnpm monorepo organized by vertical features. Each feature (`auth`, - **Public surface split** — Feature root (`.`) exports contracts only: types, errors, schemas, IUseCase / IController aliases, router type, constants. UI artifacts (query builders, components) live behind `./ui` (`src/ui/index.ts`). Apps import queries from `@repo//ui`, schemas/types from `@repo/` - **Payload repositories via constructor** — Feature packages receive Payload config at constructor time, not as a direct dependency - **Three binding modes per feature** — Each feature exports two binders: `./di/bind-production` (real Payload) and `./di/bind-dev-seed` (populated mock). The app's `bindAll()` dispatcher in `apps/web-next/src/server/bind-production.ts` picks one by env: `USE_DEV_SEED="true"` → dev seed; `NODE_ENV="production"` → production; otherwise → dev seed (developer default so `pnpm dev` boots without Payload). Dev seed lives in `src/__seeds__/dev.ts` as a lazy `buildDev()` function that uses the feature's existing factory +- **Binders take a `ctx` arg from `core-shared/di`** — `bindProductionX(ctx: BindProductionContext)` for production binders; `bindDevSeedX(ctx: BindContext)` for dev-seed. Required fields: `tracer`, `logger`, plus `config` for production. Optional fields: `bus`, `queue`, `realtime`, `realtimeRegistry` (correspond to optional core packages — guard with `?.` when used, or cast to the full interface if the feature unconditionally requires the dep). Aggregator builds one ctx object and passes it to all feature binders - **App bootstrap** — Each app calls `bindAll()` from a server entry point (page server component, route handler) before resolving any feature controller. The dispatcher is idempotent - **Instrumentation lives in `core-shared/instrumentation/`** — Two interfaces (`ITracer`, `ILogger`), three implementations (`NoopTracer`/`NoopLogger`, `SentryTracer`/`SentryLogger`, and `RecordingTracer`/`RecordingLogger` from `core-testing`). Feature packages MUST NOT import `@sentry/*` directly (R40, eslint-enforced) - **Spans + capture composed at DI bind time** — Use cases + controllers wrapped via `withSpan(tracer, spanOpts, withCapture(logger, tags, factory(deps)))` inside `bind-production` / `bind-dev-seed`. `withSpan` is outermost so an errored span's timing reflects the capture-and-rethrow. Repository methods are different — they call `this.tracer.startSpan(...)` and `this.logger.captureException(...)` inline per method because they own per-call attributes (R41, R42) diff --git a/docs/architecture/dependency-flow.md b/docs/architecture/dependency-flow.md index 2abc415..006ecc5 100644 --- a/docs/architecture/dependency-flow.md +++ b/docs/architecture/dependency-flow.md @@ -115,7 +115,11 @@ apps/web-next/src/server/bind-production.ts (bindAll) │ server.ts → SocketIORealtimeBroadcaster + RealtimeHandlerRegistry (passed in from server.ts) │ page/test → InMemoryRealtimeBroadcaster + RealtimeHandlerRegistry (defaults) │ ↓ - ├─ bindProductionBlog(config, tracer, logger, bus, queue, realtime, realtimeRegistry) + ├─ build ctx: BindProductionContext = { config, tracer, logger, bus, queue, realtime, realtimeRegistry } + │ Required: tracer, logger, config (production only) + │ Optional: bus, queue, realtime, realtimeRegistry (guard with ?. when used) + │ ↓ + ├─ bindProductionBlog(ctx: BindProductionContext) │ │ │ ├─ blogContainer.bind(TRACER).toConstantValue(tracer) │ ├─ blogContainer.bind(LOGGER).toConstantValue(logger) @@ -127,9 +131,23 @@ apps/web-next/src/server/bind-production.ts (bindAll) │ ├─ // queue.register(...) in dev-seed; Payload task in prod │ └─ // realtimeRegistry.register(...) (ADR-016, gen realtime handler) │ - └─ (same for auth, marketing-pages, navigation, media) + └─ (same for auth, marketing-pages, navigation, media — each receives the same ctx) ``` +**`BindContext` shape (from `@repo/core-shared/di`):** + +| Field | Type | Required | Notes | +|---|---|---|---| +| `tracer` | `ITracer` | always | Resolved by Rule 0 (Sentry vs Noop) | +| `logger` | `ILogger` | always | Resolved by Rule 0 | +| `config` | `SanitizedConfig` | production only | Present in `BindProductionContext`, absent in `BindContext` | +| `bus` | `EventBusProtocol?` | optional | `IEventBus` at the aggregator; protocol surface at binders | +| `queue` | `IJobQueue?` | optional | Present when `core-shared/jobs` is wired | +| `realtime` | `RealtimeBroadcasterProtocol?` | optional | `IRealtimeBroadcaster` at the aggregator | +| `realtimeRegistry` | `RealtimeRegistryProtocol?` | optional | `IRealtimeHandlerRegistry` at the aggregator | + +Feature binders destructure `ctx` and use optional fields with `?.` or cast to the full interface when the feature unconditionally requires them (e.g. `bus as IEventBus` when a use case always needs the event bus). + **Why per-feature containers also get the binding:** lets internal DI-resolved code in a feature pull TRACER/LOGGER without going through the app dispatcher. In practice, only repository classes and feature-internal services would use this — controllers and use cases receive instrumentation via the bind-time wrapper. **Why the shared container exists at all:** isolates Rule 0 resolution from feature containers. Feature containers don't need to know if Sentry is on or off — they just receive an `ITracer` instance. diff --git a/docs/architecture/vertical-feature-spec.md b/docs/architecture/vertical-feature-spec.md index 372a1c0..beeedbb 100644 --- a/docs/architecture/vertical-feature-spec.md +++ b/docs/architecture/vertical-feature-spec.md @@ -693,8 +693,8 @@ Invoke the `superpowers:writing-plans` skill to produce a detailed, executable i - `infrastructure/repositories/.repository.ts` — constructor takes `(config, tracer, logger)` with Noop defaults; every public method's body is wrapped in `tracer.startSpan(...)` and any `catch` block calls `logger.captureException(err, { tags: { feature, repo, method } })` before re-throwing. - `infrastructure/repositories/.repository.mock.ts` — same constructor/wrapping shape (no catch — mocks don't originate infra errors). -- `di/bind-production.ts` — signature `(config, tracer, logger)`. Binds TRACER + LOGGER to the feature container; constructs the real repo with tracer/logger; wraps every use case + controller via `withSpan(withCapture(factory(deps)))` at bind time. `withSpan` is outermost so an errored span's timing reflects the capture-and-rethrow; `withCapture` honours the `__sentryReported` flag so a bubbled error from the repo isn't re-captured. -- `di/bind-dev-seed.ts` — signature `(tracer, logger)`. Same wrapping as bind-production but with the populated mock. +- `di/bind-production.ts` — signature `(ctx: BindProductionContext)` (from `@repo/core-shared/di`). Destructures `{ config, tracer, logger, bus, queue, realtime, realtimeRegistry }` from `ctx`. Binds TRACER + LOGGER to the feature container; constructs the real repo with tracer/logger; wraps every use case + controller via `withSpan(withCapture(factory(deps)))` at bind time. `withSpan` is outermost so an errored span's timing reflects the capture-and-rethrow; `withCapture` honours the `__sentryReported` flag so a bubbled error from the repo isn't re-captured. Optional fields (`bus`, `queue`, `realtime`, `realtimeRegistry`) are guarded with `?.` or cast to the full interface when the feature unconditionally requires them. +- `di/bind-dev-seed.ts` — signature `(ctx: BindContext)` (no `config`). Same wrapping as bind-production but with the populated mock. **Required exports (per feature root):** unchanged. diff --git a/packages/auth/AGENTS.md b/packages/auth/AGENTS.md index ae6ffca..cbab6ea 100644 --- a/packages/auth/AGENTS.md +++ b/packages/auth/AGENTS.md @@ -31,8 +31,8 @@ Users collection + authentication use cases (sign-in, sign-up, sign-out). Provid | `./ui` | Placeholder — extend here when auth gains React Query builders, never re-add to root | | `./api` | `authRouter` (tRPC router) | | `./cms` | Payload Users collection definition | -| `./di/bind-production` | `bindProductionAuth(config, tracer, logger, bus, queue)` — swaps mock impls for real Payload-backed ones at app boot | -| `./di/bind-dev-seed` | `bindDevSeedAuth(tracer, logger, bus, queue)` — replaces the default empty mock with a populated one for dev / Storybook | +| `./di/bind-production` | `bindProductionAuth(ctx: BindProductionContext)` — swaps mock impls for real Payload-backed ones at app boot | +| `./di/bind-dev-seed` | `bindDevSeedAuth(ctx: BindContext)` — replaces the default empty mock with a populated one for dev / Storybook | | `./di/container` | `authContainer` — the per-feature inversify container (consumed by e2e tests + production Payload event-tasks) | | `./di/symbols` | `AUTH_SYMBOLS` — DI symbol registry (consumed by e2e tests + production Payload event-tasks) | diff --git a/packages/blog/AGENTS.md b/packages/blog/AGENTS.md index 7e02a64..b5aa287 100644 --- a/packages/blog/AGENTS.md +++ b/packages/blog/AGENTS.md @@ -29,8 +29,8 @@ Articles collection + content use cases (get articles, get article by slug, crea | `./ui` | `articleBySlugQuery`, `listArticlesQuery` — React Query option builders | | `./api` | `blogRouter` (tRPC router) | | `./cms` | Payload Articles collection definition | -| `./di/bind-production` | `bindProductionBlog(config, tracer, logger, bus, queue)` — swaps mock impls for real Payload-backed ones at app boot | -| `./di/bind-dev-seed` | `bindDevSeedBlog(tracer, logger, bus, queue)` — replaces the default empty mock with a populated one for dev / Storybook | +| `./di/bind-production` | `bindProductionBlog(ctx: BindProductionContext)` — swaps mock impls for real Payload-backed ones at app boot | +| `./di/bind-dev-seed` | `bindDevSeedBlog(ctx: BindContext)` — replaces the default empty mock with a populated one for dev / Storybook | ## Use-case + controller patterns diff --git a/packages/marketing-pages/AGENTS.md b/packages/marketing-pages/AGENTS.md index 4d4e90d..412f9f5 100644 --- a/packages/marketing-pages/AGENTS.md +++ b/packages/marketing-pages/AGENTS.md @@ -29,8 +29,8 @@ Pages collection + SiteSettings global for site-wide metadata. Provides marketin | `./ui` | `pageBySlugQuery`, `siteSettingsQuery` — React Query option builders | | `./api` | `marketingPagesRouter` (tRPC router) | | `./cms` | Payload Pages collection + SiteSettings global | -| `./di/bind-production` | `bindProductionMarketingPages(config, tracer, logger, bus, queue)` — swaps mock impls for real Payload-backed ones at app boot | -| `./di/bind-dev-seed` | `bindDevSeedMarketingPages(tracer, logger, bus, queue)` — replaces the default empty mocks with populated ones for dev / Storybook | +| `./di/bind-production` | `bindProductionMarketingPages(ctx: BindProductionContext)` — swaps mock impls for real Payload-backed ones at app boot | +| `./di/bind-dev-seed` | `bindDevSeedMarketingPages(ctx: BindContext)` — replaces the default empty mocks with populated ones for dev / Storybook | | `./di/container` | `marketingPagesContainer` — the per-feature inversify container (consumed by e2e tests + production Payload event-tasks) | | `./di/symbols` | `MARKETING_PAGES_SYMBOLS` — DI symbol registry (consumed by e2e tests + production Payload event-tasks) | | `./services/mailer` | `IMailerService` — interface for the welcome-email seam (ADR-015 proof-of-life) | diff --git a/packages/media/AGENTS.md b/packages/media/AGENTS.md index 41ab5dc..9688966 100644 --- a/packages/media/AGENTS.md +++ b/packages/media/AGENTS.md @@ -33,8 +33,8 @@ Media upload collection (images, PDFs, etc.) and media-related use cases (get, l | `./ui` | Placeholder — extend here when media gains React Query builders, never re-add to root | | `./api` | `mediaRouter` (tRPC router) | | `./cms` | Payload Media collection definition | -| `./di/bind-production` | `bindProductionMedia(config, tracer, logger, bus, queue)` — swaps mock impls for real Payload-backed ones at app boot | -| `./di/bind-dev-seed` | `bindDevSeedMedia(tracer, logger, bus, queue)` — replaces the default empty mock with a populated one for dev / Storybook | +| `./di/bind-production` | `bindProductionMedia(ctx: BindProductionContext)` — swaps mock impls for real Payload-backed ones at app boot | +| `./di/bind-dev-seed` | `bindDevSeedMedia(ctx: BindContext)` — replaces the default empty mock with a populated one for dev / Storybook | ## Use-case + controller patterns diff --git a/packages/navigation/AGENTS.md b/packages/navigation/AGENTS.md index 45f998c..6a1ae69 100644 --- a/packages/navigation/AGENTS.md +++ b/packages/navigation/AGENTS.md @@ -29,8 +29,8 @@ Header global for main site navigation. Provides the Header Payload global and t | `./ui` | `headerQuery` — React Query option builder | | `./api` | `navigationRouter` (tRPC router) | | `./cms` | Payload Header global definition | -| `./di/bind-production` | `bindProductionNavigation(config, tracer, logger, bus, queue)` — swaps mock impls for real Payload-backed ones at app boot | -| `./di/bind-dev-seed` | `bindDevSeedNavigation(tracer, logger, bus, queue)` — replaces the default empty mock with a populated one for dev / Storybook | +| `./di/bind-production` | `bindProductionNavigation(ctx: BindProductionContext)` — swaps mock impls for real Payload-backed ones at app boot | +| `./di/bind-dev-seed` | `bindDevSeedNavigation(ctx: BindContext)` — replaces the default empty mock with a populated one for dev / Storybook | ## Use-case + controller patterns