UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

323 lines (268 loc) 14.7 kB
--- name: app-architecture description: "Build Beignet app feature slices with @beignet/core primitives: schemas, contracts, use cases, agent capabilities, app errors, ports, policies, TenantScope, context, providers, runtime integrity, domain events, listeners, jobs, schedules, tasks, notifications, outbox, mail, uploads, seeds, tests, auth helpers, audit, idempotency, storage, cache, rate limits, flags, entitlements, payments, search, webhooks, and explicit core subpath imports. Use when adding or fixing Beignet application behavior, dependency boundaries, tenant-owned repository access, app-facing ports, authorization, workflow primitives, agent entrypoints, or app-bound lib builders." --- # Beignet App Architecture Use this skill when working in an app that depends on `@beignet/core`. Read the app's `AGENTS.md`, `beignet.config.*`, and closest existing feature slice before changing code. ## Source Order 1. Follow local app instructions and configured paths. 2. Reuse the nearest feature slice's naming and helper exports. 3. Inspect installed `@beignet/core/*` exports and types when an API is unclear. 4. Read narrow Beignet docs only for the concept being changed. ## Feature Slice Normal feature code belongs here: ```txt features/<feature>/ contracts.ts schemas.ts routes.ts agent-capabilities.ts use-cases/ domain/ ports.ts policy.ts client/ components/ jobs/ listeners/ notifications/ schedules/ tasks/ uploads/ seeds/ tests/ factories/ ``` Small features do not need every file. The starter ships a lean subset; workflow folders, `lib/` builders, and central registries appear when a generator or deliberate feature change introduces that concern. Bind route declarations to the app context once in `lib/routes.ts` with `createRoutes<AppContext>()`. Feature route files should import the resulting `defineRoute` or `defineRouteGroup` helper instead of binding `AppContext` themselves. ## Core Boundaries - Contracts and schemas must be import-safe for clients and OpenAPI. - Use cases own business workflows and depend on `ctx.ports`, not infra. - Domain and use-case code must not import React, routes, providers, server runtime, or concrete adapters. - Infra implements ports and may import provider packages, SDKs, and database clients. - UI and feature client modules call server behavior through contracts and a typed client, not by importing use cases. - Server-only request context helpers may bridge Server Components to Beignet's request-scoped `AppContext`. Keep them outside `client/`, usually in `lib/server-context.ts`, and mark them with `@beignet/core/server-only`. The context factory receives `requestInfo` from the server's explicit `trustedProxy` policy; store it on request contexts when layouts need the external URL, origin, host, protocol, or client IP. Layouts may read that metadata with `ctx.auth` and `ctx.tenant` for redirects and shell state; feature data still belongs behind use cases. - Server-only React Query prefetch helpers may consume that context for hydrated client state. Use them only for direct `{ contract, useCase }` route bindings whose use-case output matches the contract success body. - Server, provider, and infra code may import `@beignet/core/server-only`; browser-reachable client helpers may import `@beignet/core/client-only` as lint markers when the app uses them. ## Contracts And Schemas Use `@beignet/core/contracts` and keep shared DTOs in `schemas.ts`. ```ts import { defineContractGroup } from "@beignet/core/contracts"; import { z } from "zod"; import { errors } from "@/features/shared/errors"; export const projectSchema = z.object({ id: z.uuid(), name: z.string().min(1), }); const projects = defineContractGroup() .namespace("projects") .prefix("/api/projects"); export const getProject = projects .get("/:id") .pathParams(z.object({ id: z.uuid() })) .errors({ ProjectNotFound: errors.ProjectNotFound }) .responses({ 200: projectSchema }); ``` If a route-backed use case throws `appError("ProjectNotFound")`, the matching contract should declare `ProjectNotFound` with `.errors(...)`. ## Use Cases Use the app-bound `useCase` builder from `lib/use-case.ts`. ```ts import { requireUserId } from "@beignet/core/ports"; import { appError } from "@/features/shared/errors"; import { projectIdInputSchema, projectSchema } from "@/features/projects/schemas"; import { useCase } from "@/lib/use-case"; export const getProjectUseCase = useCase .query("projects.get") .input(projectIdInputSchema) .output(projectSchema) .run(async ({ ctx, input }) => { const userId = requireUserId(ctx); const project = await ctx.ports.projects.findById(input.id); if (!project) throw appError("ProjectNotFound", { details: input }); await ctx.gate.authorize("projects.read", project); return project; }); ``` Use `ctx.ports.uow.transaction(...)` when writes, audit entries, events, jobs, or outbox records must commit together. ## Errors And Policies - Generated apps usually place the error catalog in `features/shared/errors.ts`. Some apps centralize it elsewhere; reuse the existing catalog. - Business authorization belongs in use cases or feature policies, not only route metadata. - Put a policy in the feature that owns the authorized resource. Cross-feature abilities may still live there when they inspect that resource. ## Ports And Providers - Feature-specific ports usually live in `features/<feature>/ports.ts`. - App-wide ports live in `ports/`. - `ports/index.ts` defines the completed `AppPorts` and transaction-port types. - `infra/port-wiring.ts` exports `initialPorts`, the app-owned values and deferred keys passed into server startup. - `bound` ports are app-owned at boot, such as gates, config, clocks, IDs, or no-op reporters. - `deferred` ports are supplied by providers at startup, such as auth, database, mail, logger, jobs, repositories, idempotency, rate limits, storage, search, payments, flags, locks, and error reporting. Do not import provider packages from contracts, use cases, routes, UI, or feature client modules. Prefer typed options objects for provider factories. Explicit first-party options override matching environment values. Treat injected SDK or database clients as caller-owned unless an option such as `closeOnStop: true` delegates shutdown to one provider instance, and preserve provider instrumentation when using a lower-level direct adapter. Tenant-owned repository methods should accept `TenantScope` instead of raw tenant IDs. Use cases derive it with `requireTenantScope(ctx)` or `createTenantScope(...)`; adapters unwrap it with `tenantScopeId(scope)` and include that storage ID in read, write, update, and delete predicates. Provider-correlation lookups such as webhook customer IDs are not tenant authorization by themselves. Resolve request tenants only from membership-backed app state or provider claims that the app explicitly treats as authoritative and current. Keep that decision behind an app-owned resolver. Never turn caller-supplied tenant IDs or unverified session fields directly into `TenantContext`. ## Workflows Feature-owned workflow definitions belong under the feature: ```txt features/<feature>/domain/events/ features/<feature>/jobs/ features/<feature>/listeners/ features/<feature>/schedules/ features/<feature>/notifications/ features/<feature>/tasks/ features/<feature>/uploads/ features/<feature>/seeds/ ``` Small existing apps may use feature-root `jobs.ts` or `schedules.ts`; follow local style unless migrating deliberately. Central registries and service contexts live under `server/`. Context-free declarations stay top-level, such as `defineEvent(...)`. Context-bound definitions should use app-bound builders created once in `lib/`, for example `createJobs<AppContext>()`, `createListeners<AppContext>()`, `createSchedules<AppContext>()`, `createNotifications<AppContext>()`, and `createTasks<AppContext>()`. Agent-callable application actions use `@beignet/core/agent-capabilities`. Create the app-bound capability and registry builders together, keep definitions feature-owned, and compose them into one explicit registry. The bound registry rejects definitions from another context or principal type. Delegate business behavior to existing use cases. The executor's app-owned context resolver must authenticate the transport principal, re-read tenant membership from an authoritative port, and call `server.createServiceContext(...)`; never trust a role from agent claims or assemble `AppContext` directly. Agent registration, grants, approval, and token verification belong to the transport integration, not the capability handler. Executor completion hooks receive validated input and output for best-effort activity projections. Error hooks receive validated input only after parsing succeeds. Keep mandatory audit writes in the workflow and never copy raw malformed input or unvalidated output into instrumentation. Register workflow artifacts explicitly: - listeners in `server/listeners.ts`, then through `registerListeners(...)` in server provider wiring - schedules in `server/schedules.ts` - tasks in `server/tasks.ts` - outbox events and jobs in `server/outbox.ts` - Inngest-backed job functions in `server/inngest.ts`; share one app-owned client with the jobs provider and mount the registry through the host's Inngest adapter - queued notification definitions and their delivery job in `server/notifications.ts`, then add that job to every worker/outbox registry - seeds through the app's seed entrypoint, usually `server/seed.ts` Notification channels run independently and return per-channel outcomes. Use the inline dispatcher for immediate delivery and the queued dispatcher/provider for one independently retryable job per channel. Preferences are an optional app-owned `NotificationPreferencesPort`; inbox persistence remains app-owned through `beignet make inbox`. For apps that opt into runtime boot checks, declare workflow artifacts in `server/runtime-integrity.ts` with `defineRuntimeManifest(...)`, compare them to the central registries with `defineRuntimeRegistries(...)`, and pass the resulting `runtimeIntegrity` check to server startup. This check is pure and serverless-safe: it proves registry coverage, not worker deployment health. Uploads are feature-owned definitions under `features/<feature>/uploads/`, then exposed through an app route with `createUploadRouter(...)` and the platform adapter. Webhooks and payment webhooks are inbound transport concerns: define reusable verification or fulfillment in feature/application modules and keep provider-specific route glue in focused route files. GitHub and Stripe signature verification use the route-bound `@beignet/webhooks-github` and `@beignet/webhooks-stripe` integrations. They do not fill ports or install lifecycle providers, so keep them out of `server/providers.ts` and provider-audit expectations. Providers should not start unbounded background loops on server boot. Expose cron routes, worker entrypoints, task runners, schedule runners, or outbox drains instead. In Next.js 15.1 and newer, an app may request one bounded outbox drain through `after()` after its Unit of Work commits. Treat this as push-assisted polling, not durable execution: the transaction-scoped outbox row owns durability and a recovery cron still owns missed callbacks, delayed messages, and retries. When tracing is installed, pass the tracing port to outbox recorders and job dispatchers. Beignet's durable event-bus, BullMQ, and Inngest adapters capture the active W3C trace carrier automatically and restore it as the consumer-span parent. Trace metadata is versioned and best-effort: malformed metadata or a capture failure must never prevent message delivery. Report failures once at the boundary that knows they are terminal. Retryable job and outbox attempts stay in instrumentation and logs. Use `tryReportException(...)` for app-owned listener or direct-runner boundaries; do not also report inside handlers when an HTTP hook, queue worker, route, or CLI runner owns the terminal failure. Attach stable identifiers instead of payloads, and remember that structured redaction does not rewrite exception messages or make `AppError.details` safe to export. Best-effort capture is bounded to one second by default. Tune `timeoutMs` when needed; use `false` only when reporting may intentionally block the boundary. ## Runtime Safety - HTTP idempotency defaults to the current actor and includes the current tenant when one exists. The default actor scope fails closed when the required identity is missing. Use `meta.scope: "global"` only for deliberately public operations whose callers should share one key namespace. - Idempotency ports must reject `complete(...)` and `fail(...)` when the fingerprint, reservation token, or in-progress state no longer matches. Never silently drop a stale mutation: callers would mistake an unstored result for a replayable one. The memory adapter throws `IdempotencyMutationError`; Drizzle dialects expose corresponding `Drizzle*IdempotencyMutationError` classes. - `formatMailAddress(...)` rejects carriage returns and line feeds before a provider builds headers; providers still own complete email-syntax validation. Mail providers make one vendor call, so put retryable delivery behind jobs or the outbox. - Keep storage keys relative and reuse `assertValidStorageKey(...)`, `normalizeStorageKeyPrefix(...)`, `prefixStorageKey(...)`, and `createStoragePublicUrl(...)` from `@beignet/core/ports` in custom adapters. Add provider-specific restrictions only after the shared validation. - Uploads are protected by default. Declare `authorize(...)` or deliberately opt into `access: "public"`; server uploads authorize each file before reading bytes for validation. Include actor, tenant, or resource ownership in object keys. - Direct-upload completion is stateless, so make `onComplete(...)` idempotent by upload ID or object key and use app-owned issuance state for single-use or revocable grants. Before app-owned completion starts, a failed server batch removes objects already written. After `onComplete(...)` starts, durable references may exist and transaction or compensation belongs to the app. ## Validation After core app-architecture changes, run from the app root: ```bash beignet lint beignet doctor --strict bun run test bun run typecheck ``` Use the app's package manager and scripts. If package-manager shims are not on PATH, use local binaries such as `./node_modules/.bin/beignet lint`.