# Beignet Source: https://www.beignetjs.com/ Beignet Beignet is experimental alpha software. The `0.0.x` package line is for early evaluation, and APIs may change between releases while the framework settles. See [stability and releases](/stability) for what that means in practice. A TypeScript framework for building REST applications that stay coherent as they grow. Beignet gives the HTTP boundary, application workflows, typed clients, OpenAPI, providers, and local tooling one shared model. Contracts are the starting point, but the goal is the whole app: routes validate requests and responses, use cases own behavior, ports keep infrastructure replaceable, and devtools show what happened while the app runs. ## Start an app ```bash bun create beignet my-app cd my-app bun install cp .env.example .env.local bun beignet db migrate bun beignet db status bun run dev ``` Then open `http://localhost:3000/sign-up` and create the first account. The starter is a working full-stack app: real auth pages, a todos feature, a typed client, and a UI shell. [Quickstart](/getting-started) walks the first session, including a first change you can watch take effect. ## What makes it different - **REST stays the public API.** Beignet adds validation, inference, clients, and OpenAPI without codegen or a custom transport protocol. - **Contracts connect the stack.** The same route definitions feed server validation, typed clients, React Query, forms, route inspection, and docs. - **Application code has a place.** Features, use cases, policies, domain code, ports, and tests follow one default structure. - **Infrastructure stays behind ports.** Providers wire databases, storage, mail, auth, queues, cache, logging, and rate limits without leaking into use cases. - **The CLI checks the app model.** Generators, route inspection, architecture linting, doctor checks, OpenAPI, and devtools all reinforce the same model. ## One contract, many surfaces ```typescript import { defineContractGroup } from "@beignet/core/contracts"; import { z } from "zod"; const todos = defineContractGroup() .namespace("todos") .prefix("/api/todos"); export const getTodo = todos .get("/:id") .pathParams(z.object({ id: z.string() })) .responses({ 200: z.object({ id: z.string(), title: z.string(), completed: z.boolean(), }) }); ``` That contract can be registered on the server, called from a typed client, included in OpenAPI output, and reused by frontend adapters. ## What to read first - [Quickstart](/getting-started) creates an app and makes the first visible change. - [Mental model](/concepts) defines the vocabulary the rest of the docs use. - [Build your first feature](/build-first-resource) generates a feature and walks one real change end to end. - [App architecture](/app-architecture) maps every folder to the decision it owns. --- # Quickstart Source: https://www.beignetjs.com/getting-started Create a Beignet app, run it, and make one change you can watch take effect. This is the first page to follow when you are new to Beignet. > **Alpha software:** Beignet is experimental alpha software. The `0.0.x` > package line is for early evaluation, and APIs may change between releases. ## Create an app Install Node.js 22.12 or newer. The commands below use Bun 1.3.14 or newer; npm, pnpm, and Yarn are also supported. ```bash bun create beignet my-app cd my-app bun install cp .env.example .env.local ``` `npm create beignet@latest`, `pnpm create beignet`, and `yarn create beignet` work the same way. See the [CLI reference](/cli) for the setup prompts and flags such as `--api` and `--db`. There is one starter: a working full-stack app with a shadcn/Tailwind UI shell, Better Auth sign-in, sign-up, and settings pages, and a user-owned todos feature backed by contracts, use cases, and Drizzle/libSQL. Generated apps include `@beignet/cli` as a dev dependency, so every command after creation runs as `bun beignet ` from the app directory. ## Prepare the database ```bash bun beignet db migrate bun beignet db status ``` The initial migration ships vendored in the scaffold's `drizzle/` folder, so `db migrate` is the only mutating database step before the first run; `db status` verifies that the target matches the checked-in history. The default starter uses SQLite — nothing to install or start. If you created the app with `--db postgres` or `--db mysql`, start the database server before `db migrate`: ```bash # Postgres 14+ docker run --rm -d -e POSTGRES_USER=beignet -e POSTGRES_PASSWORD=beignet -e POSTGRES_DB=my_app -p 5432:5432 postgres:17-alpine # MySQL 8.0+ docker run --rm -d -e MYSQL_ROOT_PASSWORD=beignet -e MYSQL_DATABASE=my_app -p 3306:3306 mysql:8.4 ``` The generated database name is the app directory with punctuation replaced by underscores (`my-app` becomes `my_app`). Keep the container name aligned with the generated `POSTGRES_DB_URL` or `MYSQL_DB_URL` in `.env.example`. ## Start the app ```bash bun run dev ``` Open `http://localhost:3000/sign-up` and create the first account. Signing up lands on the dashboard inside the app shell. The sidebar links to Dashboard, Todos, and Settings, plus Devtools and the OpenAPI document while the app runs in development. Todos is the feature to study: create a todo, complete it with the checkbox, delete it. It exercises contracts, use cases, ports, the typed client, React Query, and React Hook Form end to end. ## Make your first change The todo title rule lives in one Zod schema that the form, the API, and the OpenAPI document all share. Tighten it and watch all three react. Open `features/todos/schemas.ts` and change `CreateTodoInputSchema`: ```diff export const CreateTodoInputSchema = z.object({ - title: z.string().min(1).max(120), + title: z.string().min(3, "Give the todo at least 3 characters.").max(120), }); ``` With `bun run dev` still running: - Open the Todos page, type `hi`, and press Add. The form rejects it with your message before any request is sent, because the form helper validates with the contract's body schema in the browser. - The server enforces the same schema: any request that bypasses the form gets a `422` response with code `VALIDATION_ERROR` and the same message. - Open `http://localhost:3000/api/openapi` and find the todo create body schema: `title` now advertises `"minLength": 3`. One schema edit changed the form, the API, and the generated docs. That is the core Beignet loop: define the shape once, let every surface consume it. ## Inspect the app ```bash bun beignet routes bun beignet map bun beignet check ``` `routes` shows the HTTP surface. `map` shows how that surface connects to features, use cases, workflows, ports, providers, OpenAPI, and tests; use `bun beignet map --json --feature todos` for a focused agent-readable graph. `routes` confirms which contracts are wired to route files. `check` runs the whole validation loop in one pass: `beignet lint` (feature layers must not `beignet doctor --strict` (route, OpenAPI, and resource drift), and the app's `lint` (Biome), `typecheck`, and `test` scripts. `bun run format` applies Biome formatting. Each command also works on its own — see the [CLI reference](/cli). ## Open these files first | File or folder | Why it matters | | --- | --- | | `features/todos/contracts.ts` | Endpoint shapes used by the server, the typed client, OpenAPI, and React helpers | | `features/todos/schemas.ts` | The shared Zod schemas you just edited | | `features/todos/use-cases/` | Application behavior and validation | | `server/routes.ts` | Central route registry and OpenAPI contract list | | `server/index.ts` | Runtime composition: providers, hooks, and error mapping | | `infra/port-wiring.ts` | Default runtime wiring for the app's dependency interfaces | Next, read [Mental model](/concepts) for the vocabulary these files use, then [Build your first feature](/build-first-resource) to generate a second feature and follow one real change end to end. [App architecture](/app-architecture) has the full folder map when you want it. --- # Mental model Source: https://www.beignetjs.com/concepts Beignet uses a small, fixed vocabulary across the docs, the CLI, and generated apps. This page defines each term once. Read it after [Quickstart](/getting-started), before the deeper pages lean on these words. ## The HTTP boundary - **Contract** — describes one HTTP endpoint: method, path, parameters, request body, response statuses, and metadata. Contracts feed server validation, typed clients, React Query and form helpers, and OpenAPI generation. See [Contracts](/contracts). - **Route group** — a feature-owned list that maps contracts to use cases, defined in `features//routes.ts` and composed centrally in `server/routes.ts`. See [Routes and server](/server). - **Hook** — an ordered lifecycle function for infrastructure behavior at the HTTP boundary: auth, CORS, rate limits, logging, response shaping, and error mapping. Hooks can short-circuit before the handler, enrich context, observe responses, or map errors. See [Hooks](/hooks). - **Context** — the per-request value (`ctx`) the server builds from your context blueprint: the actor, the session, the request id, and the app's ports. Use cases and hooks read everything through it. - **Error catalog** — the app-owned set of named business errors in `features/shared/errors.ts`. Contracts declare them with `.errors(...)`, use cases throw them, and clients receive them typed. Framework-owned responses such as validation failures stay distinguishable from catalog errors. See [Errors](/errors) and [Request lifecycle](/request-lifecycle) for how response ownership works. ## Application code - **Use case** — a validated application workflow (a command or a query) with typed input and output. The same use case can run from HTTP routes, jobs, schedules, scripts, and tests. See [Use cases](/application). - **Policy** — feature-owned business authorization rules. Hooks decide who is signed in; policies decide what they may do. See [Authorization](/authorization). - **Actor, tenant, and gate** — the actor is who is acting, the tenant is the organization scope they act in, and the gate checks policies against both. All three live on the request context. - **Domain** — optional helpers for entities, value objects, and domain events when a feature wants more structure around core business concepts. See [Domain modeling](/domain). ## Dependencies - **Port** — an app-facing dependency interface, used through `ctx.ports`. Feature repositories live in `features//ports.ts`; app-wide ports live in `ports/`. See [Ports and adapters](/ports). - **Adapter** — a concrete implementation of a port, kept in `infra/` and wired in `infra/port-wiring.ts`. - **Provider** — a package that installs or replaces ports at server startup: Drizzle, Redis, Pino, Better Auth, Inngest, mail services, and more. Tests pass mock ports directly instead. See [Providers](/providers). - **Unit of Work (UoW)** — the transaction boundary at `ctx.ports.uow.transaction(...)`. It commits repository writes together and records events and outbox messages that run after commit. See [Database and transactions](/database). ## Workflow primitives Beyond the request path, Beignet names background concepts by the question they answer: - **Event** — "this fact happened"; listeners react to it. - **Job** — "do this work later or outside the request." - **Best-effort work** — "try this after the operation; losing it is acceptable." Use it for non-durable hints after the authoritative mutation succeeds. See [Ports and adapters](/ports#defer-best-effort-work). - **Schedule** — "start this workflow at this time." - **Notification** — "tell a person or team about this." - **Task** — "run this operational entrypoint", such as a backfill. - **Idempotency key** — "this logical command may arrive again." - **Outbox record** — "this side effect must commit with the database write." See [Workflow primitives](/workflows#workflow-primitives) for the decision table and the common combinations. An **agent capability** is a typed, validated entrypoint that exposes an existing application use case to an authenticated AI agent transport. Beignet owns its registry and execution boundary; integrations own agent identity, grants, and protocol transport. See [Agent capabilities](/agent-capabilities). ## API grammar Beignet APIs follow one naming rule, so new packages feel predictable: - `defineX` declares something you register: contracts, routes, ports, errors, events, jobs, schedules, policies. - `createX` builds a runtime object you call: servers, clients, providers, and the per-capability factories such as `createJobs()` that return app-bound `defineJob` builders. Builders are immutable: each chained method refines the definition and returns the next builder. Contract-aware adapters then accept the contract you already exported: ```typescript export const createPost = posts .post("/") .body(CreatePostInputSchema) .responses({ 201: PostSchema }); const endpoint = client.endpoint(createPost); const mutation = rq(createPost).mutationOptions(); const form = rhf(createPost).useForm(); ``` Define the shape once, then bind it to the runtime surface you need. That is the transfer rule behind every Beignet integration. --- # Build your first feature Source: https://www.beignetjs.com/build-first-resource The starter ships with todos. This page generates a second feature, reads the code it creates, then makes one real change and follows it from the contract to the database. Run it inside a starter app from [Quickstart](/getting-started). ## Generate the resource ```bash bun beignet make resource projects ``` The generator writes a compiling vertical slice: a contract group with list, create, read, update, and delete endpoints, shared schemas, five use cases, a repository port, an in-memory adapter for tests, a Drizzle table and adapter, a feature route group, and a starter test. It also registers the new pieces in `ports/index.ts`, `infra/port-wiring.ts`, `infra/db/repositories.ts`, and `server/routes.ts`. Because the starter persists with Drizzle, create and apply the migration for the new table: ```bash bun beignet db generate bun beignet db migrate bun beignet db status ``` Then confirm the routes are wired: ```bash bun beignet routes ``` ```txt METHOD PATH CONTRACT HANDLER GET /api/projects listProjects app/api/[[...path]]/route.ts:GET POST /api/projects createProject app/api/[[...path]]/route.ts:POST DELETE /api/projects/:id deleteProject app/api/[[...path]]/route.ts:DELETE GET /api/projects/:id getProject app/api/[[...path]]/route.ts:GET PATCH /api/projects/:id updateProject app/api/[[...path]]/route.ts:PATCH ... ``` ## Read the contract `features/projects/contracts.ts` owns the HTTP surface. Each endpoint is a builder chain that names its inputs, catalog errors, and responses: ```typescript // features/projects/contracts.ts (excerpt) const projects = defineContractGroup() .namespace("projects") .responses({ 500: ErrorResponseSchema }); export const createProject = projects .post("/api/projects") .body(CreateProjectInputSchema) .responses({ 201: ProjectSchema }); export const getProject = projects .get("/api/projects/:id") .pathParams(ProjectIdInputSchema) .errors({ ProjectNotFound: errors.ProjectNotFound }) .responses({ 200: ProjectSchema }); ``` The schemas it references live in `features/projects/schemas.ts`, so use cases, ports, tests, and the client can share them without importing the contract. ## Read the use case Each endpoint binds to a use case in `features/projects/use-cases/`. The generated `get-project.ts` is the whole pattern in one file: ```typescript // features/projects/use-cases/get-project.ts (excerpt) export const getProjectUseCase = useCase .query("projects.get") .input(ProjectIdInputSchema) .output(ProjectSchema) .run(async ({ ctx, input }) => { const project = await ctx.ports.projects.findById(input.id); if (!project) { throw appError("ProjectNotFound", { details: { id: input.id }, }); } return project; }); ``` Input and output are validated against the same schemas the contract uses. The use case throws a catalog error the contract declared, and it reaches persistence only through `ctx.ports` — never through a database import. ## Read the port `features/projects/ports.ts` is the dependency interface the use cases depend on: ```typescript // features/projects/ports.ts (excerpt) export interface ProjectRepository { list(query: ListProjectsQuery): Promise; create(input: CreateProjectInput): Promise; findById(id: string): Promise; update(input: UpdateProjectInput): Promise; delete(id: string): Promise; } ``` Two adapters implement it: `infra/projects/drizzle-project-repository.ts` for the real database and `infra/projects/in-memory-project-repository.ts` for tests. `infra/port-wiring.ts` wires the Drizzle one into `ctx.ports.projects`. `features/projects/routes.ts` then maps each contract to its use case, and the generator registered that route group in `server/routes.ts` for you. ## Make a change: add a field Projects need a description. Add it where the shape is defined, `features/projects/schemas.ts`: ```diff export const ProjectSchema = z.object({ id: z.string().uuid(), name: z.string().min(1), + description: z.string().nullable(), version: z.number().int().min(1), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }); export const CreateProjectInputSchema = z.object({ name: z.string().min(1).max(120), + description: z.string().max(500).optional(), }); ``` Now ask the compiler what else has to learn about the field: ```bash bun run typecheck ``` ```txt infra/projects/drizzle-project-repository.ts(17,2): error TS2741: Property 'description' is missing ... infra/projects/in-memory-project-repository.ts(12,2): error TS2741: Property 'description' is missing ... features/projects/tests/projects.test.ts(54,52): error TS2345: ... ``` This is the payoff of the structure: the contract, use cases, route group, and typed client all updated themselves through inference. Only the adapters behind the port — and the test data — still describe the old shape. Fix the database table in `infra/db/schema/projects.ts`: ```diff export const projects = sqliteTable("projects", { id: text("id").primaryKey(), name: text("name").notNull(), + description: text("description"), version: integer("version").notNull(), ``` Then teach both repository adapters the field. In each `create`, persist `description: input.description ?? null`, and in each `toProject` mapper, return `description` alongside the other columns. Finally add `description: null` to the seed rows in `features/projects/tests/projects.test.ts`. Migrate and verify: ```bash bun beignet db generate bun beignet db migrate bun beignet db status bun run test bun run lint bun run typecheck ``` ## See it respond With `bun run dev` running: ```bash curl -s -X POST http://localhost:3000/api/projects \ -H "content-type: application/json" \ -d '{"name":"Docs rewrite","description":"Shipped from the tutorial"}' ``` ```json { "id": "7a8569a5-1248-4c49-b109-20488bd77171", "name": "Docs rewrite", "description": "Shipped from the tutorial", "version": 1, "createdAt": "2026-06-11T23:29:58.517Z", "updatedAt": "2026-06-11T23:29:58.517Z" } ``` List endpoints filter too: `curl -s "http://localhost:3000/api/projects?name=Docs"` returns the project inside a cursor-paged envelope. The generated resource has no authorization rules yet — any caller can reach it. Give it rules in `features/projects/policy.ts` and the use cases before shipping; see [Authorization](/authorization). ## Where to go next `make resource` is the CRUD-shaped generator used here; use `bun beignet make feature ` when the concept is a workflow rather than a resource, and add `--dry-run` to either to preview the write plan first. Use `bun beignet make feature --recipe full-slice` when you want a richer reference slice with policy, client helpers, workflow artifacts, events, listener registration, jobs, and outbox wiring. Build UI for the feature under `features/projects/components/` with the typed client helpers — the todos feature in the starter is the working example. After any manual edits, `bun beignet lint` and `bun beignet doctor` confirm the app still matches its conventions. --- # App architecture Source: https://www.beignetjs.com/app-architecture A Beignet app keeps production code in a small set of predictable places, and each folder owns one kind of decision. This page is the map: what lives where, where each production concern goes, and the dependency direction `beignet lint` enforces. For the first-hour loop, use [Quickstart](/getting-started); for the guided tour of one feature, use [Build your first feature](/build-first-resource). ## What goes where | Path | Responsibility | | --- | --- | | `features//contracts.ts` | HTTP surface: method, path, params, request body, headers, responses, metadata, and catalog errors | | `features//schemas.ts` | Shared DTO and validation schemas that contracts, use cases, ports, client modules, and tests may import | | `features//routes.ts` | Feature route group that maps contracts to use cases | | `features//use-cases/` | Application workflows with input and output validation | | `features//domain/` | Feature-owned entities, value objects, and domain events | | `features//client/` | Feature-owned client data-fetching functions, React Query hooks, and other browser-safe feature helpers | | `features//components/` | Feature-owned UI | | `features//agent-capabilities.ts` | Optional agent-facing adapters over existing use cases | | `features//policy.ts` | Feature-owned authorization rules | | `features//ports.ts` | Feature-specific dependency interfaces such as repositories | | `features//notifications/`, `uploads/`, `jobs/`, `listeners/`, `schedules/`, `tasks/`, `seeds/` | Feature-owned workflow artifacts, added by generators when needed | | `features//tests/` | Feature behavior tests, with shared factories in `tests/factories/` | | `features/shared/errors.ts` | Application error catalog and route-owned error schemas | | `features/shared/domain/` | Shared-kernel domain concepts used across features | | `server/routes.ts` | Central route registry and OpenAPI contract list | | `server/context.ts` | Shared context blueprint reused by the runtime server and route tests | | `server/index.ts` | Runtime wiring: context, hooks, providers, and error mapping | | `server/agent-capabilities.ts` | Optional central agent capability registry, executor, and delegated context resolution | | `server/providers.ts` | Beignet lifecycle providers installed at server startup | | `server/listeners.ts`, `server/notifications.ts`, `server/tasks.ts`, `server/outbox.ts`, `server/schedules.ts` | App-owned workflow registries and CLI contexts for listeners, queued notification delivery, tasks, outbox draining, and schedules | | `server/runtime-integrity.ts` | Optional boot check comparing app-declared workflow artifacts against runtime registries | | `server/seed.ts` | Optional database seed entrypoint that composes feature seeds through an application service context | | `server/workers/` | Optional long-running runtime entrypoints, such as a BullMQ job worker | | `app/api/` | Thin Next.js route files that call `createApiRoute(getServer)` or focused route helpers | | `app-context.ts` | Shared request context type used by handlers, hooks, and use cases | | `ports/` | App-wide dependency interfaces shared across features | | `infra/` | Concrete adapters and default port wiring, including `infra/port-wiring.ts` | | `lib/` | Small app helpers: `env.ts`, `auth.ts`, server-only context helpers, and the `use-case.ts` builder | | `client/` | Typed Beignet client and frontend adapter factories | | `drizzle/` | Checked-in SQL migrations and Drizzle migration metadata | The CLI starter scaffolds the subset of this structure it ships: contracts, schemas, routes, use cases, components, ports, infra, server composition, and the client. Workflow-tier artifacts such as jobs, listeners, notifications, schedules, uploads, the outbox registry, and operational tasks appear when `beignet make` generators add them, along with their `lib/` builders and server registries. The database-backed starter includes `drizzle/` migration history. `beignet make seed` adds `server/seed.ts` when the app needs seed data; add `server/workers/` only when the deployment runs a long-lived worker process. `beignet doctor` treats optional workflow entrypoints as absent until the app opts in. ## Production concern map When you know what you need to build, this table says where it goes: | Concern | Put it here | Read next | | --- | --- | --- | | Endpoint shape | `features//contracts.ts` | [Contracts](/contracts) | | Feature route wiring | `features//routes.ts` | [Server](/server) | | Request routing | `server/routes.ts`, `server/index.ts`, and `app/api/` | [Routes and server](/server) | | Request lifecycle behavior | `server` hooks | [Request lifecycle](/request-lifecycle), [Hooks](/hooks) | | Business workflow | `features//use-cases/` | [Use cases](/application) | | Agent-callable workflow | `features//agent-capabilities.ts` and `server/agent-capabilities.ts` | [Agent capabilities](/agent-capabilities) | | Business authorization | `features//policy.ts` or app-owned policy helpers | [Authorization](/authorization) | | Persistence and transactions | feature repository ports plus `ctx.ports.uow.transaction(...)` | [Database and transactions](/database) | | Database migrations and seed data | checked-in `drizzle/` history plus optional `server/seed.ts` | [Database and transactions](/database) | | Audit/activity logging | `ctx.ports.audit` plus request `actor`, `tenant`, and `requestId` | [Audit and activity logging](/audit) | | Cached reads and invalidation | `ctx.ports.cache` from `infra/` or a cache provider | [Cache](/cache) | | Object storage | `ctx.ports.storage` from `infra/` or a storage provider | [Storage](/storage) | | Uploads | `features//uploads/`, `StoragePort`, and app-owned attachment records | [Uploads](/uploads) | | Domain events | `features//domain/events/`, feature listeners, Unit of Work event recorder | [Events](/events) | | Background work | `ctx.ports.jobs` and job definitions | [Jobs](/jobs) | | Long-running job workers | an explicit runtime module under `server/workers/` | [Jobs](/jobs), [Runtime recipes](/runtime-recipes) | | Scheduled work | `features//schedules/` and a cron/provider trigger | [Schedules](/schedules) | | Mail | `ctx.ports.mailer` and mail provider adapters | [Mail](/mail) | | Notifications | `features//notifications/`, optional `server/notifications.ts`, and `ctx.ports.notifications` | [Notifications](/notifications) | | Structured logging | `ctx.ports.logger` and request logging hooks | [Logging](/logging) | | Error reporting | `ctx.ports.errorReporter` and error reporting hooks | [Error reporting](/error-reporting) | | Rate limiting | contract metadata plus rate limit hooks | [Rate limiting](/rate-limiting) | | Provider startup and teardown | `server/providers.ts` | [Providers](/providers) | | Env vars and deployment config | `lib/env.ts` | [Config](/config), [Deployment](/deployment) | | App errors | `features/shared/errors.ts` and contract `.errors(...)` | [Errors](/errors) | | OpenAPI route | `app/api/openapi/route.ts` | [OpenAPI](/openapi) | | Dev-only request inspection | `app/api/devtools/[[...path]]/route.ts` | [Devtools](/devtools) | | UI data fetching | `client/`, `features//client/`, `features//components/`, React Query | [React](/react), [React Query](/react-query) | ## Dependency direction The rule is simple: transport, application behavior, and infrastructure do not own each other. A feature owns its contracts, route group, use cases, policy, domain model, UI, and feature-specific ports. App-wide ports describe dependencies shared across features. Infra implements those ports. Domain concepts that are genuinely shared across features live in `features/shared/domain/`. `beignet lint` enforces the most important directions. Domain and use cases cannot import infra, UI, route, client, provider, or framework code. Feature domain cannot import another feature's domain unless it comes from `features/shared/domain`. Route files cannot import infra or UI, and infra adapters cannot import UI, routes, server modules, or clients. Contract and schema files, plus everything reachable from `client/` or `"use client"` modules, are also checked as client-safe import graphs that must not reach server-only code; see the [CLI reference](/cli) for the full lint rules. Agent capabilities may adapt use cases, but application workflows and helpers cannot depend back on those adapters. Feature seeds compose app ports and test factories rather than concrete infra or providers. Use boundary markers as side-effect imports when a module outside a canonical server-only folder still must stay out of client bundles: ```typescript import "@beignet/core/server-only"; ``` Two placement notes that follow from the rule: - Small feature-root helper modules are allowed — for example a `features/issues/history.ts` with pure helpers shared across the feature's use cases. Keep them pure: as soon as a helper needs a dependency, give it a port. `beignet lint` follows local value imports through these helpers, so a use case cannot reach infra or another forbidden layer indirectly. - Test placement follows ownership: feature behavior tests live in `features//tests/`, while infra and server modules may keep adjacent `*.test.ts` files beside the module they exercise, such as a repository test next to its adapter. ## Feature-owned UI Product UI lives with the feature it serves, in `features//components/`. Feature-specific client data-fetching functions, React Query query/mutation options, invalidation helpers, hooks, and browser-safe helpers may live beside the feature in `features//client/`. Shared client wiring — the typed Beignet client, React Query helper, React Hook Form helper, upload client, and QueryClient provider — lives in `client/`. Feature components import those shared helpers, their feature's client helpers, and their feature's contracts, then call endpoints through React Query and form adapters. Server-only request context helpers that bridge Server Components to Beignet's request-scoped `AppContext` should live outside `client/`, usually in `lib/server-context.ts` with an `@beignet/core/server-only` marker. It is appropriate for layouts and Server Components to read request metadata, `ctx.auth`, and `ctx.tenant` from that context for redirects and shell state. Feature data and business workflows should still go through use cases. Server-only React Query prefetch helpers may live in `lib/server-react-query.ts` and consume that context. Keep the contract-derived query key from `rq(contract).queryOptions(...)`, but call the use case directly only for thin `{ contract, useCase }` route bindings whose output matches the contract success body. `components/` may contain React Server Components and Client Components. Server Components can call server-only modules when they are not reachable from a client root. Client Components and anything they import cannot reach use cases, route groups, infra adapters, server modules, provider packages, or `app-context.ts` — keep server-only workflows behind route groups and explicit server entrypoints. See [React](/react) for the component patterns. ## Server composition Features keep route wiring local in `features//routes.ts`, and the server composes them at the boundary: `server/routes.ts` owns the central route list, `server/context.ts` declares the context blueprint once for the runtime and route tests, `server/index.ts` assembles ports, providers, hooks, and routes. Next.js apps expose that server through a catch-all `app/api/[[...path]]/route.ts` and `createApiRoute(getServer)`. Standard Fetch apps pass the same central route list to `createFetchServer(...)` and mount its handler through their runtime. Both conventions give the CLI one source of truth for route inspection, generation, OpenAPI wiring, and drift checks. The code for each piece is on [Routes and server](/server). ## CLI profile and custom paths `framework: "next"` is the default CLI profile. Set `framework: "web"` when `server/index.ts` uses `createFetchServer(...)` from `@beignet/web`; route inspection and doctor then follow the central `defineRoutes(...)` registry without requiring Next.js route files. Use path overrides when the app keeps the same architecture under different paths: ```typescript import { defineConfig } from "@beignet/cli/config"; export default defineConfig({ framework: "next", paths: { appContext: "src/app-context.ts", contracts: "src/features", features: "src/features", ports: "src/ports/index.ts", portWiring: "src/infra/port-wiring.ts", routes: "src/app/api", server: "src/core/server/index.ts", listeners: "src/core/server/listeners.ts", }, }); ``` The framework profile selects the server adapter the CLI inspects, while path overrides change where it looks and writes. Neither replaces the architecture: feature contracts still define the HTTP boundary, the server still registers route groups, and application code still belongs behind use cases and ports. When `appContext` moves under a source root such as `src/app-context.ts`, client setup follows that root at `src/client/index.ts`. The config can also declare app-owned names for Beignet operational database tables when they differ from provider defaults: ```typescript import { defineConfig } from "@beignet/cli/config"; export default defineConfig({ database: { tables: { audit: "audit_events", }, schemaSources: ["@acme/db/schema"], }, }); ``` `doctor` uses those table names when it checks Drizzle-backed audit, idempotency, and outbox wiring. Runtime code still needs matching provider `tableName` options where setup statements and ports are created. Use `database.schemaSources` when Drizzle table definitions live outside the app's canonical database files, such as a shared workspace package. Entries can be app-relative files or directories, `@/` app paths, or package specifiers. Provider metadata describes standard static environment requirements. If a custom injected client receives one of those values through a platform binding or another non-env mechanism, list the exact exception under `providerAudit.ignoreRequiredEnv`. This changes `doctor` and provider audit only; runtime configuration remains enforced by the app and provider. --- # Comparisons Source: https://www.beignetjs.com/comparisons The fastest way to place Beignet: most typed-API tools stop at the HTTP boundary, and most application frameworks treat the HTTP boundary as an afterthought. Beignet is built on the bet that the two halves belong to one model — the same contract that validates a request also types the client, generates OpenAPI, and hands off to a use case that lives in an enforced application architecture with ports, providers, background work, and devtools. If you only want one of the halves, one of the tools below is probably a better fit, and this page tries to say so honestly. Beignet is pre-1.0 alpha. Every comparison below should be read with that weight: the tools here are mature, widely deployed, and have large communities. Beignet's bet is coherence, not maturity — yet. ## The short version | Tool | What it is | Reach for it when | | --- | --- | --- | | tRPC | Typed RPC for full-stack TypeScript | The API is internal and HTTP shape doesn't matter | | ts-rest | Compact typed REST contracts | You want typed REST as a library, not a framework | | oRPC | Procedures with RPC and OpenAPI transports | You want procedure ergonomics with broad runtime adapters | | Hono | Minimal multi-runtime web framework | You want a fast router and will own the architecture yourself | | NestJS | Decorator-based Node application framework | Your team wants Angular-style structure and a huge module ecosystem | | AdonisJS | TypeScript-first MVC framework | You want a Laravel-style monolith in TypeScript on a long-running server | | Laravel / Rails | Batteries-included app frameworks | You want maximum maturity and TypeScript isn't a requirement | ## tRPC tRPC gives full-stack TypeScript teams typed server procedures that feel like local function calls. It deliberately hides HTTP — methods, paths, and status codes are transport details — which is exactly right for internal APIs where both ends are TypeScript and ship together. Beignet keeps the opposite bet: REST is the public API. Methods, paths, headers, status codes, and error shapes are explicit in the contract, so the same API serves your own frontend, non-TypeScript consumers, OpenAPI docs, and anything else that speaks HTTP — without a second API layer bolted on later. **Choose tRPC when** the API is private to one TypeScript codebase, you want procedure-call ergonomics with middleware-chained context, or you need subscriptions and WebSockets, which Beignet does not have. **Choose Beignet when** the HTTP surface is a product requirement — public APIs, mobile clients, webhooks, OpenAPI — or when you want the framework to also answer what happens *behind* the procedure: where use cases, policies, jobs, and infrastructure live. ## ts-rest ts-rest is the closest comparison at the contract layer: a compact object DSL for typed REST contracts shared between server and client, with response validation, React Query hooks, and OpenAPI generation. As a focused library it is easy to adopt incrementally inside an existing app, and its nested router contract shape is very discoverable. Beignet's contract layer covers the same ground with a builder grammar (`defineContractGroup().namespace(...).prefix(...)`), but the differentiator is everything after the contract: a canonical app structure, use cases with validated inputs and outputs, ports and providers for infrastructure, an error catalog with route-owned errors, a workflow tier (events, jobs, schedules, tasks, notifications, uploads, a transactional outbox, idempotency), generators, architecture linting, doctor checks, and a devtools dashboard. **Choose ts-rest when** you want typed REST inside an architecture you already own, with broader server adapter coverage (Express, Nest, Fastify, serverless) today. **Choose Beignet when** you are starting an application and want the typed REST boundary *and* the application model behind it to come from one tool that enforces its own conventions. ## oRPC oRPC is a strong philosophical neighbor: no-codegen type safety, Standard Schema support, typed errors, and both RPC and OpenAPI transports, with wide adapter coverage across runtimes and frameworks. Its procedure builders and typed middleware are excellent at the transport layer. The trade is the same as ts-rest but with an RPC accent: oRPC is a procedure layer you bring into your own architecture, and its OpenAPI story sits alongside an RPC protocol rather than REST being the only shape. Beignet has no RPC mode — REST is the boundary — and spends its complexity budget on the application framework instead: feature folders, dependency-direction linting, provider-backed infrastructure, background work, and tooling that keeps generated apps conformant. **Choose oRPC when** you want procedure composition with maximum runtime flexibility and are happy owning the rest of the stack. **Choose Beignet when** you want the REST contract and the production app structure to be the same opinionated system. ## Hono Hono is a fast, minimal web framework that runs everywhere fetch does, with typed routes via its RPC client and OpenAPI support through extensions. It is a router with excellent ergonomics — and deliberately not an application framework. Architecture, validation conventions, background work, and infrastructure wiring are yours to design. Beignet sits a full layer up. Contracts are schema-first rather than route-handler-first, and the framework owns the answers Hono leaves open: where business logic lives, how infrastructure stays swappable, how jobs and events get reliability guarantees, and how drift gets caught (`beignet lint`, `beignet doctor`). Beignet's server core is runtime-portable through the same fetch standard — Next.js is the first-class adapter, and `@beignet/web` serves Cloudflare Workers, Bun, Deno, and Node fetch servers. **Choose Hono when** you want a minimal, very fast HTTP layer and intend to build your own conventions on top. **Choose Beignet when** you want the conventions included and enforced, and REST contracts as the source of truth rather than handler inference. ## NestJS NestJS is the most established Node application framework: modules, decorators, dependency injection, guards, pipes, interceptors, and an enormous ecosystem. It shares Beignet's belief that applications need structure, and it has a decade of production hardening Beignet cannot claim. The differences are philosophical. Nest's structure comes from class decorators and a runtime DI container; types describe the code but the HTTP contract lives in decorator metadata, and end-to-end client typing or OpenAPI fidelity require additional layers. Beignet is functions and inference all the way down — the contract is a value, the client and OpenAPI derive from it, and architecture rules are enforced by static linting rather than a container. Beignet's ports are plain interfaces wired explicitly in `infra/`, not injected tokens. **Choose NestJS when** you want a mature, hireable, batteries-available framework and your team likes explicit OOP structure. **Choose Beignet when** end-to-end type inference from a single contract matters more than ecosystem breadth, and you prefer functional composition to decorators and DI containers. ## AdonisJS AdonisJS is the closest framework to Beignet's ambition: TypeScript-first, batteries included, with an ORM, auth, validation, mail, events, a CLI, and a testing story — Laravel's philosophy executed natively in TypeScript. If the question is "a real application framework, in TypeScript," Adonis is the incumbent answer, with years of production use behind it. The split is in where types come from and where the app runs. Adonis is MVC: routes point at controllers, validators guard inputs, and the HTTP contract is an emergent property of handler code — typed clients and OpenAPI need additional packages and generation steps. Beignet inverts that: the contract is a standalone value, and the server, client, React Query options, forms, and OpenAPI all infer from it with no generation step. Architecture differs the same way — Adonis structures the app through an IoC container and service providers, where Beignet uses plain-interface ports wired explicitly in `infra/` and enforced by static linting. And Adonis assumes a long-running Node server, while Beignet is built for the Next.js/serverless deployment model first. **Choose AdonisJS when** you want a proven TypeScript monolith with the batteries already attached, you deploy long-running servers, and contract-derived client types are a nice-to-have rather than the point. **Choose Beignet when** the contract-first boundary and no-codegen inference across the whole stack are the point, or your deployment target is serverless/Next.js rather than a persistent Node process. ## Laravel and Rails Laravel and Rails are the high-water mark for application frameworks: ORM, migrations, queues, mail, notifications, scheduling, policies, file storage, testing culture, deployment ecosystem, and conventions deep enough that any experienced developer can navigate any codebase. Beignet borrows their best idea — a canonical place for everything — openly. What they cannot offer is the TypeScript contract story: one definition that types the server, the client, the forms layer, and the OpenAPI document with no codegen step. Beignet's equivalents of the application tier exist and are real — Drizzle-backed persistence across SQLite, Postgres, and MySQL with a unit of work, transactional outbox, idempotency storage, typed jobs, events, schedules, tasks, notifications, mail, uploads, policies — but they are years younger, and the ecosystem of packages, hosting recipes, and answered questions is a fraction of the size. **Choose Laravel or Rails when** maturity, ecosystem, and hiring outweigh language choice, or the app is server-rendered with modest API needs. **Choose Beignet when** the stack is TypeScript end to end and you want framework-grade structure without giving up typed contracts as the source of truth. ## What Beignet deliberately doesn't do Honest scope, stated once: - No RPC protocol, no subscriptions, no WebSockets — REST and standard `Response` are the boundary. - OpenAPI generation requires Zod schemas; runtime validation accepts any Standard Schema library. - Next.js is the first-class server adapter; other runtimes go through the fetch adapter in `@beignet/web` rather than per-framework adapters. - Pre-1.0: APIs can change between `0.0.x` releases while the framework settles. If those constraints fit, the rest of the docs show what the coherence buys: start with the [mental model](/concepts) or [build your first feature](/build-first-resource). --- # Contracts Source: https://www.beignetjs.com/contracts A contract is the single source of truth for an API endpoint. It describes the HTTP method, path, parameters, response shape, and error cases — all in TypeScript. Contracts live at `@beignet/core/contracts`. Beignet intentionally avoids a root `@beignet/core` entrypoint so imports name the framework area they depend on. ## Contract groups Use `defineContractGroup` to define a group of related contracts. Groups can share configuration like metadata and shared response schemas. ```typescript import { defineContract, defineContractGroup, defineQueryTransport, query, } from "@beignet/core/contracts"; import { z } from "zod"; const TodoSchema = z.object({ id: z.string(), title: z.string(), completed: z.boolean(), }); const CreateTodoSchema = z.object({ title: z.string().min(1), completed: z.boolean().optional(), }); const todos = defineContractGroup() .namespace("todos") .prefix("/api/todos") .meta({ auth: "required" }) .headers(z.object({ authorization: z.string().startsWith("Bearer "), })); ``` The namespace is the resource identity for the group. It is used for OpenAPI tags, generated contract names, and React Query cache-key grouping. The prefix is composed into each contract path. Metadata and shared request header schemas are inherited by all contracts in the group. ## Defining contracts Chain methods to describe the endpoint shape. Most Beignet APIs accept the contract builder directly. Use `.config`, `.schema`, `.metadata`, or `.responseSchemas` only when you are writing integration code, tests, generators, or advanced introspection. ### GET with path parameters ```typescript export const getTodo = todos .get("/:id") .pathParams(z.object({ id: z.string() })) .responses({ 200: TodoSchema }); ``` The client and OpenAPI generator can infer required path argument keys from literal path templates. Add `.pathParams(...)` when you want runtime validation, coercion, richer OpenAPI schemas, or parameter descriptions. Beignet contract paths intentionally support only concrete segments and single-segment params such as `:id` and `[id]`. Catch-all framework route files such as `app/api/[[...path]]/route.ts` are adapter glue for exposing `createApiRoute(getServer)`; they do not mean individual contracts should use catch-all patterns such as `/files/[...path]`. Keep catch-all or prefix dispatch in the host adapter and keep contracts on explicit resource paths. Request bodies are supported for `POST`, `PUT`, and `PATCH` contracts only. ### Request headers ```typescript export const getProtectedTodo = todos .get("/:id") .pathParams(z.object({ id: z.string() })) .headers(z.object({ "x-api-version": z.literal("2026-01-01").optional(), })) .responses({ 200: TodoSchema }); ``` Use `.headers(...)` for request headers that are part of the endpoint contract. Declare header keys in lowercase; server and client runtime matching is case-insensitive. ### GET with query parameters ```typescript const listTodosQueryTransport = defineQueryTransport({ completed: query.boolean(), limit: query.integer(), offset: query.integer(), tags: query.array(query.string()), filter: query.deepObject({ owner: query.string() }), }); export const listTodos = todos .get("/") .query( z.object({ completed: z.boolean().optional(), limit: z.number().int().min(1).max(100).optional(), offset: z.number().int().min(0).optional(), tags: z.array(z.string()).optional(), filter: z.object({ owner: z.string().optional() }).optional(), }), listTodosQueryTransport, ) .responses({ 200: z.object({ items: z.array(TodoSchema), page: z.object({ kind: z.literal("offset"), limit: z.number(), offset: z.number(), total: z.number(), hasMore: z.boolean(), }), }) }); ``` The Standard Schema and query transport have separate jobs. The schema defines the logical input, defaults, validation, and transforms. The transport defines how each field is encoded in a URL. When a schema exposes a concrete input type, Beignet checks its field names and value types against the transport at compile time. The same transport then drives client encoding, server decoding, and OpenAPI serialization. Primitive fields use ordinary form values, scalar arrays repeat the parameter name, and one-level objects use `deepObject` bracket names. The example value `{ tags: ["bug", "urgent"], filter: { owner: "user_1" } }` becomes `?tags=bug&tags=urgent&filter[owner]=user_1`. Use `query.string()`, `query.number()`, `query.integer()` for JavaScript safe integers, `query.boolean()`, `query.dateTime()`, or `query.date()` for scalar values. `query.dateTime()` carries an RFC 3339 string; `query.date()` carries a JavaScript `Date` and encodes it as RFC 3339. Arrays may contain scalars, and `deepObject` values must be flat. Unsupported nesting, repeated scalar values, and undeclared object fields fail deterministically. Empty arrays and objects are omitted by default because OpenAPI form encoding cannot distinguish them from absent parameters. Set `{ empty: "preserve" }` on `query.array(...)` or `query.deepObject(...)` only when Beignet typed clients must preserve the distinction. That option uses a versioned Beignet extension; ordinary OpenAPI clients still treat empty and omitted collections as the same value. The typed client serializes logical query input values. When `validateInput: true` is enabled, it also validates that input without using a schema transform's output as the wire value. The server decodes the URL once and then runs the Standard Schema, so route handlers receive schema outputs after defaults and transforms. Do not use a schema transform to define an HTTP wire format; declare that format in the query transport instead. ### POST with a request body ```typescript export const createTodo = todos .post("/") .body(CreateTodoSchema) .responses({ 201: TodoSchema }); ``` ### PATCH with path and body ```typescript export const updateTodo = todos .patch("/:id") .pathParams(z.object({ id: z.string() })) .body(z.object({ title: z.string().optional(), completed: z.boolean().optional(), })) .responses({ 200: TodoSchema }); ``` ### DELETE ```typescript export const deleteTodo = todos .delete("/:id") .pathParams(z.object({ id: z.string() })) .responses({ 204: null }); ``` Use `null` for void responses like `204 No Content`. ## Auto-generated names If you do not pass a custom `name`, Beignet generates one from the HTTP method and full path. ```typescript defineContract({ method: "GET", path: "/users/:id" }).name; // "getUsersById" defineContract({ method: "POST", path: "/api/todos" }).name; // "createTodos" ``` The generated name ignores a leading `/api`, includes path parameters as `By...`, and is reused by downstream integrations like React Query and OpenAPI. Pass `name` explicitly when you want a custom identifier. ## Path prefixes Use `.prefix(...)` on contract groups to compose shared URL segments once: ```typescript const api = defineContractGroup().prefix("/api/v1"); const todos = api .namespace("todos") .prefix("/todos"); export const listTodos = todos.get("/"); // GET /api/v1/todos export const getTodo = todos.get("/:id"); // GET /api/v1/todos/:id ``` Prefixes compose immutably and normalize boundary slashes. `namespace()` controls resource identity for names, tags, and cache keys; `prefix()` controls URL paths. ## Versioning and deprecation Use path-prefixed contract groups as the default versioning strategy for an external API. Paths remain explicit in contracts, logs, OpenAPI documents, and gateway rules: ```typescript const v1 = defineContractGroup() .namespace("legacyTodos") .prefix("/api") .prefix("/v1/todos") .deprecated({ since: "2026-07-11T00:00:00Z", sunset: "2027-01-01T00:00:00Z", reason: "Use the current todos collection.", replacement: "/api/todos", documentation: "https://docs.example.com/migrations/todos-v1", }); export const listTodosV1 = v1 .get("/", "list") .responses({ 200: z.array(TodoSchema) }); ``` `.deprecated(...)` is available on a contract or a group. It requires a UTC ISO 8601 `since` timestamp and accepts an optional `sunset`, reason, replacement URI, and absolute documentation URL. Beignet marks the OpenAPI operation deprecated and sends standard `Deprecation`, `Sunset`, and deprecation-documentation `Link` headers while the route remains served. Header- or media-type-based version negotiation stays app-owned. Use it only when an existing public API already requires that model; Beignet does not hide multiple request or response shapes behind one contract. ## Responses and catalog errors Define success responses with `.responses(...)`. Prefer `.errors(...)` for expected route-owned business failures. ```typescript export const getTodo = todos .get("/:id") .pathParams(z.object({ id: z.string() })) .responses({ 200: TodoSchema }) .errors({ TodoNotFound: errors.TodoNotFound }); ``` Route-owned error responses can still use any schema you declare with `.responses()` when you need a custom body shape: ```typescript export const importTodos = todos .post("/import") .body(ImportTodosSchema) .responses({ 202: ImportJobSchema, 422: z.object({ code: z.literal("IMPORT_INVALID"), message: z.string(), details: z.object({ errors: z.array(z.string()), }), }), }); ``` Shared response schemas defined on a contract group are inherited by all contracts. Per-contract responses are merged with group responses. Any non-empty response map is treated as a response contract. Include successful statuses such as `200` or `201` alongside error statuses; use `responses: {}` only when you want to skip response validation. Catalog errors declared with `.errors()` use Beignet's standard `{ code, message, details?, requestId? }` envelope automatically. `.errors()` declarations merge: catalog errors declared on a contract group combine with route-level `.errors(...)`, so each contract carries the union. Later declarations win when the same catalog key is declared twice. Keep application error identity in the catalog, then declare expected catalog errors on each route: ```typescript export const errors = defineErrors({ TodoNotFound: { code: "TODO_NOT_FOUND", status: 404, message: "Todo not found", details: z.object({ id: z.string() }), }, }); export const getTodo = todos .get("/:id") .responses({ 200: TodoSchema }) .errors({ TodoNotFound: errors.TodoNotFound }); ``` ## Metadata Attach metadata to contracts for use in server hooks. ```typescript const DataSchema = z.object({ id: z.string(), value: z.string(), }); const PaymentSchema = z.object({ amount: z.number().positive(), currency: z.string(), }); const PaymentResultSchema = z.object({ id: z.string(), status: z.enum(["pending", "succeeded", "failed"]), }); // Authentication export const getProtectedData = todos .get("/protected") .meta({ auth: "required" }) .responses({ 200: DataSchema }); // Rate limiting export const createTodo = todos .post("/") .meta({ rateLimit: { max: 10, windowSec: 60 } }) .body(CreateTodoSchema) .responses({ 201: TodoSchema }); // Idempotency const payments = defineContractGroup().namespace("payments"); export const createPayment = payments .post("/api/payments") .meta({ idempotency: { required: true, header: "idempotency-key", scope: "actor-tenant", ttlSec: 60 * 60 * 24, }, }) .body(PaymentSchema) .responses({ 201: PaymentResultSchema }); ``` Metadata is available to [hooks](/hooks) via `contract.metadata`; Beignet also ships first-party hooks and helpers for concerns such as [rate limiting](/rate-limiting) and [idempotency](/idempotency). Top-level metadata uses last-write-wins semantics. The `openapi` namespace is the exception: `.meta({ openapi: ... })` and `.openapi(...)` merge operation metadata one field deep, so shared group metadata composes with contract-level documentation. Later values replace matching OpenAPI fields; nested objects and arrays are replaced rather than recursively merged. ## Schema libraries Beignet works with any [Standard Schema](https://github.com/standard-schema/standard-schema) library for runtime validation in contracts, the server, and the client. OpenAPI includes Zod support by default and accepts custom schema converters and introspectors for other libraries. Opaque path parameter schemas fall back to required string parameters derived from the literal path template. Query schemas remain portable because their HTTP representation comes from `defineQueryTransport(...)`; OpenAPI still needs an introspector to document their requiredness and constraints. ### Zod ```typescript import { z } from "zod"; const TodoSchema = z.object({ id: z.string(), title: z.string(), completed: z.boolean(), }); ``` ### Valibot ```typescript import * as v from "valibot"; const TodoSchema = v.object({ id: v.string(), title: v.string(), completed: v.boolean(), }); ``` ### ArkType ```typescript import { type } from "arktype"; const TodoSchema = type({ id: "string", title: "string", completed: "boolean", }); ``` ## Introspection Contracts expose their path and schemas for runtime inspection. ```typescript contract.path // "/api/todos/:id" contract.schema.pathParams // Standard Schema or null contract.schema.query // Standard Schema or null contract.schema.body // Standard Schema or null contract.schema.responses // { 200: StandardSchema, 404: StandardSchema, ... } contract.config.queryTransport // QueryTransport or null ``` --- # Use cases Source: https://www.beignetjs.com/application The `@beignet/core/application` subpath provides a fluent builder for use cases — the core business operations in your application. ```bash bun add @beignet/core ``` ## Route handler or use case? Keep a workflow in a route handler when the endpoint is only transport glue: health checks, simple adapter responses, or request normalization with no business rules. Move the workflow into a use case when it touches ports, owns business decisions, needs direct tests, or may run from HTTP, jobs, scripts, events, or tests. Use [Workflow primitives](/workflows#workflow-primitives) when deciding whether a use case should record an event, dispatch a job, send a notification, protect itself with idempotency, or write to the outbox. For multi-step lifecycle flows with durable state, use the [workflow/state-machine pattern](/workflows): keep state in repositories, put each transition in a command use case, and use events/outbox for post-commit work. ## Creating a use case builder Start by creating a builder scoped to your application's context type: ```typescript import { createUseCase } from "@beignet/core/application"; import type { AppContext } from "@/app-context"; const useCase = createUseCase(); ``` Use cases validate their input before the handler runs and validate the returned output before resolving. This makes them safe to call from HTTP routes, jobs, scripts, tests, and event handlers. ```typescript const useCase = createUseCase({ validate: true, // default }); const useCaseMetadataOnly = createUseCase({ validate: false, }); ``` ## Commands and queries Use `.command()` for operations that change state and `.query()` for read-only operations: ```typescript import { z } from "zod"; const createTodo = useCase .command("todos.create") .input(z.object({ title: z.string() })) .output(z.object({ id: z.string(), title: z.string(), completed: z.boolean() })) .run(async ({ input, ctx }) => { return ctx.ports.todos.create(input); }); const getTodo = useCase .query("todos.get") .input(z.object({ id: z.string() })) .output(z.object({ id: z.string(), title: z.string(), completed: z.boolean() })) .run(async ({ input, ctx }) => { return ctx.ports.todos.findById(input.id); }); ``` Inside `.run(...)`, `input` is the parsed schema output. Schema defaults, coercions, and transforms have already been applied. ## Reusing schemas in contracts Application DTO schemas that are shared by contracts, use cases, ports, tests, or client code should live in `features//schemas.ts`. Contracts are client-safe roots, so they should import shared schemas directly instead of importing use cases to reach `.inputSchema` or `.outputSchema`. ```typescript // features/todos/schemas.ts import { z } from "zod"; export const CreateTodoInputSchema = z.object({ title: z.string().min(1) }); export const TodoSchema = z.object({ id: z.string(), title: z.string(), completed: z.boolean(), }); ``` ```typescript // features/todos/contracts.ts export const createTodoContract = todos .post("/api/todos") .body(CreateTodoInputSchema) .responses({ 201: TodoSchema, }); ``` Keep explicit contract schemas when the HTTP shape differs from the application input or output, such as headers, path params, multipart uploads, or transport wrappers. ## Emitting domain events Use cases can declare which domain events they may emit. The handler receives an `events` helper scoped to `.emits(...)`, so undeclared events are caught by TypeScript and by runtime checks: ```typescript import { defineEvent } from "@beignet/core/events"; const todoCreated = defineEvent("todo.created", { payload: z.object({ id: z.string(), title: z.string() }), }); const createTodo = useCase .command("todos.create") .input(z.object({ title: z.string() })) .output(z.object({ id: z.string(), title: z.string() })) .emits([todoCreated]) .run(async ({ input, ctx, events }) => { const todo = await ctx.ports.uow.transaction(async (tx) => { const created = await tx.todos.create(input); await events.record(tx.events, todoCreated, { id: created.id, title: created.title, }); return created; }); return todo; }); ``` ## Transactions and buffered events Use cases are the recommended place to define transaction boundaries. Keep the Unit of Work itself as an app-owned port so the database adapter can decide how to create transaction-scoped repositories. ```typescript import type { DomainEventRecorderPort, UnitOfWorkPort, } from "@beignet/core/ports"; type TodoTransactionPorts = { todos: TodoRepositoryPort; events: DomainEventRecorderPort; }; type AppPorts = { todos: TodoRepositoryPort; eventBus: EventBusPort; uow: UnitOfWorkPort; }; ``` Inside the use case, call transaction-scoped ports through `tx`. Record domain events during the transaction and let the adapter validate, parse, and publish them after commit. ```typescript const createTodo = useCase .command("todos.create") .input(CreateTodoInput) .output(TodoOutput) .emits([todoCreated]) .run(async ({ ctx, input, events }) => { return ctx.ports.uow.transaction(async (tx) => { const todo = await tx.todos.create(input); await events.record(tx.events, todoCreated, { todoId: todo.id, }); return todo; }); }); ``` This avoids publishing events, sending jobs, or triggering side effects when the database work rolls back. For tests and in-memory adapters, `createNoopUnitOfWork(...)` gives the same shape without pretending to create a real database transaction. After-commit hooks run only after successful work; if they fail, rollback hooks do not run because the work has already completed. ## Instrumentation Use cases are instrumented by default. Each run resolves the provider instrumentation port from `ctx.ports` and records `usecase` events for `start`, `end`, and `error` phases, plus a correlated `error` event for failed runs. Without an installed sink, runs stay silent. When `ctx.ports.tracing` is installed, the same run executes inside an active `beignet.use_case ` span. It inherits the current request or workflow span and stays active across asynchronous work. See [OpenTelemetry](/observability) for provider setup and propagation limits. ```typescript // Default: instrumented automatically. export const useCase = createUseCase(); // Opt out of built-in instrumentation. const quietUseCase = createUseCase({ instrumentation: false }); ``` Pass an `onRun` hook to observe use case execution with app-owned logic. It runs in addition to the built-in instrumentation: ```typescript const useCase = createUseCase({ onRun(event) { // event.phase: "start" | "end" | "error" // event.name, event.kind, event.durationMs console.log(`[${event.phase}] ${event.name} (${event.durationMs}ms)`); }, }); ``` Validation failures are reported through the same hook as `phase: "error"`. The observer is best-effort: synchronous throws and rejected promises are ignored, so observability code cannot change the use-case result or mask its original error. ## Validation errors Use case validation failures throw `UseCaseValidationError`: ```typescript import { UseCaseValidationError } from "@beignet/core/application"; try { await createTodo.run({ ctx, input }); } catch (error) { if (error instanceof UseCaseValidationError) { error.useCaseName; error.phase; // "input" | "output" error.issues; } } ``` ## Testing use cases Use `createUseCaseTester` to centralize context setup and run use cases with typed inputs: ```typescript import { createUseCaseTester } from "@beignet/core/application"; import { createTodo } from "@/features/todos/use-cases"; const tester = createUseCaseTester(() => ({ ports: { todos: createInMemoryTodoRepository() }, requestId: "test-request", })); const result = await tester.run(createTodo, { title: "First todo" }); ``` Use a context factory when tests mutate in-memory ports or request-scoped state. Call `tester.ctx()` when multiple use cases in the same test need to share one context instance. ## Authorizing use cases Use hooks for HTTP boundary authentication, such as rejecting routes that require a signed-in request before parsing business input. Put business authorization in use cases so the same rule runs when the workflow is called from HTTP, jobs, scripts, events, or tests. Read [Authentication](/authentication) for session and hook wiring. Read [Authorization](/authorization) for policy placement and testing. ```typescript import { appError } from "@/features/shared/errors"; const updatePost = useCase .command("posts.update") .input(UpdatePostInput) .output(PostOutput) .run(async ({ ctx, input }) => { const post = await ctx.ports.posts.findById(input.id); if (!post) { throw appError("PostNotFound", { details: { id: input.id } }); } await ctx.gate.authorize("posts.update", post); return ctx.ports.posts.update(input.id, input); }); ``` Policies are typed app modules registered with `createGate(...)`. Move repeated ownership, role, tenant, plan, or resource-state rules into feature policy files declared with `definePolicy(...)`. See [Authorization](/authorization) for writing and testing policies. ## Wiring into routes Routes bind contracts directly to use cases: ```typescript import { defineRouteGroup } from "@/lib/routes"; import { createTodo } from "@/features/todos/use-cases"; export const todoRoutes = defineRouteGroup({ name: "todos", routes: [{ contract: contracts.createTodo, useCase: createTodo }], }); ``` The server validates the request against the contract, maps the parsed parts to the use case input, runs the use case, and returns its output with the contract's sole declared 2xx status. A sole path, query, or body schema is passed through unchanged when no additional path, query, or object body values are present, so scalar and array request bodies retain their shape. Multiple sources use the documented object merge. The server owns these input and boundary-parse rules; see [Route registration](/server#route-registration). Full `handle` routes remain available for responses the binder does not cover; call `useCase.run({ ctx, input })` yourself there. --- # Agent capabilities Source: https://www.beignetjs.com/agent-capabilities Agent capabilities expose narrow application actions to authenticated AI agent transports. They are another entrypoint into ordinary Beignet use cases, not a second business-logic layer or an agent orchestration framework. Beignet owns the transport-neutral definition, validation, application context, execution, tracing, and instrumentation. An integration such as Better Auth Agent Auth owns agent registration, tokens, grants, constraints, and approval. ## Define the agent-facing contract Bind the app context and verified principal types once: ```typescript // lib/agent-capabilities.ts import { createAgentCapabilities } from "@beignet/core/agent-capabilities"; import type { AppContext } from "@/app-context"; export type AgentPrincipal = { agentId: string; userId: string; }; export const { defineAgentCapability, defineAgentCapabilityRegistry } = createAgentCapabilities(); ``` Keep definitions near the feature and delegate behavior to existing use cases. The capability schema may differ from an HTTP contract when an agent needs a curated input or output: ```typescript // features/issues/agent-capabilities.ts import { z } from "zod"; import { createIssueUseCase } from "@/features/issues/use-cases"; import { defineAgentCapability } from "@/lib/agent-capabilities"; export const createIssueCapability = defineAgentCapability("issues.create", { description: "Create an issue in one workspace.", input: z.object({ workspaceId: z.string().min(1), title: z.string().min(1).max(200), }), output: z.object({ id: z.string(), title: z.string() }), async handle({ ctx, input }) { const { workspaceId: _workspaceId, ...useCaseInput } = input; return createIssueUseCase.run({ ctx, input: useCaseInput }); }, }); ``` Both schemas are runtime boundaries. Arguments are parsed before context construction; handler results are parsed before they reach the transport. ## Register and execute capabilities Compose definitions explicitly and create one executor. The context resolver is the security boundary between a verified transport identity and your application's authorization state: ```typescript // server/agent-capabilities.ts import { createAgentCapabilityExecutor, } from "@beignet/core/agent-capabilities"; import { appError } from "@/features/shared/errors"; import { createIssueCapability } from "@/features/issues/agent-capabilities"; import { defineAgentCapabilityRegistry } from "@/lib/agent-capabilities"; export const agentCapabilityRegistry = defineAgentCapabilityRegistry([createIssueCapability]); export const agentCapabilityExecutor = createAgentCapabilityExecutor({ registry: agentCapabilityRegistry, async createContext({ principal, input }) { const server = await import("@/server").then(({ getServer }) => getServer()); const membership = await server.ports.members.findMembership({ workspaceId: input.workspaceId, userId: principal.userId, }); if (!membership) throw appError("NotAWorkspaceMember"); return server.createServiceContext({ asUser: { id: principal.userId, role: membership.role }, tenantId: input.workspaceId, }); }, }); ``` Never accept a tenant role from agent claims. Re-read membership from an authoritative app port, then use `createServiceContext(...)` so the server attaches the same gate and correlation state used by other entrypoints. See [Routes and server](/server#acting-as-a-user-from-non-http-entrypoints). `execute(...)` is typed by literal capability name for tests and in-process callers. Protocol adapters use `executeDynamic(...)` with an `authorize(...)` callback when transport authorization must inspect parsed input. Core invokes that callback against the exact parsed value before context construction, then uses the same value for the context and handler. ## Better Auth Agent Auth Install the bridge beside Better Auth's Agent Auth plugin: ```bash bun add @beignet/agent-auth-better-auth \ @better-auth/agent-auth@0.6.2 better-auth@1.6.25 ``` The bridge supports Agent Auth `>=0.6.2 <0.7.0` and Better Auth `>=1.4.0 <1.7.0`. The command pins the versions Beignet validates together. ```typescript import { agentAuth } from "@better-auth/agent-auth"; import { createBetterAuthAgentCapabilityAdapter } from "@beignet/agent-auth-better-auth"; import { APIError } from "better-auth/api"; import { agentCapabilityExecutor, agentCapabilityRegistry, } from "@/server/agent-capabilities"; const capabilityBridge = createBetterAuthAgentCapabilityAdapter({ registry: agentCapabilityRegistry, executor: agentCapabilityExecutor, principal({ agentSession }) { if (!agentSession.userId) { throw new APIError("FORBIDDEN", { message: "A delegated user is required.", }); } return { agentId: agentSession.agentId, userId: agentSession.userId, }; }, metadata: { "issues.create": { approvalStrength: "session", requiredConstraints: ["workspaceId"], }, }, }); agentAuth({ ...capabilityBridge, providerName: "my-app", modes: ["delegated"], }); ``` After registering `agentAuth(...)`, regenerate the Better Auth schema for your database adapter and apply the resulting migration. Repeat this step after upgrading Agent Auth or changing Better Auth plugins that add or modify database fields, so registration, grant, and protocol state match the runtime. When auth initialization and server composition depend on each other, pass a lazy executor instead of creating a proxy object in the app: ```typescript import { agentCapabilityRegistry } from "@/lib/agent-capability-registry"; const capabilityBridge = createBetterAuthAgentCapabilityAdapter({ registry: agentCapabilityRegistry, executor: () => import("@/server/agent-capabilities").then( ({ agentCapabilityExecutor }) => agentCapabilityExecutor, ), principal({ agentSession }) { if (!agentSession.userId) { throw new APIError("FORBIDDEN", { message: "A delegated user is required.", }); } return { agentId: agentSession.agentId, userId: agentSession.userId }; }, }); ``` Keep the transport-safe registry in a module that does not import server composition; only the executor needs the dynamic server import. The first successful resolution is memoized for the warm module instance, including concurrent execution. Failed initialization is retried on the next request. The bridge converts Zod schemas to JSON Schema during setup. Apps using another Standard Schema library pass a `SchemaConverter`. Unsupported schemas fail at boot with the capability name rather than failing on the first request. Agent Auth evaluates grant constraints against raw arguments before calling the bridge. Beignet then verifies that the active grant still contains every field currently declared in `requiredConstraints`. Tightening capability metadata therefore makes older broad grants unusable at execution time. Revoke or migrate those stale grants so agents can request replacement grants with the newly required scope; Beignet does not mutate Agent Auth's persistence. If validation changes a field constrained by the active grant, Beignet rejects the invocation before context construction or application work begins. Keep constrained identifiers and amounts non-transforming; unconstrained fields may still use normalization and other schema transformations. The bridge snapshots active constrained values before parsing once, so in-place mutation and a second parse cannot change the value used for execution. The bridge supports Agent Auth's default capability execution endpoint. Custom capability locations are intentionally excluded because they route requests to app-owned handlers outside Beignet's validation, context, constraint, tracing, and output boundary. Better Auth verifies the agent and active grant before calling the executor. The app still verifies that the represented user may access the requested tenant and relies on use cases and policies for business authorization. ## Errors and observability Core execution uses stable error codes for unknown capabilities, invalid input, invalid output, and execution failures. The Better Auth bridge maps malformed arguments to protocol-safe client errors and redacts invalid output and unexpected failures as internal errors. Declared Beignet `AppError` failures use their stable application code as the Agent Auth error code. Individual execution also preserves the HTTP status; batch execution preserves the per-item code but does not have a per-item HTTP status. Intentional Better Auth `APIError` failures remain public. Throw one of those explicit public errors when a failure is safe for an agent to receive; ordinary errors are always redacted. Executor hooks observe start, completion, and failure across lookup, input, authorization, context, handler, and output stages. Successful completion events include the capability definition, application context, validated input, validated output, principal, and duration. Failure events include the validated input and context only when execution reached those stages; malformed raw input and unvalidated handler output are never exposed. Because early stages run before application context exists, failure events expose an optional `ctx`. Pass independent `instrumentation` and `tracing` options to the executor when malformed input or context-construction failures must reach those ports; otherwise Beignet derives them from `ctx` after successful context construction. Recorded attributes are bounded, and principal data, arguments, and results are never recorded automatically. Lifecycle hooks remain best-effort observers; place mandatory audit writes inside the application workflow. Use the adapter-specific test helper when testing the Better Auth bridge: ```typescript import { createBetterAuthAgentCapabilityTestContext } from "@beignet/agent-auth-better-auth/testing"; await capabilityBridge.onExecute?.( createBetterAuthAgentCapabilityTestContext({ capability: "issues.create", arguments: { workspaceId: "workspace_1", title: "Fix login" }, constraints: { workspaceId: "workspace_1" }, }), ); ``` The first release supports synchronous promise-based results. Streaming, asynchronous polling, model loops, prompts, conversation memory, and approval UI remain transport or application concerns. --- # Ports and adapters Source: https://www.beignetjs.com/ports Ports are the dependency interface your application code uses. They keep handlers and use cases independent from infrastructure choices such as databases, caches, mailers, queues, auth systems, and external APIs. For concrete app capabilities, read [Database and transactions](/database), [Audit and activity logging](/audit), [Cache](/cache), [Storage](/storage), [Mail](/mail), [Jobs](/jobs), [Schedules](/schedules), [Authentication](/authentication), [Authorization](/authorization), and [Rate limiting](/rate-limiting). In Beignet apps, `ports/` owns the app-facing types and `infra/` owns concrete implementations. Most apps start with a single `definePorts(...)` object and split port types by feature or capability as the app grows. ```bash bun add @beignet/core ``` ## Define ports Use `definePorts` to capture the exact shape of your dependencies when you wire concrete ports. ```typescript import { definePorts } from "@beignet/core/ports"; type Todo = { id: string; title: string; completed: boolean }; export const initialPorts = definePorts({ todos: { findById: async (id: string): Promise => { return db.todos.findById(id); }, create: async (data: { title: string }): Promise => { return db.todos.create(data); }, }, cache: { get: async (key: string) => redis.get(key), set: async (key: string, value: string, options?: { ttlSeconds?: number }) => { await redis.set(key, value, options?.ttlSeconds); }, delete: async (key: string) => (await redis.del(key)) > 0, has: async (key: string) => (await redis.exists(key)) > 0, remember: async (key: string, factory: () => Promise, options?: { ttlSeconds?: number }) => { const cached = await redis.get(key); if (cached != null) return cached; const value = await factory(); await redis.set(key, value, options?.ttlSeconds); return value; }, }, }); export type AppPorts = typeof initialPorts; ``` Repository ports may also expose purpose-built aggregate methods such as `countByStatus(...)` when a feature needs summary data; see [Aggregates](/database#aggregates) for the naming and row-visibility convention. ## Type shape and runtime wiring Canonical apps keep the final dependency type separate from the initial runtime values: | File | Responsibility | | --- | --- | | `ports/index.ts` | Defines `AppPorts` and `AppTransactionPorts`: the completed dependency bag available to application code. | | `infra/port-wiring.ts` | Exports `initialPorts`: app-owned values plus deferred keys that providers must supply during startup. | | `server/providers.ts` | Constructs provider-backed implementations for deferred keys and owns their lifecycle. | TypeScript erases `AppPorts` at runtime, so Beignet cannot discover its keys from the type alone. The `deferred` array materializes those requirements for startup validation. `definePorts()(...)` checks that bound values and deferred names agree with the type; `createServer(...)` then fails boot if a provider leaves any deferred key unresolved. ## Defer ports to providers Production apps usually bind a few app-owned ports directly and let [providers](/providers) contribute the rest at server startup. Use the curried `definePorts()(...)` form to declare which keys are deferred instead of writing throwing stub implementations: ```typescript import { definePorts } from "@beignet/core/ports"; import type { AppPorts } from "@/ports"; export const initialPorts = definePorts()({ bound: { gate }, deferred: ["audit", "db", "logger", "mailer", "storage", "uow"], }); ``` Deferred keys boot as marked placeholders. Calling any method on one throws a descriptive error naming the port, and `createServer(...)` validates after provider startup that nothing is left unbound: - The default `onUnboundPorts: "error"` fails boot and lists the unbound keys. - `"warn"` logs the same message and continues. - `"ignore"` skips the check; unbound ports still throw on first use. Tests that boot a server with only the ports they exercise typically use this. ```typescript export const getServer = createNextServerLoader(() => createNextServer({ ports: initialPorts, providers, onUnboundPorts: "error", // default context: appContextBlueprint, }), ); ``` ## Put ports in context The server passes ports into the context factories. From there, use cases, handlers, and hooks receive them through `ctx.ports`. ```typescript import { createNextServer, createNextServerLoader } from "@beignet/next"; import type { AppContext } from "@/app-context"; export const getServer = createNextServerLoader(() => createNextServer({ ports: initialPorts, context: ({ ports, req }) => ({ requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(), ports, }), }), ); ``` ```typescript import { defineRouteGroup } from "@/lib/routes"; import { getTodoUseCase } from "@/features/todos/use-cases"; export const todoRoutes = defineRouteGroup({ name: "todos", routes: [{ contract: getTodo, useCase: getTodoUseCase }], }); ``` ## Use ports in use cases Use cases stay transport-agnostic because they receive the same context shape that route handlers use. ```typescript import { createUseCase } from "@beignet/core/application"; import type { AppContext } from "@/app-context"; const useCase = createUseCase(); const createTodo = useCase .command("todos.create") .input(CreateTodoSchema) .output(TodoSchema) .run(async ({ input, ctx }) => { const todo = await ctx.ports.todos.create(input); await ctx.ports.cache.delete("todos:list"); return todo; }); ``` ## Defer best-effort work Use `BestEffortWorkPort` for follow-up work that may be lost without changing the result of the originating operation. A cache-invalidation broadcast is a good fit when the database remains authoritative and clients reconcile on reconnect: ```typescript import type { BestEffortWorkPort } from "@beignet/core/ports"; export type AppPorts = { bestEffortWork: BestEffortWorkPort; workspaceBroadcast: { publish( workspaceId: string, message: { type: "page.changed"; pageId: string }, ): Promise; }; }; ctx.ports.bestEffortWork.defer(() => ctx.ports.workspaceBroadcast.publish(input.workspaceId, { type: "page.changed", pageId: input.pageId, }), ); ``` In a Next.js app, supply the port from `server/providers.ts` with `createNextBestEffortWorkPort({ defer: after, onError })` on Next.js 15.1 or newer. Defer its key in `infra/port-wiring.ts` and register the adapter after any provider used by the error observer: ```typescript import { createProvider } from "@beignet/core/providers"; import { createNextBestEffortWorkPort } from "@beignet/next"; import { after } from "next/server"; import type { AppPorts } from "@/ports"; const bestEffortWorkProvider = createProvider>()({ name: "next-best-effort-work", setup({ ports }) { return { ports: { bestEffortWork: createNextBestEffortWorkPort({ defer: after, onError: (error) => ports.logger.warn("Best-effort work failed", { error }), }), }, }; }, }); ``` The Next adapter isolates synchronous and asynchronous scheduler failures, callback failures, and error-observer failures from the originating response. That failure boundary is intentional, but it is not durable execution. Use a job or transaction-scoped outbox when work needs retries or must survive process termination. Request deferred work only after the authoritative mutation succeeds, typically from a listener invoked by an after-commit event flush. The Next binding is request-bound: calls from workers, CLI commands, or other contexts unsupported by `after()` report a scheduling failure through `onError` and do not run the callback. ## Unit of work Use a Unit of Work port when a use case needs multiple operations to succeed or fail together. Beignet keeps this as a convention instead of a database abstraction: your app owns the transaction ports, and infra decides how to bind them to Drizzle, Prisma, Kysely, or an in-memory adapter. ```typescript import type { DomainEventRecorderPort, EventBusPort, UnitOfWorkPort, } from "@beignet/core/ports"; type TransactionPorts = { todos: TodoRepository; events: DomainEventRecorderPort; }; export type AppPorts = { todos: TodoRepository; eventBus: EventBusPort; uow: UnitOfWorkPort; }; ``` Use cases call transaction-scoped ports through the callback: ```typescript const todo = await ctx.ports.uow.transaction(async (tx) => { const created = await tx.todos.create(input); await events.record(tx.events, todoCreated, { todoId: created.id }); return created; }); ``` For tests and simple in-memory adapters, use `createNoopUnitOfWork(...)` with a fresh domain-event recorder per transaction: ```typescript import { createDomainEventRecorder, createNoopUnitOfWork, definePorts, } from "@beignet/core/ports"; const todos = createInMemoryTodoRepository(); const eventBus = createMemoryEventBus(); export const initialPorts = definePorts({ todos, eventBus, uow: createNoopUnitOfWork( () => ({ todos, events: createDomainEventRecorder(), }), { afterCommit: (tx) => tx.events.flush(eventBus), afterRollback: (_error, tx) => tx.events.clear(), }, ), }); ``` Production database adapters should replace `createNoopUnitOfWork` with a real transaction wrapper that creates repositories from the transaction client, then flushes recorded events only after commit. Flush validates each payload and publishes its original value; the registered listener performs the handler-facing parse so schema transforms are applied once at execution. If after-commit flushing fails, the UOW rejects without running rollback hooks because the transaction work already succeeded. That rejection does not mean the database rolled back, so callers must not blindly retry a non-idempotent command. Use a transaction-scoped outbox recorder when follow-up delivery must be durable. Use `createObservedUnitOfWork(...)` when best-effort work should be requested only after the wrapped transaction resolves. Its `afterCommit` observer and optional `onObserverError` callback are isolated, so their failures cannot reject an already committed operation. The observer is a scheduling hook, not durable storage; record durable intent through the transaction-scoped outbox. ## Mock ports in tests Tests can pass plain objects instead of production infrastructure. ```typescript const testPorts = definePorts({ todos: { findById: async (id: string) => ({ id, title: "Test", completed: false }), create: async (data: { title: string }) => ({ id: "1", completed: false, ...data, }), }, cache: { get: async () => null, set: async () => {}, delete: async () => false, has: async () => false, remember: async (_key, factory) => factory(), }, }); ``` ## Shared redaction Ports also provides shared redaction helpers for observability payloads. Use them in audit adapters, provider instrumentation, logging metadata, and devtools custom events when structured details may contain secrets: ```typescript import { redactHeaders, redactValue } from "@beignet/core/ports"; const headers = redactHeaders(req.headers); const metadata = redactValue({ authorization: "Bearer secret", todoId: "todo_1", }); ``` The default redactor hides secret-shaped keys and scrubs high-confidence credential shapes embedded in strings, including authorization schemes, JWTs, credential-bearing URLs, secret assignments, and private keys. App-specific sensitive domain fields should still be handled intentionally before they are logged or stored. ## Ports vs providers Ports are the interface. Providers are startup-time adapters that install ports for production use. Use direct ports when the dependency is simple or test-local. Use providers when the dependency needs configuration, startup, teardown, or reusable packaging. --- # Routes and server Source: https://www.beignetjs.com/server The server runtime matches requests to contracts, validates requests and responses, and runs your use cases with a typed per-request context. Read this page when you are wiring route groups, choosing the Next.js or Web Fetch adapter, or configuring server options. If you are creating your first app, start with [Quickstart](/getting-started). For what happens to a request between arrival and response, see [Request lifecycle](/request-lifecycle). ## Creating a server ```typescript // server/index.ts import { createNextServer, createNextServerLoader } from "@beignet/next"; import { initialPorts } from "@/infra/port-wiring"; import { appContext } from "@/server/context"; import { routes } from "@/server/routes"; export const getServer = createNextServerLoader(() => createNextServer({ ports: initialPorts, context: appContext, routes, }), ); ``` `ports` wires your application's dependency interfaces — databases, caches, mailers — defined with `definePorts`. See [Ports](/ports) for defining and deferring ports, and [Providers](/providers) for ready-made implementations. The server also accepts the `mapUnhandledError` and `onCaughtError` error options — see [Errors](/errors) — the `instrumentation` option covered in [Request lifecycle](/request-lifecycle#request-instrumentation), and `requestBody.maxBytes` for JSON/text contract route bodies. The default request body limit is 1 MiB; requests over the limit return a framework-owned `413` with code `PAYLOAD_TOO_LARGE`. ## Next.js integration The `@beignet/next` adapter works with the Next.js App Router. Expose the central handler from a catch-all API route: ```typescript // app/api/[[...path]]/route.ts import { createApiRoute } from "@beignet/next"; import { getServer } from "@/server"; export const { DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT } = createApiRoute(getServer); ``` Next App Router requires literal named exports for each HTTP method, so Beignet exposes `createApiRoute(getServer)` to return those exports while keeping provider startup behind the memoized server loader. The catch-all file belongs to the adapter layer; Beignet contracts themselves still use concrete paths with optional single-segment params such as `/posts/:id`. The Next adapter also ships helpers for common app glue: `createOpenAPIHandler` and Swagger routes (see [OpenAPI](/openapi)), Server Component context helpers, upload routes, storage routes, devtools routes, outbox drain routes, and `createNextBestEffortWorkPort(...)` for non-durable work scheduled through `after()` on Next.js 15.1 or newer. See [Ports and adapters](/ports#defer-best-effort-work) for the failure boundary and provider wiring. Server Component contexts preserve request headers and synthesize the standard `cookie` header from Next's `cookies()` store when `headers()` does not expose one, so auth providers see the same cookie shape they see in API routes. Export `contracts = contractsFromRoutes(routes)` from `server/routes.ts` for OpenAPI. For per-file `server.route(contract).handle(...)` handlers, pass an explicit contract list instead. ### Web Fetch runtimes Use `@beignet/web` when the runtime accepts a standard `Request` and returns a standard `Response` — Cloudflare Workers, Bun, Deno, Node fetch servers, and route tests. `createFetchServer(...)` takes the same options as `createNextServer` and exposes a framework-neutral `server.fetch` handler plus `server.runServiceContext(...)` for scoped non-HTTP work; see the [`@beignet/web` README](https://www.npmjs.com/package/@beignet/web) for setup. Plain response objects default to JSON serialization. In Web Fetch and Next.js runtimes, an explicit `ReadableStream` stays a stream even for JSON media types. An explicit non-JSON `Content-Type` opts strings and binary values into Web `BodyInit` serialization. Native `Response` remains available when a route needs direct transport control. All adapters share the same boundary: `@beignet/core/server` owns matching, hooks, validation, error mapping, and response ownership, while the adapter only converts between platform request/response types — implement the `HttpAdapter` shape (`webFetchAdapter` is the reference) to target another runtime. ## Context Put your baseline context type in `app-context.ts`, then declare a context blueprint that builds that shape. Feature route groups, use cases, hooks, and tests should import `AppContext` from that file instead of redefining it. ```typescript // app-context.ts import type { ActivityActor, ActivityTenant } from "@beignet/core/ports"; import type { TrustedRequestInfo } from "@beignet/core/server"; import type { TraceContext } from "@beignet/core/tracing"; import type { AppGate, AppPorts } from "@/ports"; import type { AuthSession } from "@/ports/auth"; export type AppContext = { actor: ActivityActor; auth: AuthSession | null; gate: AppGate; requestId: string; requestInfo?: TrustedRequestInfo; ports: AppPorts; tenant?: ActivityTenant; } & Partial; ``` Keep the runtime blueprint in `server/context.ts` so the server and route tests reuse the same context construction: ```typescript // server/context.ts import { createAnonymousActor, createServiceActor, createTenant, createUserActor, } from "@beignet/core/ports"; import { defineServerContext } from "@beignet/core/server"; import type { TraceContext } from "@beignet/core/tracing"; import type { AppContext } from "@/app-context"; function resolveRequestTenant(auth: AppContext["auth"]) { const tenantId = tenantIdFromAuthoritativeAuthClaims(auth); return tenantId ? createTenant(tenantId) : undefined; } function tenantIdFromAuthoritativeAuthClaims(auth: AppContext["auth"]) { const tenantId = auth?.session?.tenantId; return typeof tenantId === "string" && tenantId.trim() ? tenantId.trim() : undefined; } export type AppServiceContextInput = | { tenantId?: string; } | undefined; export const appContext = defineServerContext< AppContext, AppContext["ports"] >()({ gate: (ports) => ports.gate, request: async ({ ports, req, requestId, requestInfo, trace }) => { const auth = await ports.auth.getSession(req); return { actor: auth ? createUserActor(auth.user.id) : createAnonymousActor(), auth, requestId, requestInfo, ...trace, ports, tenant: resolveRequestTenant(auth), }; }, service: ({ ports, input, requestId, trace, }: { ports: AppContext["ports"]; input: AppServiceContextInput; requestId: string; trace: TraceContext; }) => ({ actor: createServiceActor("app-service"), auth: null, requestId, ...trace, ports, tenant: createTenant(input?.tenantId ?? "tenant_default"), }), }); ``` The `context` option receives this blueprint. The `request` factory runs on every request: it receives ports, the raw request, and the server-resolved `requestId`, `requestInfo`, and `trace` values, and returns the context fields available to all handlers. `requestInfo` contains the request URL, origin, protocol, host, and optional client IP after applying the server's `trustedProxy` policy. It ignores forwarding headers by default. Service contexts omit it because they do not originate from HTTP requests. The server owns `ctx.gate`: declare which port provides it with `gate`, and the server attaches a live gate that always authorizes against the current `actor` and `tenant` — even after hooks elevate identity. Returning `gate` from a factory is a compile error. `auth` is the resolved provider session or `null`. `actor` is the durable audit and authorization actor derived from that session, or from a service identity in background contexts. `tenant` is optional; omit it when no tenant is active instead of setting it to `null`. Only use `tenantIdFromAuthoritativeAuthClaims(...)` when the auth provider guarantees that the server-issued claim represents current membership. A multi-tenant app without that guarantee must replace this seam with an app-owned membership lookup before it creates `TenantContext`; never trust a tenant ID echoed from the caller or stored in an unverified session field. Configure trusted request metadata once in `server/index.ts` when every request passes through a trusted edge: ```typescript createNextServer({ // The last entry is safe only when the trusted proxy appends it. trustedProxy: { clientIp: "x-forwarded-for-last" }, context: appContext, hooks: [createCsrfHooks(), createRateLimitHooks()], // ... }); ``` The request context and each server-hook phase receive the same resolved `requestInfo`. CSRF and rate-limit hooks use it automatically. Logging observers may read it explicitly, but Beignet does not automatically persist client IPs in logs or audit entries. An optional `service` factory powers the two service entrypoints used by schedules, outbox drains, tasks, and background work; it receives fresh `requestId` and `trace` values per call and typically defaults `actor` to `createServiceActor(...)`: - `server.createServiceContext(...)` returns the built context and enters the ambient correlation frame for the rest of the caller's async execution. Use it from long-lived runtimes only: servers, workers, and test runners. - `server.runServiceContext(...)` builds the same context and runs a callback inside a scoped ambient frame, returning the callback's result. Plain scripts such as seeds and one-off maintenance work must use this entrypoint: `createServiceContext(...)` relies on `AsyncLocalStorage.enterWith`, and resuming that frame across top-level await crashes Bun 1.3.x in plain scripts. ```typescript // scripts/seed.ts (plain script, top-level await) await server.runServiceContext({ tenantId: "tenant_demo" }, async (ctx) => { await seedDemoData(ctx); }); ``` ### Acting as a user from non-HTTP entrypoints The `service` input type is app-owned, so entrypoints that act *on behalf of a user* — AI agents, queue consumers, imports, backfills — extend it instead of hand-assembling a context. Resolve the user's real membership from the database in the caller (never from the caller's claims), pass it through the input, and let the factory build the same shape the request factory builds. The server attaches the gate either way, so policies evaluate the impersonated identity exactly as they would a signed-in request: ```typescript export type AppServiceContextInput = | { tenantId?: string; /** Act as this user with their verified membership. */ asUser?: { id: string; role: WorkspaceRole; name?: string }; } | undefined; // In the service factory: service: ({ ports, input, requestId, trace }) => ({ actor: input?.asUser ? createUserActor(input.asUser.id, { displayName: input.asUser.name }) : createServiceActor("app-service"), auth: input?.asUser ? { user: { id: input.asUser.id } } : null, requestId, ...trace, ports, ...(input?.tenantId ? { tenant: createTenant(input.tenantId) } : {}), ...(input?.asUser ? { membership: { role: input.asUser.role } } : {}), }), ``` ```typescript // An agent capability acting for a verified user: const membership = await server.ports.members.findMembership({ workspaceId, userId, }); if (!membership) throw forbidden(); const ctx = await server.createServiceContext({ asUser: { id: userId, role: membership.role }, tenantId: workspaceId, }); await appendToPageUseCase.run({ ctx, input }); ``` Use the registry and executor from [Agent capabilities](/agent-capabilities) when exposing multiple actions through an agent transport. Never assemble a context object by hand or call `gate.attach(...)` from app code — the server owns gate attachment and strips hand-attached gates with a dev warning. Apps without a `gate` on their context type can pass a plain request factory: `context: async ({ ports, requestId }) => ({ requestId, ports })`. ## Route registration The canonical Beignet app route style binds route builders to `AppContext` once in `lib/routes.ts`. Feature route files use `defineRouteGroup({ name, routes })`, route entries bind a contract directly to a use case (`{ contract, useCase }`), and routes that own response headers, streaming, native `Response` values, or multi-status handling implement a full handler (`{ contract, handle }`). The app-bound builder preserves per-route contract and use-case type checks without repeated generic arguments. `server/routes.ts` composes groups with `defineRoutes(...)`, and `server/index.ts` passes the central `routes` list to the adapter. This gives route inspection, OpenAPI, typed clients, lint, and doctor one shared route registry. ```typescript // lib/routes.ts import "@beignet/core/server-only"; import { createRoutes } from "@beignet/core/server"; import type { AppContext } from "@/app-context"; export const { defineRoute, defineRouteGroup } = createRoutes(); ``` ```typescript // features/todos/routes.ts import { defineRouteGroup } from "@/lib/routes"; import { getTodo, listTodos } from "@/features/todos/contracts"; import { getTodoUseCase, listTodosUseCase } from "@/features/todos/use-cases"; export const todoRoutes = defineRouteGroup({ name: "todos", routes: [ { contract: listTodos, useCase: listTodosUseCase }, { contract: getTodo, useCase: getTodoUseCase }, ], }); ``` A binder route synthesizes the handler at registration time. The response status is inferred when the contract declares exactly one 2xx response; multiple 2xx responses require an explicit `status`. A sole path, query, or body schema is passed through unchanged when no additional path, query, or object body values are present, so scalar and array bodies retain their shape. Multiple declared sources and inferred path values use the default object merge. Use-case errors flow through the app error catalog exactly as they do from `handle` routes. Share schemas by reference and Beignet skips double validation: when a contract's single input source schema, or its declared success response schema, is the same object as the use case's `.input(...)` or `.output(...)` schema, the redundant second parse is skipped only after the first layer actually validated it. In particular, `validate: { output: false }` on the use case keeps route response validation active. Reusing schemas by reference is how the framework knows validation already happened. The multiple-source input mapping — query lowest, then body, then path, headers never merged — is exported as `defaultBinderInput`. Routes that read headers, combine another source with a scalar or array body, or need any other input shape declare an explicit `input` mapper over the parsed parts: ```typescript { contract: resolveIssue, hooks: [writerAuth.required()], useCase: resolveIssueUseCase, input: ({ path, headers }) => ({ key: path.key, expectedVersion: headers["x-expected-version"], }), }, ``` ### Full handlers `handle` remains the escape hatch for everything the binder intentionally does not cover — response headers, streaming, native `Response` values, redirects, and multi-status handling: ```typescript // features/todos/routes.ts { contract: exportTodos, handle: async ({ ctx, query }) => { const csv = await exportTodosUseCase.run({ ctx, input: query }); return new Response(csv, { status: 200, headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": 'attachment; filename="todos.csv"', }, }); }, }, ``` The handler object gives you `req` (Beignet's framework-neutral HTTP request), `ctx`, the validated `path`, `query`, `headers`, and `body` parts, and `contract` for metadata access — all typed from the contract definition. Declared request headers are normalized to lowercase before validation: use `headers.authorization` for parsed contract headers and `req.headers` for raw transport access. Fetch adapters also expose the native request as `req.raw` and its cancellation signal directly as `req.signal`. Compose feature groups at the server boundary: ```typescript // server/routes.ts import { contractsFromRoutes, defineRoutes } from "@beignet/core/server"; import type { AppContext } from "@/app-context"; import { todoRoutes } from "@/features/todos/routes"; export const routes = defineRoutes([todoRoutes]); export const contracts = contractsFromRoutes(routes); ``` `defineRoutes` flattens route groups before they are passed to the server, so `server/index.ts` can stay focused on app composition. ### Focused per-file routes Use `server.route(contract).handle(...)` for focused per-file adapter routes — webhooks, redirects, downloads, OpenAPI/devtools — that intentionally sit outside the central route registry. Export an explicit contract list when such a route should appear in OpenAPI or typed-client contract lists. ### Raw routes Some routes cannot be contracts at all: third-party callback endpoints with externally defined request shapes (a Liveblocks room-auth endpoint, a Better Auth callback), signature-verified webhooks, or streaming endpoints that own body consumption. `server.rawRoute(...)` builds a handler for these that still runs the whole pipeline — correlation, `onRequest`/`beforeHandle`/ `beforeSend`/`afterSend` hooks, context creation, instrumentation, and framework error mapping — without contract parsing or validation. The request body is left unconsumed, so the handler can verify a signature over the exact raw bytes. ```typescript // app/api/liveblocks-auth/route.ts import { getServer } from "@/server"; let roomAuth: ((req: Request) => Promise) | undefined; export async function POST(req: Request) { roomAuth ??= (await getServer()) .rawRoute({ name: "collab.roomAuth", method: "POST", path: "/api/liveblocks-auth", metadata: { rateLimit: { max: 300, windowSec: 60, scope: "user" }, }, }) .handle(async ({ req, ctx }) => { const body = await req.text(); // Authorize the room from app data and return the provider token. return { status: 200, body: { token: "..." } }; }); return roomAuth(req); } ``` `name`, `method`, and `path` identify the route to hooks, instrumentation, and devtools; routing itself belongs to the file that mounts the handler. `metadata` feeds metadata-driven hooks exactly like contract metadata, so declared rate limits and idempotency apply without hand-rolled enforcement. The Next.js route factories — `createWebhookRoute`, `createPaymentWebhookRoute`, `createScheduleRoute`, and `createOutboxDrainRoute` — run through this pipeline automatically when the server they receive exposes `rawRoute(...)`. ## Beyond JSON Beignet is JSON-first: returning a plain object from a handler produces a JSON response and declared response schemas validate that JSON value. For transport-level cases such as webhook signatures, downloads, plain text, redirects, or streams, use the raw request readers and return a native web `Response`: ```typescript import { getServer } from "@/server"; export async function POST(req: Request) { const server = await getServer(); const handle = server.route(stripeWebhook).handle(async ({ req }) => { const rawBody = await req.text(); const signature = req.headers.get("stripe-signature"); verifyWebhookSignature(rawBody, signature); return { status: 200, body: { received: true } }; }); return handle(req); } export async function GET(req: Request) { const server = await getServer(); const handle = server.route(downloadReport).handle(async () => new Response(await loadReportBytes(), { headers: { "Content-Type": "application/pdf", "Content-Disposition": 'attachment; filename="report.pdf"', }, }), ); return handle(req); } ``` Native `Response` instances are transport-owned: they bypass JSON response validation, and `beforeSend` hooks only merge header changes onto them. Use `{ status, body }` when you want the response contract enforced; use `Response` when the route owns transport details directly. See [Request lifecycle](/request-lifecycle#response-ownership) for the ownership taxonomy and [Hooks](/hooks) for the native-response hook rules. ### Server-sent events Use `createServerSentEventResponse(...)` when a contract handler or raw route needs a portable Server-Sent Events response. It JSON-encodes event data, emits heartbeat comments every 25 seconds by default, closes when `req.signal` aborts, bounds unread event data to 1 MiB, and runs any cleanup returned by `start(...)` exactly once. It also sets `Content-Type: text/event-stream`, `Cache-Control: no-store, no-transform`, and `X-Accel-Buffering: no`. ```typescript // app/api/workspaces/[workspaceId]/events/route.ts import { createServerSentEventResponse } from "@beignet/core/server"; import { authorizeWorkspaceEvents } from "@/features/collab/use-cases/authorize-workspace-events"; import { getServer } from "@/server"; export async function GET( req: Request, { params }: { params: Promise<{ workspaceId: string }> }, ) { const { workspaceId } = await params; const server = await getServer(); const handle = server .rawRoute({ name: "workspaces.events", method: "GET", path: "/api/workspaces/:workspaceId/events", }) .handle(async ({ req, ctx }) => { await authorizeWorkspaceEvents.run({ ctx, input: { workspaceId } }); return createServerSentEventResponse({ signal: req.signal, maxLifetimeMs: 240_000, start({ send }) { // Reconcile authoritative state on every initial connection/reconnect. send({ event: "reconcile", data: { workspaceId } }); const subscription = ctx.ports.workspaceBroadcast.subscribe( workspaceId, (change) => { send({ event: "changed", data: change }); }, ); return { close: () => subscription.unsubscribe() }; }, onError: (error) => ctx.ports.logger.error("SSE stream failed", { error }), }); }); return handle(req); } ``` With this route, a browser `EventSource` receives `reconcile` when it connects and `changed` for each published message. Each `MessageEvent.data` value is the JSON string produced from the corresponding `data` object. The authorization use case and `workspaceBroadcast` port above are app-owned. The helper does not provide authorization, replay, distributed connection limits, or durable delivery. Treat ephemeral messages as invalidation hints: keep the database authoritative and reconcile when the browser initially connects or automatically reconnects. Set `heartbeatMs: false` only when the host already owns keep-alives; `maxLifetimeMs` is disabled unless you opt in. Both numeric timer options accept integers from `1` through `2147483647` milliseconds. Provide `onError` on production endpoints so subscription, encoding, stream, and cleanup failures reach the application logger. Producer, framing, and encoding failures close the connection; failures inside the error observer are isolated from the stream lifecycle. `maxBufferedBytes` limits the encoded frames waiting inside the response's Web Stream and defaults to `1_048_576`. If one frame or the accumulated unread queue would exceed the limit, `send(...)` or `comment(...)` returns `false`, the helper reports a `RangeError` through `onError`, and the connection closes so a client can reconnect and reconcile. The `start(...)` callback receives a stream-scoped `signal`; pass it to subscription APIs that perform asynchronous setup. Response-body cancellation waits for cleanup that is already registered without waiting indefinitely for pending setup. Cleanup returned after closure still runs exactly once. An expected `AbortError` from setup after stream closure is treated as cancellation and does not reach `onError`; other setup rejections still report. Custom buffer limits accept integers from `1` through `2_147_483_647` bytes. Use a contract handler when the stream belongs in the route registry and OpenAPI document. Use a raw route when the endpoint is transport-only and the app owns its request and response shape. Framework-neutral `{ status, headers, body }` responses accept either a string or a string array for each header. Use an array when the field must be emitted more than once: ```ts return { status: 200, headers: { "set-cookie": [ "session=abc; Path=/; HttpOnly; Secure; SameSite=Lax", "theme=dark; Path=/; Secure; SameSite=Lax", ], }, body: { ok: true }, }; ``` The Web and Next adapters append each array item as a separate field, so multiple cookies keep response validation, hooks, correlation headers, and idempotency replay. A native `Response` remains the escape hatch for transport-owned bodies and platform-specific behavior. Document binary or streaming transport-owned routes with `.responses({ 200: null })` and an OpenAPI media override such as `application/octet-stream` or `text/event-stream`; call them with platform `fetch` when the caller needs bytes or a stream. ## Response validation Beignet automatically validates incoming requests against your contract schemas. If validation fails, it returns a framework-owned 422 response whose body identifies the contract, method, path, failing location (`path`, `query`, `headers`, or `body`), and schema issues — see [Errors](/errors#error-shape) for the envelope. Your handler only runs if the request is valid. It also validates outgoing handler responses against `contract.responses`. If a handler returns an undeclared status or a body that does not match the declared schema, Beignet returns a 500 contract-violation response instead of silently drifting from the contract. Response violations identify the returned status and the declared statuses, but never echo the invalid body, so handlers cannot leak route-owned data while reporting drift. Pass `validateResponses: false` to `createServer(...)` to skip route-owned response validation, mirroring the typed client's `validateResponses` option. **Production posture.** Response validation is on by default and costs one schema parse per response on your hottest routes. Keep it on in development, CI, and tests, where it catches contract drift the moment it happens. If profiling shows it is a measurable cost in production, drive it from env so only production trades the guarantee for throughput: ```typescript // lib/env.ts declares VALIDATE_RESPONSES as a boolean defaulting to true. export const getServer = createNextServerLoader(() => createNextServer({ // ... validateResponses: env.VALIDATE_RESPONSES, }), ); ``` Binder routes whose use case `.output(...)` schema is the same object as the declared success response schema already skip the redundant success-status parse, so for them this knob only affects error and undeclared statuses. Validation responses are one kind of framework-owned response. For the full route-owned / framework-owned / transport-owned taxonomy and the `x-beignet-error-owner` header, see [Request lifecycle](/request-lifecycle#response-ownership). For the app error catalog, `AppError`, and unhandled-error mapping, see [Errors](/errors). ## Hooks Server hooks wrap every request for protocol and lifecycle behavior; route hooks attach beside contracts in feature route groups for auth, tenancy, and feature-specific preconditions. See [Hooks](/hooks) for lifecycle order, `createAuthHooks`, and typing hook-enriched context with `defineRoute`. [Logging](/logging) and [Rate limiting](/rate-limiting) are production patterns built on hooks. [Error reporting](/error-reporting) uses `createErrorReportingHooks(...)` to capture unexpected HTTP failures without changing response mapping. ## Registration-time guarantees `createServer(...)` validates the route registry up front so contract drift fails at startup instead of surfacing as confusing request-time behavior: - **Unique method + path.** Registering the same method and path twice throws, and dynamic paths that differ only by parameter name (`/items/:id` vs `/items/:slug`) are rejected as ambiguous. - **Unique contract names.** Typed clients, OpenAPI operations, and devtools key on contract names, so two contracts with the same name cannot both be registered even on different paths. - **Path templates match `pathParams`.** When a contract declares an introspectable `pathParams` object schema, its keys must match the `:param` keys in the path template; missing or extra keys throw at startup with the contract name and path. Non-introspectable Standard Schemas skip this check. - **Body schemas require a body method.** Request body schemas are supported for `POST`, `PUT`, and `PATCH` contracts; attaching one to `GET`, `HEAD`, `DELETE`, or `OPTIONS` is rejected during registration. These checks apply both to the `routes` list passed to `createServer(...)` and to imperative `server.route(contract).handle(...)` registration. Runtime dispatch — match specificity, `405` with `Allow`, and `404` — is covered in [Request lifecycle](/request-lifecycle#route-matching). Structural registration checks run whenever a route is added. Startup hook `validate(...)` methods and the server's `contracts` snapshot run once against the initial `routes` list, however; they do not rerun for later imperative `server.route(...)` registrations. Keep routes that must participate in boot-time cross-route validation in the central registry, and list intentional per-file contracts explicitly in OpenAPI or client catalogs. ## Runtime integrity Routes fail loudly when they are not registered. Workflow artifacts can fail more quietly: a listener, schedule, task, or outbox handler can exist in a feature folder but never make it into the runtime registry that executes it. Use runtime integrity when you want boot to catch that drift: ```typescript // server/runtime-integrity.ts import { createRuntimeIntegrity, defineRuntimeManifest, defineRuntimeRegistries, } from "@beignet/core/server"; import { postEvents } from "@/features/posts/domain/events"; import { postJobs } from "@/features/posts/jobs"; import { postListeners } from "@/features/posts/listeners"; import { listeners } from "@/server/listeners"; import { outboxRegistry } from "@/server/outbox"; export const runtimeManifest = defineRuntimeManifest({ listeners: [...postListeners], outbox: { events: [...postEvents], jobs: [...postJobs], }, }); export const runtimeRegistries = defineRuntimeRegistries({ listeners, outbox: outboxRegistry, }); export const runtimeIntegrity = createRuntimeIntegrity({ manifest: runtimeManifest, registries: runtimeRegistries, }); ``` Then pass it to the server: ```typescript createNextServer({ ports, context, routes, integrity: runtimeIntegrity, }); ``` The check is pure and serverless-safe: it compares imported definitions and registries in memory. It does not scan the filesystem, call providers, touch databases or queues, start workers, or prove that cron and worker deployments exist. `mode: "warn"` logs findings instead of failing startup; omitted `integrity` leaves the server unchanged. `beignet doctor --strict` remains the static drift check and also warns when an opted-in runtime manifest omits feature workflow registries. --- # Request lifecycle Source: https://www.beignetjs.com/request-lifecycle A Beignet request moves through a predictable pipeline before your application code runs and before a response is sent. Read this page when you need to know what the framework guarantees around a request — matching, validation, correlation, and who owns each kind of response. ```txt HTTP request -> adapter request normalization -> route matching -> request correlation (request ID + trace context) -> onRequest hooks -> request parsing -> contract request validation -> context creation -> route hooks -> beforeHandle hooks -> route handler or use case -> beforeSend hooks (correlation response headers) -> route-owned response validation -> adapter response serialization -> afterSend hooks (instrumentation events) ``` This pipeline keeps business responses strict without forcing every route to declare infrastructure responses such as malformed JSON, auth failures, rate limits, unmatched routes, or unexpected failures. ## Route matching Beignet apps register routes centrally in `server/index.ts` with `defineRoutes`. Next.js apps expose them through `createApiRoute(getServer)` in a catch-all `app/api/[[...path]]/route.ts`; standard Fetch runtimes mount the `server.fetch` handler returned by `createFetchServer(...)`. Both adapters dispatch the incoming request through the same registered contract and handler pipeline. Routes are matched by HTTP method and path. Static segments are more specific than dynamic segments, so `/posts/new` wins over `/posts/:slug` regardless of registration order. Dynamic parameter names do not affect matching — registering both `/items/:id` and `/items/:slug` for the same method is rejected at startup as ambiguous, along with the other [registration-time guarantees](/server#registration-time-guarantees). If the path matches one or more registered routes but the method does not, Beignet returns a framework-owned `405` response with `code: "METHOD_NOT_ALLOWED"` and an `Allow` header listing the registered methods for that path. A `GET` route also serves `HEAD` when the path has no explicit `HEAD` route. Explicit `HEAD` routes take precedence, `Allow` includes the implicit method, and every HEAD response is bodyless while preserving its status and headers. CORS preflight `onRequest` hooks still short-circuit `OPTIONS` requests before a `405` is produced. If no route matches the path at all, Beignet returns a framework-owned `404` error response. ## Request validation The runtime parses path params, query params, headers, and JSON body data from the request. It validates each declared schema before your handler runs. Invalid request data never reaches the handler. It becomes a framework-owned error response with a standard error envelope. When a body is a valid JSON object or array but omits `Content-Type`, Beignet still treats it as text according to HTTP semantics. If body validation then fails, `details.hint` explains that `Content-Type: application/json` is needed; explicit text content types do not receive that suggestion. ## Request instrumentation The server owns request correlation. You can rely on these outcomes without writing any hooks: - Every request gets a request ID and W3C trace context — taken from incoming `x-request-id` and `traceparent` headers when present, generated otherwise — available to context factories as `requestId` and `trace`. - By default the server writes `x-request-id` and `traceparent` response headers, including on streamed responses. - `request` and `error` events are recorded to the resolved provider instrumentation port (see [Writing a provider](/writing-a-provider)) after responses are sent; without an installed sink, headers are still written and events are a no-op. - Correlation is ambient for the request's duration, so instrumentation sinks can correlate events recorded anywhere in the request. - When `ports.tracing` is installed, the request runs inside an active `beignet.request ` span. Nested use cases and provider SDK spans inherit that OpenTelemetry context across asynchronous work. Configure it with the `instrumentation` option on `createServer(...)`: ```typescript const server = await createNextServer({ // ... instrumentation: { requestIdHeader: "x-request-id", // or false traceContextHeader: "traceparent", // or false ignorePaths: ["/api/devtools"], redact: (event) => event, shouldCapture: ({ response }) => response.status !== 404, }, }); ``` Pass `instrumentation: false` to disable headers and event recording. Context factories still receive `requestId` and `trace` arguments. Context values win over server-computed correlation: when a factory sets its own `requestId`, headers and recorded events use it. Install `@beignet/provider-tracing-opentelemetry` to turn correlation into production spans and metrics. The app still owns SDK registration, sampling, export, and serverless flush behavior. See [OpenTelemetry](/observability). ## Per-stage timings Every request records a per-stage timing breakdown alongside its total duration. `afterSend` hooks receive it as `stages`, and the recorded `request` event carries the same object, which the [devtools waterfall](/devtools) renders as sub-bars under the request span: | Stage | Measures | | --- | --- | | `onRequestMs` | `onRequest` hooks, including early rate limits and CORS preflight | | `parseMs` | Query/path/header/body parsing and contract validation | | `contextMs` | The `context.request` factory, including gate attachment | | `beforeHandleMs` | Route hooks plus `beforeHandle` hooks (auth resolution, user-scoped rate limits) | | `handlerMs` | The route handler or bound use case | | `sendMs` | `beforeSend` hooks, response validation, and finalizers | Stages that did not run report `0`, and the stages do not sum exactly to `durationMs` — routing and framework bookkeeping live in the gaps. ## Context latency budgets `contextMs` is the number to watch in serverless deployments with a remote database. The `context.request` factory runs on **every** contract request before any handler, so each sequential `await` inside it — a session lookup, then a membership query — adds a full database round-trip to every request in the app. Two sequential 120ms remote queries put a ~240ms floor under every endpoint before business logic starts. Budget context creation like the hot path it is: - Run independent lookups concurrently with `Promise.all` instead of sequential awaits, and resolve dependent data (a role for the resolved tenant) inside the use cases that need it when most routes do not. - Cache what the auth provider lets you cache — for example Better Auth's `cookieCache` avoids a session query per request. - Wrap repository reads in [`createMemo`](#request-scoped-memoization) so a lookup the context factory already resolved is not paid again by policies and use cases in the same request. - Keep the factory to identity and correlation. Loading feature data in context makes every route pay for the heaviest route's needs. - Watch the waterfall in development: a wide `context` bar on every request is this problem, and it shows up long before production traffic does. ## Request-scoped memoization `createMemo` from `@beignet/core/memo` deduplicates a lookup for the lifetime of one request: the first call runs, and every later call with the same arguments — from a policy, a use case, an event handler — returns the same value, including the same in-flight promise, so concurrent calls share one query. The cache dies with the request, so memoized reads can never serve data staler than the request that fetched them: no TTL, no invalidation policy, no cross-request state. Wrap reads where the adapter is wired, in infra, so use cases and policies never know caching exists: ```typescript // infra/issues-repository.ts import { createMemo } from "@beignet/core/memo"; const repository = createDrizzleIssuesRepository(db); export const issues = { ...repository, findById: createMemo(repository.findById, { name: "issues.findById" }), update: async (id: string, patch: IssuePatch) => { const updated = await repository.update(id, patch); // A write makes the memoized read stale within this same request. issues.findById.invalidate(id); return updated; }, }; ``` Memoize reads, not mutations, and pair every mutation that affects a memoized read with `invalidate(...)` as above. Default cache keys use a structural, type-tagged encoding of the arguments (`"1"` and `1` never collide, object key order is irrelevant); arguments that cannot be encoded deterministically — class instances, functions, circular values — throw a `MemoKeyError` that names the memo, and a `key` option takes over: ```typescript createMemo(repository.search, { name: "issues.search", key: (query) => query.normalized, }); ``` The server enters a memo scope around every HTTP request (contract routes and raw routes) and around `server.runServiceContext(...)` executions, and `createTestApp` requests each get their own scope. `createServiceContext(...)` has no call boundary that could end a scope, so memoized functions call straight through there — as they do in plain scripts, where `runMemoScope` from `@beignet/core/memo` creates a scope explicitly. When devtools is installed, every memoized call records a `memo.hit` or `memo.miss` event (with fill duration) under the request, so duplicate lookups are visible in the waterfall before and after you wrap them. For caching that must survive across requests — the remote-database session and role floor above — reach for the explicit tier instead: the auth provider's cookie cache for sessions, and `ports.cache.remember` with keys that change when the data changes (a per-tenant version segment, for example) for everything else. Cross-request caching trades staleness for latency and needs an invalidation story; request-scoped memoization never does, which is why it is the tier the framework applies automatically. ## Context and hooks The server's `context.request` factory builds the per-request context used by handlers, hooks, and use cases from the framework-neutral request, final ports, the matched contract, and the server-resolved `requestId` and `trace` values. See [Server](/server#context) for the context blueprint and service contexts. When the context blueprint declares a `gate`, the server attaches `ctx.gate` itself and re-attaches it after hooks enrich the context, so authorization always evaluates the current identity. Hooks run around the handler for application-level behavior: authentication at the HTTP boundary, CORS, logging and tracing, rate limiting, response shaping, and error mapping. Hooks can short-circuit the request; short-circuit responses are framework-owned unless the hook returns a native `Response`. See [Hooks](/hooks) for the lifecycle order and hook kinds. ## Route execution After validation, the route runs. Binder routes (`{ contract, useCase }`) map the validated request parts to the use case input, run the use case, and return its output with the contract's declared success status: ```typescript { contract: getProject, useCase: getProjectUseCase, } ``` When the route shares schemas by reference with its use case, Beignet skips the redundant second validation pass. See [Server](/server#route-registration) for the binder's input mapping and one-schema-one-parse rules. Full `handle` routes receive the validated parts directly and own the response: ```typescript { contract: getProject, handle: async ({ req, ctx, path, query, headers, body }) => ({ status: 200, body: await getProjectUseCase.run({ ctx, input: { id: path.id } }), }), } ``` ## Response ownership Beignet separates responses into three groups: | Owner | Source | Validation | | --- | --- | --- | | Route-owned | Bound use case output, handler `{ status, body }`, or route-owned `AppError` | Validated against `contract.responses` | | Framework-owned | Parsing, validation, hooks, unmatched routes, global error handling, contract violations | Uses Beignet's standard error envelope | | Transport-owned | Native `Response` returned by handler or hook | Bypasses JSON response validation | Route-owned responses are business outcomes: responses returned by the handler, and `AppError` instances thrown by the handler or bound use case. When the route declares response schemas, an undeclared status or a non-matching body becomes a framework-owned 500 contract-violation response; an empty `contract.responses` applies no response validation. The `validateResponses` server option and its production posture live in [Server](/server#response-validation). Framework-owned responses skip route response validation and use Beignet's standard error envelope when applicable: - Request parsing and request validation errors - Unmatched route 404s and method-mismatch 405s - Hook short-circuit responses from `onRequest` or `beforeHandle` - `mapUnhandledError` responses - Internal contract-violation 500s Framework-owned Beignet error envelopes include `x-beignet-error-owner: framework` so generated clients can distinguish framework errors from route-owned error responses that share the same status code. Transport-owned responses are native `Response` objects returned from handlers or hooks. They bypass JSON response validation, and `beforeSend` sees a headers-only view where only header changes apply; `afterSend` still observes their status and headers. Plain Beignet responses can intentionally send text, or binary data by declaring an explicit non-JSON `Content-Type` and returning a Web `BodyInit` value through `@beignet/web` or `@beignet/next`. Explicit `ReadableStream` bodies remain streams for every media type, including JSON. Use a native response for redirects, multipart boundary generation, Server-Sent Events, or other transport-specific behavior. HTTP statuses 204, 205, and 304 are always required to be bodyless. Beignet checks that invariant inside the server pipeline even when response schemas are absent or `validateResponses` is disabled, so violations still pass through error mapping, finalizers, lifecycle headers, and instrumentation. If a response-schema validator itself throws unexpectedly, Beignet maps that failure through the same pipeline before running response finalizers. Hooks therefore receive the mapped failure; the idempotency hook can release its reservation instead of leaving the key in progress until its timeout. This keeps the contract strict for business responses while letting infrastructure concerns such as auth, CORS, rate limits, malformed requests, and unexpected failures stay outside each route's response union. ## Client behavior Typed clients use the same contracts. A non-2xx route-owned response is parsed against the declared error response schema. Framework-owned errors use Beignet's standard `{ code, message, details?, requestId? }` envelope when the response includes Beignet's ownership header. Use `call()` when failures should throw `ContractError`. Use `safeCall()` when explicit result branching is clearer. See [Client](/client#error-handling) for narrowing and handling patterns. --- # Hooks Source: https://www.beignetjs.com/hooks Hooks run framework-level behavior around your route handlers. Beignet has two hook scopes: - Server hooks wrap every request for protocol and lifecycle behavior such as security headers, CSRF, CORS, logging, tracing, response shaping, and error observation. - Route hooks run only where they are attached and add route-specific context for authentication, tenancy, feature gates, idempotency, or audit scope. Use route hooks for HTTP boundary authentication and infrastructure checks: parse the session, reject routes that require a signed-in request, or enrich `ctx`. Keep business authorization in use cases or app-owned policy functions so the same ownership, role, tenant, or resource-state rule runs outside HTTP too. For the full auth story, read [Authentication](/authentication) and [Authorization](/authorization). For production observability and traffic protection, read [Deployment](/deployment), [Logging](/logging), [Error reporting](/error-reporting), and [Rate limiting](/rate-limiting). Server hooks are configured on the server: ```typescript export const getServer = createNextServerLoader(() => createNextServer({ ports, hooks: [loggingHooks, corsHooks, devtoolsHooks], context: appContextBlueprint, }), ); ``` Common global infrastructure concerns should use first-party server hook helpers when they fit: ```typescript import { createCorsHooks, createCsrfHooks, createErrorReportingHooks, createLoggingHooks, createSecurityHeadersHooks, } from "@beignet/core/server"; import type { AppContext } from "@/app-context"; const hooks = [ createSecurityHeadersHooks(), createCorsHooks({ origins: "*" }), createCsrfHooks(), createErrorReportingHooks(), createLoggingHooks({ logger }), ]; ``` `origins: "*"` is only for non-credentialed browser requests. When cookies or authorization headers are allowed cross-origin, pass an explicit origin list with `credentials: true`; Beignet rejects credentialed wildcard CORS during hook setup. `createSecurityHeadersHooks(...)` adds the framework's browser response-header baseline without guessing your CSP or HSTS policy. `createCsrfHooks(...)` protects unsafe methods with same-origin checks by default and can be configured with `allowMissingOrigin: false` plus double-submit cookie tokens for cookie-authenticated browser-only APIs. Provider webhooks and auth callbacks can be skipped with app-owned `skip` rules when another verifier owns the route. When the app sits behind a trusted edge, configure `trustedProxy: {}` on `createServer(...)`. CSRF origin checks then use the centrally resolved external host and protocol. A hook-local `trustedProxy` option is available only when this hook intentionally needs a different policy. Route hooks live beside route groups: ```typescript import { createAuthHooks } from "@beignet/core/server"; import { defineRouteGroup } from "@/lib/routes"; import type { AppContext } from "@/app-context"; const auth = createAuthHooks()({ resolve: ({ ctx }) => { return ctx.auth ? { user: ctx.auth.user } : null; }, }); export const postRoutes = defineRouteGroup({ name: "posts", hooks: [auth.optional()], routes: [ { contract: createPost, hooks: [auth.required()], useCase: createPostUseCase, }, ], }); ``` `createAuthHooks()` binds the app context; the inner call infers the added fields from `resolve`. When credentials live in request headers, declare a `headers` schema on the auth hooks so `resolve` receives typed header values; see [Authentication](/authentication) for the header-based and session-based variants. Binder routes pair hooks with use cases directly, without extra typing helpers: `auth.required()` guards the HTTP boundary, and the use case enforces the business rule from its own context. ## Lifecycle Hooks run in this order: 1. `onRequest` runs after route matching and before request parsing or context creation. 2. Request path, query, headers, and body are parsed and validated. 3. The `context.request` factory runs and the server attaches `ctx.gate` when the blueprint declares one. 4. Route hooks run and add route-specific context such as auth or tenancy. After every hook, the server re-attaches the gate so policies always see the current identity. 5. Server `beforeHandle` hooks run with the route-hook context, so metadata hooks such as user-scoped rate limits and actor-scoped idempotency see the finalized identity. 6. The route runs: the bound use case or the full `handle` implementation. 7. If a failure occurs, `onCaughtError` observes it and `mapUnhandledError` can map unknown or otherwise unhandled failures. 8. `beforeSend` can shape the response. Route-owned replacements are validated against the contract before they are returned. 9. `afterSend` observes completion. ## `onRequest` Use `onRequest` for raw request concerns that do not need parsed input or context. ```typescript const corsHooks = { name: "cors", onRequest: ({ req }) => { if (req.method === "OPTIONS") { return { status: 204, headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,PATCH,DELETE,OPTIONS", }, }; } }, }; ``` ## Route hooks Use route hooks when a policy or context addition belongs to one feature, route group, or route. Route hooks add fields to `ctx`; they should throw framework or application errors for denials instead of returning HTTP responses directly. Authentication hooks come from `createAuthHooks(...)`. Other route hooks are plain `RouteHook` object literals: ```typescript import { GateAuthorizationError } from "@beignet/core/ports"; import type { RouteHook } from "@beignet/core/server"; export const requireTenant: RouteHook = { name: "tenant.required", resolve: async ({ ctx }) => { const tenant = await ctx.ports.tenants.resolveTenant(ctx); if (!tenant) { throw new GateAuthorizationError("Tenant is required"); } return { tenant }; }, }; ``` Group hooks apply to every route in the group. Use the app-bound `defineRouteGroup({ ... })` from `lib/routes.ts` for all feature route groups, including groups whose hooks add fields to `ctx`; route hooks append after group hooks. ```typescript export const billingRoutes = defineRouteGroup({ name: "billing", hooks: [auth.required(), requireTenant], routes: [{ contract: listInvoices, useCase: listInvoicesUseCase }], }); ``` Full `handle` routes that read hook-added context fields should be wrapped in `defineRoute(...)` so `ctx` is enriched at compile time: ```typescript import { defineRoute } from "@/lib/routes"; defineRoute({ contract: createPost, hooks: [auth.required()], handle: async ({ ctx, body }) => { ctx.user.id; // typed because the hook is attached through defineRoute return { status: 201, body: await createPostUseCase.run({ ctx, input: body }) }; }, }); ``` ## `beforeHandle` Use server `beforeHandle` hooks when the behavior is global and should run for every route, such as response-wide infrastructure checks. Request instrumentation does not need a hook: the server owns request IDs, trace context, correlation headers, and request/error events through the `instrumentation` option on `createServer(...)`. See [request lifecycle](/request-lifecycle#request-instrumentation). Server `beforeHandle` can return a new `ctx`, a short-circuit `response`, or both. Prefer route hooks for route-specific policy because they are visible in the feature route group. ## Metadata-driven hooks Contracts can carry metadata for docs, OpenAPI, clients, and app conventions. Metadata is not enforcement by itself: a built-in server hook must read it. ```typescript export const createTodo = todos .post("/api/todos") .meta({ auth: "required", rateLimit: { max: 10, windowSec: 60 }, idempotency: { required: true, ttlSec: 60 * 60 * 24 }, }) .body(CreateTodoSchema) .responses({ 201: TodoSchema }); ``` `createRateLimitHooks(...)` enforces `meta.rateLimit` and `createIdempotencyHooks(...)` enforces `meta.idempotency`: ```typescript hooks: [ createRateLimitHooks(), createIdempotencyHooks(), ], ``` The bare `createRateLimitHooks()` call covers `global` and `user` scoped limits. Contracts that declare `rateLimit: { scope: "ip" }` require an explicit server-level `trustedProxy.clientIp`, hook-local `trustedProxy.clientIp`, `ipSource`, or `earlyKey` option. The server fails at startup otherwise instead of silently assigning every request to a contract-scoped unknown-client bucket: ```typescript createServer({ trustedProxy: { clientIp: "x-forwarded-for-last" }, hooks: [createRateLimitHooks()], // ... }); ``` See [Rate limiting](/rate-limiting) and [Idempotency](/idempotency) for the metadata shapes and error semantics. For metadata without a built-in hook, such as `auth`, prefer explicit route hooks for runtime enforcement: ```typescript hooks: [auth.required()] ``` ## `beforeSend` and `afterSend` Use `beforeSend` to add headers, shape framework-owned responses, or make last-mile route response changes that still match the contract. Use `afterSend` for logging, metrics, and tracing. ```typescript const loggingHooks = { name: "logging", beforeSend: ({ response }) => ({ ...response, headers: { ...response.headers, "x-beignet": "1", }, }), afterSend: ({ req, response, durationMs }) => { console.info(req.method, response.status, durationMs); }, }; ``` ### Native responses and hooks When a route returns a native web `Response`, `beforeSend` still runs with `native: true` and a headers-only view (`{ status, headers }`). The body is not readable, and returned body or status changes are ignored with a one-time dev warning; header changes are merged onto the native `Response` without buffering the stream. This is how CORS, `x-request-id`, and `traceparent` headers reach streamed responses. Streamed responses are also not idempotency-replayable; see [Idempotency](/idempotency). ## Error handling Hook-thrown errors, handler-thrown unknown errors, and handler-thrown `AppError` instances are passed to `onCaughtError` observers. `AppError` instances are auto-mapped before `mapUnhandledError`; `mapUnhandledError` only maps unknown or otherwise unhandled failures. See [Errors](/errors) for observing and mapping them. Hook short-circuit responses and `mapUnhandledError` responses are framework-owned, so they skip route response validation. See [Request lifecycle](/request-lifecycle#response-ownership) for the response ownership taxonomy. --- # Errors Source: https://www.beignetjs.com/errors This page owns the server-side error story: the app error catalog, `AppError`, cause preservation, and unhandled-error mapping. For handling failed calls on the client with `ContractError`, see [Client](/client#error-handling). Use [Error reporting and alerting](/error-reporting) for production exception capture and alerting. Error catalogs describe expected application failures; they are not a replacement for production incident reporting. ## Error shape Framework-owned errors use a standard envelope: ```json { "code": "VALIDATION_ERROR", "message": "Invalid request body", "details": { "contract": "todos.create", "method": "POST", "path": "/api/todos", "location": "body", "issues": [ { "path": ["title"], "message": "Required" } ] } } ``` Validation and response-contract diagnostics include the contract name, HTTP method, contract path, and failing location when Beignet can identify them. Some framework-owned errors may also include a top-level `requestId` when your server context exposes one. Route-owned error responses can use any schema declared in `contract.responses`. Using `{ code, message, details? }` for route-owned errors keeps your application errors consistent with framework errors. Framework-owned upload, webhook, payment-webhook, schedule, and outbox-drain routes use the same flat shape. Their operation-specific `code` values are stable for programmatic handling; operation context such as a schedule name is carried in `details` rather than changing the envelope. ## Define an error catalog ```typescript import { createAppError, defineErrors } from "@beignet/core/errors"; import { z } from "zod"; export const errors = defineErrors({ TodoNotFound: { code: "TODO_NOT_FOUND", status: 404, message: "Todo not found", details: z.object({ id: z.string() }), }, Unauthorized: { code: "UNAUTHORIZED", status: 401, message: "You must be signed in", }, Forbidden: { code: "FORBIDDEN", status: 403, message: "You cannot perform this action", }, }); export const appError = createAppError(errors); ``` Declare catalog errors on contracts with `.errors()`. Beignet maps them to the standard `{ code, message, details?, requestId? }` response envelope automatically: ```typescript export const getTodo = todos .get("/api/todos/:id") .responses({ 200: TodoSchema }) .errors({ TodoNotFound: errors.TodoNotFound }); ``` Catalog errors can also be declared on a contract group with `defineContractGroup().errors(...)`. Group errors merge with route-level `.errors(...)`, so contracts carry the union of both; later declarations win when the same catalog key is declared twice. The optional `details` schema types `appError()` calls and client-side `error.details` after narrowing by catalog code. Beignet does not automatically redact route-owned error details. Treat `message` and `details` as public client response data. Put stable IDs, field names, ability names, or operation names there; keep stack traces, provider errors, SQL, secrets, tokens, PHI, private content, and raw request bodies in `cause`, logs, or error reporting instead. ## Throw `AppError` Throw `AppError` from use cases, handlers, or domain/core/application code when you want a typed HTTP failure. ```typescript import { defineRouteGroup } from "@/lib/routes"; import { useCase } from "@/lib/use-case"; export const getTodoUseCase = useCase .query("todos.get") .input(GetTodoInputSchema) .output(TodoSchema) .run(async ({ ctx, input }) => { const todo = await ctx.ports.todos.findById(input.id); if (!todo) { throw appError("TodoNotFound", { details: { id: input.id } }); } return todo; }); export const todoRoutes = defineRouteGroup({ name: "todos", routes: [{ contract: getTodo, useCase: getTodoUseCase }], }); ``` `AppError` instances thrown by a bound use case or a route handler are route-owned. If the route declares response schemas, the generated response must match the schema for that status. ## Preserve causes Use `cause` for debugging without exposing internal errors to clients. ```typescript import { AppError, httpErrors } from "@beignet/core/errors"; try { await db.query(...); } catch (dbError) { throw new AppError( httpErrors.InternalServerError, { operation: "todos.query" }, undefined, { cause: dbError }, ); } ``` Helper-created `AppError`s support the same option: ```typescript throw appError("InternalServerError", { details: { operation: "todos.query" }, cause: dbError, }); ``` The `cause` is available via `error.cause` and is never exposed to the client by Beignet. `details` and `message` are public response fields when the error crosses the HTTP boundary, so only include values that are safe for clients. ## Set response headers on errors Pass `headers` when an error response should carry standard HTTP headers, such as `Retry-After` on a 429. The server merges them onto the mapped response; like `details`, headers are public response data. ```typescript import { AppError, httpErrors } from "@beignet/core/errors"; throw new AppError( httpErrors.TooManyRequests, { retryAfterSeconds: 30 }, undefined, { headers: { "Retry-After": "30" } }, ); ``` Helper-created errors accept the same option: `appError("Forbidden", { headers: { ... } })`. The framework's own `createRateLimitHooks` denials use this to set `Retry-After` automatically whenever the limiter reports a reset time. ## Observe and map unhandled errors Use the server's `onCaughtError` option for logging, metrics, and tracing: it observes caught failures without changing response behavior. The server-level `mapUnhandledError` callback maps unknown or otherwise unhandled exceptions after declared `AppError` instances are auto-mapped. ```typescript onCaughtError: ({ err, req, ctx }) => { console.error("Caught error:", err); console.error("Request:", req.method, new URL(req.url).pathname); console.error("Request ID:", ctx?.requestId); }, mapUnhandledError: ({ ctx }) => { return { status: 500, body: { code: "INTERNAL_SERVER_ERROR", message: "Internal server error", requestId: ctx?.requestId, }, }; }, ``` `mapUnhandledError` responses are framework-owned; see [Request lifecycle](/request-lifecycle#response-ownership) for the ownership taxonomy. ## Errors on the client Catalog errors cross the HTTP boundary as the same `{ code, message, details?, requestId? }` envelope, and framework-owned responses carry the `x-beignet-error-owner: framework` header so clients can tell them apart from route-owned errors with the same status. On the client they surface as `ContractError`, with `isError(...)` narrowing by catalog code, status, or source, and `safeCall()` for result-style handling. See [Client](/client#error-handling) for the full client-side story. ## Map errors to UI Use `contractErrorMessage` from `@beignet/core/client` to turn a failed call into user-facing copy. Non-`ContractError` values return the fallback, client-side input validation failures return a generic "check the highlighted fields" message, and catalog codes can override copy per call site: ```typescript import { contractErrorMessage } from "@beignet/core/client"; const message = contractErrorMessage(error, "Could not update profile.", { HANDLE_UNAVAILABLE: "That handle is already taken.", }); ``` For React Hook Form, `rootFormError` from `@beignet/react-hook-form` wraps the same mapping in the `form.setError("root", ...)` shape; see [React Hook Form](/react-hook-form). Use catalog codes for product-specific copy while keeping the default framework message for ordinary route-owned errors. --- # Clients Source: https://www.beignetjs.com/client The client gives you a fully typed HTTP client derived from your contracts. No code generation — just TypeScript inference. Use the client directly in scripts, tests, Server Components, and non-React code. In React UI, you usually consume the same endpoints through [React Query](/react-query), which wraps this client and inherits its error semantics. ## Creating a client ```typescript import { createClient } from "@beignet/core/client"; import { getTodo, listTodos, createTodo } from "@/features/todos/contracts"; const client = createClient(); export const getTodoEndpoint = client.endpoint(getTodo); export const listTodosEndpoint = client.endpoint(listTodos); export const createTodoEndpoint = client.endpoint(createTodo); ``` Pass the contract builder you exported from the feature's `contracts.ts`. Reaching for `contract.config` is only needed when you are integrating with code that cannot accept Beignet's contract-like builder shape. `createClient()` uses Next-friendly defaults for the base URL in browser modules. The route types come from the contract you pass to `client.endpoint(contract)`, not from client construction. ## Making requests ### GET with path parameters ```typescript const todo = await getTodoEndpoint.call({ path: { id: "123" }, }); console.log(todo.title); // fully typed ``` ### GET with query parameters ```typescript const result = await listTodosEndpoint.call({ query: { completed: true, limit: 10, offset: 0, }, }); console.log(result.items); // Todo[] ``` The client uses the query transport declared by the contract. Scalar arrays become repeated parameters, flat objects use `deepObject` bracket names, and numbers, booleans, and dates use ordinary URI values. With `validateInput: true`, the client validates the query against its Standard Schema but still serializes the schema input values; a schema transform does not silently change the wire format. Empty arrays and objects are omitted unless their transport opts into `{ empty: "preserve" }`. Preservation uses a versioned Beignet extension, so use it only when Beignet typed clients must distinguish empty from omitted. Standard OpenAPI clients treat those values as equivalent. ### POST with a body ```typescript const newTodo = await createTodoEndpoint.call({ body: { title: "New todo", completed: false, }, }); console.log(newTodo.id); // string ``` ### With custom headers ```typescript const todo = await getTodoEndpoint.call({ path: { id: "123" }, headers: { authorization: `Bearer ${token}`, }, }); ``` ### With AbortSignal ```typescript const controller = new AbortController(); const todo = await getTodoEndpoint.call({ path: { id: "123" }, signal: controller.signal, }); // Cancel with controller.abort() ``` React Query automatically passes its signal through `queryOptions()`, so cancellation works out of the box. ## Error handling `call()` returns the response body on success and throws a `ContractError` on non-2xx responses and local client failures. The endpoint's `isError` type guard is the recommended way to handle thrown errors — it narrows the status and gives you access to typed helpers. If a contract declares any `responses`, successful response statuses are treated as exhaustive. For example, a contract with only `401` and `404` responses declared will reject a `200` response as undeclared; use `responses: {}` when you want to skip response validation. `ContractError.source` tells you whether the failure came from `"http"` (a non-2xx server response), `"client"` (local request preparation or validation), `"network"` (a failed fetch), or `"contract"` (a malformed or contract-invalid response). Expected schema failures keep their specific validation codes. An unexpected request-preparation failure uses `CLIENT_ERROR`, a fetch rejection uses `NETWORK_ERROR`, and an unexpected response-processing failure uses `RESPONSE_PROCESSING_ERROR` while preserving the native response and status when available. Use `hasSource()` or object-form `isContractError()` when that distinction matters. For declared route-owned error responses, `error.body` is the parsed and validated response body. Framework-owned errors use Beignet's standard `{ code, message, details?, requestId? }` envelope when the response includes `x-beignet-error-owner: framework`. Native transport responses can also produce text or an empty body. `error.details` is only the nested `details` field from that envelope or local validation details. If a server returns a non-2xx status that does not match the declared route error schema and does not include Beignet's ownership header, the client treats it as a contract failure instead of guessing ownership. Code-based narrowing such as `{ code: "TODO_NOT_FOUND" }` comes from catalog errors declared on the contract with `.errors(...)`; see [Errors](/errors) for defining the catalog. ```typescript const getTodoEndpoint = apiClient.endpoint(getTodo); try { await getTodoEndpoint.call({ path: { id: "123" } }); } catch (err) { if (getTodoEndpoint.isError(err, { code: "TODO_NOT_FOUND" })) { // err.status and err.details are narrowed from the catalog entry console.log("Not found:", err.message); console.log("Details:", err.details); } else if (getTodoEndpoint.isError(err, { status: 404, source: "http" })) { console.log("Body:", err.body); } else if (getTodoEndpoint.isError(err)) { if (err.hasSource("client") && err.hasCode("INPUT_VALIDATION_ERROR")) { console.log("Invalid input:", err.details); } } } ``` Use `safeCall()` when you want explicit result handling instead of exceptions: ```typescript const result = await getTodoEndpoint.safeCall({ path: { id: "123" }, }); if (result.ok) { console.log(result.data.title); } else if (getTodoEndpoint.isError(result.error, { status: 404, source: "http" })) { console.log("Not found:", result.error.body); } else { console.error(result.error.message); } ``` React Query integration uses `call()` because TanStack Query already models failures through its error channel. When you do not have the endpoint in scope, `ContractError` is exported from `@beignet/core/client`, so `error instanceof ContractError` plus the `.hasStatus()`, `.hasCode()`, and `.hasSource()` methods work anywhere. To turn a failed call into user-facing copy, use `contractErrorMessage` with per-call-site catalog overrides; see [Errors](/errors#map-errors-to-ui). ## Configuration ### Global headers ```typescript const client = createClient({ headers: () => ({ "x-api-version": "1.0", }), providedHeaders: ["x-api-version"] as const, }); ``` Headers can be a function (sync or async) so you can inject tokens dynamically. Header keys are normalized to lowercase. Use `providedHeaders` when required contract headers are supplied globally; those keys become optional at call sites while `validateInput: true` still validates the final merged headers. ### Custom fetch ```typescript const client = createClient({ fetch: customFetch, }); ``` ### Input validation Enable `validateInput: true` to validate path params, query params, request bodies, and declared request headers against your contract schemas before sending the request. This catches malformed requests early without a round-trip. Input validation is off by default. Path params and request bodies serialize the parsed values returned by their schemas. Query parameters are different: the client serializes the original logical input according to the contract's explicit query transport. This keeps schema transforms from becoming an accidental HTTP format while the server still applies them after transport decoding. Values that do not match their declared transport fail locally with `INVALID_QUERY_PARAM`. ```typescript const client = createClient({ validateInput: true, }); ``` Input validation failures never reach the network, so they throw a client-source `ContractError` with code `INPUT_VALIDATION_ERROR`. There is no HTTP response to attach: `status`, `body`, and `response` are all `undefined`, and `details` holds the schema issues. ```typescript try { await createTodoEndpoint.call({ body: { title: "" } }); } catch (err) { if (createTodoEndpoint.isError(err, { code: "INPUT_VALIDATION_ERROR" })) { console.log("Invalid input:", err.details); } } ``` If the body schema accepts `undefined` such as `z.object({ ... }).optional()`, you can omit `body` entirely and the client will send no request body. ### Response validation Response validation is on by default: success bodies are validated against the declared response schema, declared error responses are validated the same way, and undeclared statuses are rejected. Set `validateResponses: false` to opt out. ```typescript const client = createClient({ validateResponses: false, }); ``` With response validation off, success bodies are returned as-is and undeclared statuses are accepted. Non-2xx responses still throw: the client classifies the error structurally, keeping the response status and using the body's `code`, `message`, and `details` when present, falling back to `HTTP_ERROR`. Code-based narrowing such as `isError(err, { code: "TODO_NOT_FOUND" })` keeps working. You forfeit contract-drift detection — a server response that no longer matches your contract flows through silently instead of failing with `RESPONSE_VALIDATION_ERROR` or `UNDECLARED_RESPONSE_STATUS`. A response that fails to parse as JSON still throws `INVALID_JSON`; that is a transport failure, not validation. Request bodies are supported for `POST`, `PUT`, and `PATCH` contracts. Passing `body` or `rawBody` to `GET`, `HEAD`, `DELETE`, or `OPTIONS` contracts throws `INVALID_REQUEST_BODY`. ### Raw request bodies Use `body` for contract-validated JSON requests. Use `rawBody` only when the transport body should be sent as-is, such as `FormData`, `Blob`, `ArrayBuffer`, a stream, or pre-serialized text. ```typescript const formData = new FormData(); formData.set("avatar", file); await uploadAvatarEndpoint.call({ rawBody: formData, }); ``` `rawBody` is not schema-validated or JSON-serialized, and the client does not add `Content-Type: application/json` for it. Text responses are parsed as strings, so a route can declare `z.string()` for a `text/plain` response. For binary downloads or streaming responses, use a transport-owned route that returns a native `Response` and call it with platform `fetch`. A contract with `.responses({ 200: null })` declares that the typed client should see an empty body; it is the right OpenAPI shape for file and stream routes, not a typed payload reader for bytes. --- # OpenAPI Source: https://www.beignetjs.com/openapi The `@beignet/core/openapi` subpath generates an OpenAPI 3.1 document from your contracts. You need this page when publishing your API to external consumers or generating client SDKs; Beignet's typed client does not need OpenAPI. Core contracts, the server, and the client work with any Standard Schema-compatible library for runtime validation. OpenAPI generation needs a schema introspector so it can read object shapes, descriptions, and optional fields. Zod v4 is supported by default through `createZodIntrospector()`. ```bash bun add @beignet/core zod ``` ## Generating a spec Pass your contracts and metadata to `contractsToOpenAPI`: ```typescript import { contractsToOpenAPI } from "@beignet/core/openapi"; import { getTodo, createTodo, listTodos } from "./contracts"; const spec = contractsToOpenAPI( [getTodo, createTodo, listTodos], { title: "Todo API", version: "1.0.0", description: "A simple todo API", }, ); ``` The result is a plain JavaScript object conforming to the OpenAPI 3.1 specification. Serialize it to JSON or YAML as needed. Pass the same contract builders used by the server and client. You normally do not need to extract `.config`; OpenAPI generation accepts Beignet's contract-like builder shape directly. ## Custom schema introspection Contracts built with non-Zod schemas keep working with the server and client. To document them, pass `contractsToOpenAPI` a `schemaIntrospector` for object shapes and a `schemaConverters` entry for JSON Schema conversion, or provide equivalent Zod schemas for the documented routes when that is simpler: ```typescript import type { SchemaConverter, SchemaIntrospector } from "@beignet/core/openapi"; import { contractsToOpenAPI } from "@beignet/core/openapi"; type MySchema = { description?: string; fields?: Record; inner?: MySchema; optional?: boolean; toJSONSchema(): Record; }; function isSchema(schema: unknown): schema is MySchema { return ( typeof schema === "object" && schema !== null && "toJSONSchema" in schema ); } const schemaIntrospector: SchemaIntrospector = { getShape(schema) { return isSchema(schema) ? schema.fields : undefined; }, getDescription(schema) { return isSchema(schema) ? schema.description : undefined; }, isOptional(schema) { return isSchema(schema) && schema.optional === true; }, unwrapOptional(schema) { return isSchema(schema) && schema.inner ? schema.inner : schema; }, }; const schemaConverter: SchemaConverter = { name: "my-schema", canConvert: isSchema, toJSONSchema(schema) { return isSchema(schema) ? schema.toJSONSchema() : {}; }, }; const spec = contractsToOpenAPI(contracts, { title: "Todo API", version: "1.0.0", schemaIntrospector, schemaConverters: [schemaConverter], }); ``` The introspector and converter do not perform runtime validation. They only teach the OpenAPI generator how to inspect schema metadata and emit JSON Schema. When a `pathParams` Standard Schema cannot be introspected, Beignet still emits every parameter from the literal path template as a required string. This matches server startup, which cannot compare opaque schema keys to the template. Introspectable object schemas remain strict: missing or extra path parameter keys fail both server registration and OpenAPI generation. Provide a custom introspector when the document needs richer path schemas or descriptions. ## Serving from a route Expose the spec as a JSON endpoint: ```typescript // app/api/openapi/route.ts import { createOpenAPIHandler } from "@beignet/next"; import { env } from "@/lib/env"; import { contracts } from "@/server/routes"; export const GET = createOpenAPIHandler(contracts, { title: "My API", version: "1.0.0", servers: [{ url: env.APP_URL }], }); ``` Export `contracts = contractsFromRoutes(routes)` from `server/routes.ts` so the OpenAPI route can serve the same route list as the runtime without booting providers during Next build-time route imports. If your app uses per-file Next route handlers with `server.route(contract).handle(...)`, those files are not imported by the server automatically. In that style, add those contracts to the explicit contract list you pass to `createOpenAPIHandler(...)`. Prefer a configured server URL such as `env.APP_URL` for deployed docs. `inferServersFromRequest: true` is available for local or internal routes, but it reflects the request origin into the OpenAPI document. An OpenAPI document intentionally enumerates the contract surface, including routes whose runtime handlers require authentication or API keys. For sensitive or internal APIs, protect the spec and documentation endpoints with app-owned authorization or do not expose them publicly. Beignet does not infer endpoint access control from contract metadata. The `@beignet/next` Swagger convenience handler loads version-pinned Swagger UI assets from `unpkg.com` with subresource-integrity checks. Static and request-derived spec URLs are serialized with HTML-safe JSON escaping before entering the inline script. A strict Content Security Policy or network-isolated deployment should use a custom, self-hosted CSP-compatible documentation UI instead. The built-in page also emits inline script and style blocks, so allowing the pinned asset host alone is not sufficient for a strict policy. ## Operation metadata Use `.openapi(...)` on contracts to customize generated operation metadata. ```typescript export const getTodo = todos .get("/api/todos/:id") .pathParams(z.object({ id: z.string() })) .responses({ 200: TodoSchema }) .errors({ TodoNotFound: errors.TodoNotFound }) .openapi({ summary: "Get a todo", description: "Fetch one todo by ID.", tags: ["todos"], operationId: "getTodo", }); ``` OpenAPI metadata merges one field deep across `.openapi(...)` and `.meta({ openapi: ... })` calls. This lets a contract group provide shared tags or security requirements while an individual contract adds its summary and operation ID. Later calls replace only OpenAPI fields with the same name; structured fields such as `responses` are replaced as a whole rather than deep-merged. Prefer `.openapi(...)` when setting operation metadata directly. The generated `operationId` defaults to the contract name. Set it explicitly when external clients need a stable identifier. Generated SDKs often use `operationId` as a method name, so changing it can be a breaking change even when the HTTP path stays the same. Server registration and OpenAPI generation reject duplicate operation IDs. Treat a published contract name or explicit `operationId` as durable API surface. Path template parameters are emitted as required string parameters by default. Add `.pathParams(...)` when you want specific schemas, descriptions, runtime validation, or coercion. If `.pathParams(...)` is present, its keys must match the path template exactly. Query-field and request-header descriptions are preserved whether `.describe(...)` is applied before or after `.optional()`. Query parameters include explicit OpenAPI serialization metadata. Primitive and scalar-array fields use `style: "form"` with `explode: true`; flat object fields use `style: "deepObject"` with `explode: true`. Arrays therefore repeat their parameter name, and object properties use bracket names such as `filter[owner]=user_1`. This metadata comes from the explicit query transport passed to `.query(schema, transport)`, which also drives Beignet's typed client and server. Query arrays may contain scalars, and `deepObject` values must be flat; Beignet rejects shapes that OpenAPI cannot represent consistently. The schema introspector still supplies requiredness, descriptions, and validation constraints. Its top-level query fields must exactly match the transport fields. OpenAPI generation fails when the query schema is opaque because silently omitting those parameters would publish a document that does not match the endpoint. Provide a schema introspector for non-Zod query schemas. Empty arrays and objects are omitted by default. A transport configured with `{ empty: "preserve" }` adds `x-beignet-empty-query: "v1"` and uses Beignet's versioned typed-client extension. Generated OpenAPI clients still treat empty and omitted collections as the same value unless they implement that extension. Request bodies are supported for `POST`, `PUT`, and `PATCH` contracts only. OpenAPI generation rejects body schemas on other methods. Request headers declared with `.headers(...)` are generated as OpenAPI parameters with `in: "header"`. Header names should be declared in lowercase in contracts; HTTP header matching remains case-insensitive. Catalog errors declared with `.errors(...)` use the standard error envelope in OpenAPI. The generator emits catalog `code` values as literal schemas, includes declared `details` schemas, uses catalog messages as response descriptions, and adds named examples with each catalog `code` and `message`. OpenAPI describes contract-owned responses. Framework-owned failures added by runtime composition — for example request validation, authentication hooks, rate limiting, or adapter limits — are not attached universally because the active hooks and adapters differ by deployment. Document those shared gateway responses at the API level when external consumers need them; keep route-specific business failures in `.errors(...)` so generated clients can narrow them precisely. ## Compatibility for external clients Beignet's typed client is the best internal TypeScript client because it calls the same contract builders your server uses. OpenAPI is the public client surface for teams that need generated SDKs, API gateways, partner docs, or non-TypeScript consumers. Keep both surfaces aligned by treating the contract as the source of truth and `.openapi(...)` as the place for transport details the runtime schema cannot describe. Contract names already provide stable default operation IDs; set an explicit `operationId` when the external SDK method should differ from the internal contract name. Prefer path-prefixed groups such as `/api/v1/issues` for breaking request or response changes. Mark old contracts while they are still served: ```typescript import { defineContractGroup } from "@beignet/core/contracts"; import { z } from "zod"; const v1Issues = defineContractGroup() .namespace("legacyIssues") .prefix("/api/v1/issues"); export const listIssuesV1 = v1Issues .get("/", "list") .deprecated({ since: "2026-07-11T00:00:00Z", sunset: "2027-01-01T00:00:00Z", reason: "Use the current issues collection.", replacement: "/api/issues", documentation: "https://docs.example.com/migrations/issues-v1", }) .responses({ 200: z.object({ items: z.array(z.object({ id: z.string(), title: z.string() })), }), }); ``` OpenAPI emits `deprecated: true` plus `x-beignet-deprecation` with the complete lifecycle metadata. Runtime responses emit the standard `Deprecation` and optional `Sunset` and documentation `Link` headers. A bare `.openapi({ deprecated: true })` remains available for documentation-only operations, but it does not opt the runtime into lifecycle headers. Response changes should be additive for existing status codes. Adding optional fields or new error statuses is usually compatible. Removing fields, changing field types, changing status codes, or reusing an `operationId` for a different shape should be treated as a breaking change and moved to a new route version. For external SDK generation, export the OpenAPI JSON from the same contract list registered on the server. Beignet does not generate third-party SDKs itself; use your preferred OpenAPI generator against that document. ## Generate an external TypeScript client Use Beignet's contract-aware client inside the application that owns the contracts. For a separate TypeScript application, generate types from the published OpenAPI document and pair them with an OpenAPI client: ```bash bun add openapi-fetch bun add --dev openapi-typescript typescript bunx openapi-typescript https://api.example.com/api/openapi --output src/generated/api.ts ``` Create the client with the generated `paths` type: ```typescript import createClient from "openapi-fetch"; import type { paths } from "./generated/api"; export const api = createClient({ baseUrl: "https://api.example.com", }); const { data, error, response } = await api.GET("/api/issues/{id}", { params: { path: { id: "issue_123" } }, headers: { authorization: "Bearer replace-with-access-token" }, }); if (error) { console.error(response.status, error.code, error.message); } else { console.log(data.title); } ``` Paths, path and query parameters, JSON bodies, success responses, and declared catalog errors are inferred from the generated document. Authentication credentials remain request configuration: an OpenAPI security scheme tells tools which authentication mechanism an operation uses, but it does not supply the credential. ### Generate upload and download types OpenAPI represents uploaded and downloaded bytes as `string` schemas with `format: "binary"`. Map that format to `Blob` when the generated client should accept files directly: ```typescript // scripts/generate-api-types.ts import { mkdir, writeFile } from "node:fs/promises"; import openapiTS, { astToString } from "openapi-typescript"; import ts from "typescript"; const schemaUrl = process.env.OPENAPI_URL ?? "https://api.example.com/api/openapi"; const ast = await openapiTS(new URL(schemaUrl), { transform(schema) { if (schema.format === "binary") { return ts.factory.createTypeReferenceNode("Blob"); } }, }); await mkdir("src/generated", { recursive: true }); await writeFile("src/generated/api.ts", astToString(ast)); ``` Serialize a typed upload body as `FormData` and select the parser for a binary response: ```typescript const file = new File(["example attachment"], "notes.txt", { type: "text/plain", }); const upload = await api.POST("/api/issues/{id}/attachments", { params: { path: { id: "issue_123" } }, body: { file }, bodySerializer(body) { const formData = new FormData(); formData.set("file", body.file, file.name); return formData; }, }); if (upload.error) { throw new Error(`Upload failed with ${upload.response.status}`); } const download = await api.GET( "/api/issues/{id}/attachments/{attachmentId}/download", { params: { path: { id: "issue_123", attachmentId: upload.data.attachmentId, }, }, parseAs: "arrayBuffer", }, ); ``` Use `parseAs: "text"` for documented text responses. The default parser is JSON. ### Detect contract drift in CI Commit generated client types when consumers should review API changes in the same pull request as their code. Regenerate them and fail on a diff: ```json { "scripts": { "api:generate": "bun scripts/generate-api-types.ts", "api:check": "bun run api:generate && git diff --exit-code -- src/generated/api.ts", "typecheck": "tsc --noEmit" } } ``` Run both `bun run api:check` and `bun run typecheck` in CI. Point `OPENAPI_URL` at a versioned schema artifact or the deployment being promoted, not an unrelated mutable production deployment. A generated diff is an API review signal; classify it using the compatibility rules above before accepting it. ## Security Pass global security schemes to `contractsToOpenAPI`, then attach operation-level security with `.openapi(...)`. ```typescript const spec = contractsToOpenAPI(contracts, { title: "Todo API", version: "1.0.0", securitySchemes: { bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT", }, }, security: [{ bearerAuth: [] }], }); export const publicHealth = system .get("/api/health") .responses({ 200: HealthSchema }) .openapi({ summary: "Health check", security: [{}], }); ``` Use `security: [{}]` to mark a route as public when the document has global security. ## Non-JSON media Beignet contracts still own runtime validation for JSON and typed text responses. Use `.openapi(...)` overrides when the wire format cannot be described as ordinary JSON, such as multipart uploads, file downloads, or event streams. ```typescript export const uploadAttachment = files .post("/api/attachments") .body(UploadIntentSchema) .responses({ 201: AttachmentSchema }) .openapi({ requestBody: { required: true, content: { "multipart/form-data": { schema: { type: "object", properties: { file: { type: "string", format: "binary" }, }, required: ["file"], }, }, }, }, }); export const downloadAttachment = files .get("/api/attachments/:id/download") .pathParams(z.object({ id: z.string() })) .responses({ 200: null }) .openapi({ responses: { "200": { description: "Attachment bytes", content: { "application/octet-stream": { schema: { type: "string", format: "binary" }, }, }, }, }, }); ``` Use `.responses({ 200: null })` plus a response media override when the route is transport-owned, such as private downloads, byte streams, Server-Sent Events, or `application/x-ndjson`. Runtime handlers should return a native `Response` for those cases, and consumers should use platform `fetch` because a `null` response schema means the typed client expects an empty body. For caller-owned request transports such as `FormData`, send them with the typed client's `rawBody`; see [Client](/client) for the request body rules. ## Deprecated operations ```typescript export const oldGetTodo = todos .get("/api/v1/todos/:id") .pathParams(z.object({ id: z.string() })) .responses({ 200: TodoSchema }) .openapi({ summary: "Get a todo using the old route", deprecated: true, }); ``` ## Options | Option | Type | Description | |--------|------|-------------| | `title` | `string` | API title (required) | | `version` | `string` | API version (required) | | `description` | `string?` | API description | | `servers` | `{ url, description? }[]?` | Server URLs | | `securitySchemes` | `Record?` | Auth schemes | | `security` | `Record[]?` | Global security requirements | | `jsonMediaType` | `string?` | Media type for JSON bodies (default: `"application/json"`) | | `schemaIntrospector` | `SchemaIntrospector?` | Schema metadata adapter. Defaults to Zod. | | `schemaConverters` | `SchemaConverter[]?` | Custom schema-to-JSON-Schema converters. Custom converters run before the default Zod converter. | ## What gets generated The generator extracts from each contract: - **Path parameters** → `parameters` with `in: "path"` - **Query parameters** → `parameters` with `in: "query"` - **Request headers** → `parameters` with `in: "header"` - **Request body** → `requestBody` with JSON schema - **Responses** → status codes with JSON schema (or empty for 204) - **Metadata** → `tags`, `summary`, `description`, `operationId` from contract metadata Schemas are placed in `components/schemas` and referenced via `$ref` to avoid duplication. --- # Domain modeling Source: https://www.beignetjs.com/domain The `@beignet/core/domain` subpath provides small helpers for domain-driven design: entities and value objects. These helpers are optional. Plain TypeScript objects and functions are fine domain code; reach for the helpers when validation or immutable-update patterns start repeating across a feature. ```bash bun add @beignet/core ``` ## Placement Put domain code with the feature that owns the business concept: ```txt features/posts/domain/post.ts features/posts/domain/events/published.ts features/comments/domain/events/comment-added.ts ``` Use `features/shared/domain/` only for true shared-kernel concepts that are genuinely used by multiple features, such as `EmailAddress`, `Money`, or `TenantId`. Do not put feature-specific domain code there. ## Value objects Immutable, validated types that represent a concept with no identity (e.g. an email address, a currency amount): ```typescript import { defineValueObject } from "@beignet/core/domain"; import { z } from "zod"; const Email = defineValueObject("Email") .schema(z.string().email()) .build(); const email = await Email.create("user@example.com"); // validated string await Email.isValid("not-an-email"); // false ``` ## Entities Domain objects with identity and behavior. Entities are immutable — methods return new instances: ```typescript import { defineEntity } from "@beignet/core/domain"; import { z } from "zod"; const Todo = defineEntity("Todo") .props(z.object({ id: z.string(), title: z.string(), completed: z.boolean(), })) .methods((self) => ({ complete: () => self.with({ completed: true }), rename: (title: string) => self.with({ title }), })) .build(); const todo = await Todo.create({ id: "1", title: "Buy milk", completed: false }); const done = await todo.complete(); // new instance with completed: true ``` Every entity gets a `.with()` method for partial updates, returning a new instance. ## Domain events Feature event declarations live in `features//domain/events/`, but the event APIs are owned elsewhere: declare events with `defineEvent(...)` from [Events](/events), and emit them from use cases with `.emits(...)` as shown in [Use cases](/application#emitting-domain-events). ## Schema libraries Both helpers work with any [Standard Schema](https://github.com/standard-schema/standard-schema) library — Zod, Valibot, ArkType, etc. --- # Authentication Source: https://www.beignetjs.com/authentication Authentication answers "who is making this request?" In Beignet, the recommended shape is: 1. Auth provider or app adapter installs an auth port. 2. Hooks enforce route-level authentication at the HTTP boundary. 3. The session and request actor are added to context. 4. Use cases call `requireUser(ctx)` from `@beignet/core/ports` when a workflow needs a signed-in user. Authorization is separate. It answers whether that user may perform a specific business action. See [Authorization](/authorization). ## Auth port Beignet apps use the shared `AuthPort` shape from `@beignet/core/ports`. Production apps can replace the anonymous adapter with Better Auth or another session system without changing hooks or use cases. ```typescript import type { AuthPort, AuthSession } from "@beignet/core/ports"; export type AuthUser = { id: string; email?: string; }; export type AppAuthSession = AuthSession; export type AppAuthPort = AuthPort; ``` Keep this as an app-facing interface. Your use cases and hooks should not need to know whether the user came from Better Auth, JWT, a session cookie, or a test adapter. ## Route metadata Contracts can describe authentication requirements as metadata for OpenAPI, docs, clients, and conventions: ```typescript export const createPost = posts .post("/") .meta({ auth: "required" }) .body(CreatePostInput) .responses({ 201: PostOutput }); ``` Metadata is not security by itself. Runtime enforcement should be visible in route wiring with route hooks. ## HTTP boundary hooks Use `createAuthHooks(...)` to reject unauthenticated requests before the route handler runs: ```typescript import { createAuthHooks } from "@beignet/core/server"; import { defineRouteGroup } from "@/lib/routes"; import type { AppContext } from "@/app-context"; export const auth = createAuthHooks()({ resolve: ({ ctx }) => { if (!ctx.auth) return null; return { user: ctx.auth.user }; }, }); ``` The outer call binds the app context; the inner call takes the auth options and infers the added context fields from what `resolve` returns. The helper returns explicit route-hook factories: | Hook | Behavior | | --- | --- | | `auth.public()` | Mark the route as intentionally public | | `auth.optional()` | Resolve auth when present and add optional auth fields to `ctx` | | `auth.required()` | Resolve auth, return a framework-owned `401` when missing, and add authenticated fields to `ctx` | Attach those hooks in feature route groups: ```typescript export const postRoutes = defineRouteGroup({ name: "posts", hooks: [auth.optional()], routes: [ { contract: listPosts, useCase: listPostsUseCase }, { contract: createPost, hooks: [auth.required()], useCase: createPostUseCase, }, ], }); ``` The hook guards the HTTP boundary; the bound use case reads the resolved session from its own context (for example through `requireUser(ctx)` from `@beignet/core/ports`). Full `handle` routes that need hook-added fields typed on `ctx` can wrap the route in `defineRoute`; binder routes (routes registered as `{ contract, useCase }` — see [Server](/server)) do not need it. Auth failures are framework-owned, so your business contract does not need to declare every infrastructure response such as malformed JSON, missing auth, or rate limits. The `server/context.ts` blueprint should resolve the session once and define the baseline context shape before route hooks run: ```typescript // server/context.ts import { createAnonymousActor, createUserActor } from "@beignet/core/ports"; import { defineServerContext } from "@beignet/core/server"; import type { AppContext } from "@/app-context"; export const appContext = defineServerContext< AppContext, AppContext["ports"] >()({ gate: (ports) => ports.gate, request: async ({ ports, req, requestId, trace }) => { const auth = await ports.auth.getSession(req); return { actor: auth ? createUserActor(auth.user.id) : createAnonymousActor(), auth, requestId, ...trace, ports, }; }, }); ``` `auth` is the resolved provider session or `null`. Route hooks enforce HTTP access and may narrow handler `ctx`, but they should not be the only place that derives the app's audit actor. ## Use-case helpers Use cases should still require a user when the workflow needs one. That keeps the rule active when the workflow is called from HTTP, jobs, scripts, event handlers, or tests. Beignet exports context helpers for this: | Helper | Returns | Default error | | --- | --- | --- | | `requireSession(ctx)` | The full `ctx.auth` session | `AuthUnauthorizedError` (framework-owned `401`) | | `requireUser(ctx)` | The session user, inferred from the app's `ctx.auth` type | `AuthUnauthorizedError` (framework-owned `401`) | | `requireUserId(ctx)` | The user's `id` string | `AuthUnauthorizedError` (framework-owned `401`) | | `requireTenant(ctx)` | The `ctx.tenant` activity tenant | `TenantRequiredError` (framework-owned `403`) | | `requireTenantId(ctx)` | The tenant's `id` string | `TenantRequiredError` (framework-owned `403`) | | `requireTenantScope(ctx)` | A branded `TenantScope` for repository boundaries | `TenantRequiredError` (framework-owned `403`) | ```typescript import { requireUser } from "@beignet/core/ports"; import { requireTenantScope } from "@beignet/core/tenancy"; const createPost = useCase .command("posts.create") .input(CreatePostInput) .output(PostOutput) .run(async ({ ctx, input }) => { const user = requireUser(ctx); const scope = requireTenantScope(ctx); return ctx.ports.posts.create( { ...input, authorId: user.id, }, scope, ); }); ``` The user type is inferred from the app's `ctx.auth` session, so `user` above is the app's own `AuthUser` shape without casts. The server maps `AuthUnauthorizedError` to a framework-owned `401` with code `UNAUTHORIZED` and `TenantRequiredError` to a framework-owned `403` with code `TENANT_REQUIRED`. The errors themselves expose matching `status` values of `401` and `403`, so app-owned error handling can inspect them consistently. Contracts do not need to declare these infrastructure responses. Pass `options.error` when a workflow should throw an app-catalog error instead: ```typescript const user = requireUser(ctx, { error: () => appError("Unauthorized") }); ``` App-owned wrappers around these helpers are still fine when they add app semantics, but the core helpers are the default. ## Better Auth provider Use `@beignet/provider-auth-better-auth` when Better Auth owns session lookup: ```bash bun add @beignet/core @beignet/provider-auth-better-auth better-auth@1.6.25 ``` The provider supports Better Auth `>=1.3.26 <1.7.0`. The starter pins Better Auth to `1.6.25` so clean installs use the version Beignet validates in generated apps. After upgrading Better Auth or changing its plugins, regenerate the schema for your database adapter and apply the resulting migration before deployment. Apps that enable `twoFactor()` need the current [two-factor schema](https://better-auth.com/docs/plugins/2fa#schema), including `failedVerificationCount` and `lockedUntil`. The standard Beignet starter does not enable two-factor authentication, so its default auth configuration does not require those fields. ```typescript import { createBetterAuthProvider } from "@beignet/provider-auth-better-auth"; import { auth } from "@/lib/better-auth"; export const providers = [ createBetterAuthProvider({ auth }), ]; ``` The provider wraps an already configured Better Auth instance and installs the same shared `AuthPort` on `ctx.ports.auth`. Apps that need custom provider setup can use the lower-level adapter inside an app-local provider: ```typescript // server/providers.ts import { createProvider } from "@beignet/core/providers"; import { createBetterAuthPort } from "@beignet/provider-auth-better-auth"; import { auth } from "@/lib/better-auth"; export const appAuthProvider = createProvider({ name: "app-auth", setup({ ports }) { return { ports: { auth: createBetterAuthPort({ auth, instrumentation: ports }), }, }; }, }); export const providers = [appAuthProvider] as const; ``` Define and register `appAuthProvider` in `server/providers.ts`; provider audit recognizes the direct adapter there as valid registration. Both forms leave ownership of the Better Auth instance with the app and emit the same redacted auth events. Better Auth still owns its own login, signup, callback, and session routes. Mount those routes beside your Beignet API routes. In a Next.js 16 app, you can add a root `proxy.ts` for faster redirects when a request does not have a Better Auth session cookie. Treat this as an app-specific UX gate only: the static matcher should reflect your protected URL shape, and the Beignet layout/session checks, route hooks, and use-case helpers remain the real authorization boundary. ```typescript import { getSessionCookie } from "better-auth/cookies"; import { NextResponse, type NextRequest } from "next/server"; export function proxy(request: NextRequest) { const sessionCookie = getSessionCookie(request); if (!sessionCookie) { return NextResponse.redirect(new URL("/sign-in", request.url)); } return NextResponse.next(); } export const config = { matcher: ["/dashboard/:path*", "/settings/:path*"], }; ``` Do not use this cookie-only check as proof of authentication. A request with a stale or manually created cookie must still be rejected by `auth.api.getSession`, `ctx.ports.auth`, route hooks, policies, or `requireUser(ctx)` before protected data or actions run. Route groups such as `app/(app)` are not part of the URL, so keep the matcher aligned with your app's real paths. ### Reaching app ports from auth callbacks Better Auth callbacks — `sendResetPassword`, `sendVerificationEmail`, organization invitation emails — run inside Better Auth's own routes, outside the Beignet request pipeline and before any app context exists. Do not build a parallel mail client or fall back to `console.*` there: reach the app's ports through the booted server with a dynamic import. ```typescript // lib/better-auth.ts export const auth = betterAuth({ // ... emailAndPassword: { enabled: true, async sendResetPassword({ user, url }) { // The dynamic import breaks the module cycle // (server -> providers -> auth). getServer() is memoized, so after // first boot this resolves to a cached instance; a callback that // fires before any Beignet route in a fresh process pays one boot. const { getServer } = await import("@/server"); const { ports } = await getServer(); await ports.mailer.send({ to: user.email, subject: "Reset your password", text: `Reset your password: ${url}`, }); ports.logger.info("Password reset email sent", { userId: user.id }); }, }, }); ``` Going through `ports.mailer` keeps auth email on the same provider as the rest of the app — instrumented, visible in devtools, and swappable per environment — instead of a second untracked client. The same pattern reaches any port from code that runs outside a request. ## Devtools When the devtools provider is installed before the Better Auth provider, auth checks appear in the Auth tab. The provider records `getSession`, `getUser`, and `requireUser` operations with authenticated status and duration. User and session objects are not recorded. ## Testing Tests can pass an auth adapter directly: ```typescript import { createStaticAuth } from "@beignet/core/ports"; const auth = createStaticAuth({ user: { id: "user_1", email: "user@example.com", }, }); const ctx = { user: await auth.getUser(new Request("http://test.local")), ports: { auth, posts: createInMemoryPosts(), }, }; ``` For unauthenticated tests, return `null` and assert that the use case throws `AuthUnauthorizedError` (code `UNAUTHORIZED`). ## Typed credential headers Service-to-service surfaces such as internal APIs and webhook receivers authenticate with credentials in request headers instead of a user session. Declare a `headers` schema on the auth hooks for these surfaces. The hook validates the raw lowercase request header record itself, so `resolve` receives typed header values without casting and without depending on each route's contract header schema: ```typescript import { createServiceActor, createTenant } from "@beignet/core/ports"; import { createAuthHooks } from "@beignet/core/server"; import { z } from "zod"; import type { AppContext } from "@/app-context"; const serviceHeadersSchema = z.object({ authorization: z .string() .regex(/^Bearer\s+\S+$/i, "Expected Authorization: Bearer "), }); export const serviceAuth = createAuthHooks()({ name: "internal.service", headers: serviceHeadersSchema, resolve: async ({ ctx, headers }) => { const apiKey = headers.authorization.replace(/^Bearer\s+/i, ""); const principal = await ctx.ports.apiKeys.verify({ apiKey }); if (!principal) return null; return { actor: createServiceActor(principal.id, { displayName: principal.displayName, metadata: { auth: "api-key", scopes: [...principal.scopes], }, }), tenant: createTenant(principal.tenantId), }; }, }); ``` Keep the verifier behind an app port such as `ctx.ports.apiKeys` rather than reading environment variables in the hook. That makes route tests independent from module-load env snapshots and lets production adapters verify hashed API keys, tenant membership, rotation status, and scopes without changing route wiring. See [Tenancy](/tenancy) for resolving tenant scope from verified API keys, sessions, and organization membership. On `required()` routes, a header schema failure or a `null` return from `resolve` is an authentication failure: a framework-owned `401`, not a `422`. On `optional()` routes a schema failure skips auth resolution, and `public()` never parses headers. Session-based hooks do not need a `headers` schema. Without one, `resolve` still receives the raw lowercase header record. ## Read next - [Hooks](/hooks) for hook lifecycle details. - [Tenancy](/tenancy) for tenant resolution and tenant-aware policies. - [Authorization](/authorization) for policies and ownership checks. - [Providers](/providers) for provider lifecycle and setup order. --- # Authorization Source: https://www.beignetjs.com/authorization Authorization answers "may this actor do this action to this resource?" Beignet gives apps a small Gate and Policy convention so rules are typed, testable, and reusable from HTTP handlers, jobs, scripts, and tests. Authentication still answers "who is this?" Keep that at the request boundary. Authorization usually needs domain data, so run policy checks inside use cases after loading the resource. Tenant scoping should also happen in repositories where possible, but policies are still the place that proves a loaded record belongs to the current actor and tenant. See [Tenancy](/tenancy) for request tenant resolution and trusted tenant sources. ## The model | Check | Put it here | Reason | | --- | --- | --- | | Is a session present? | Auth hook or `requireUser(ctx)` from `@beignet/core/ports` | It is an identity concern. | | Is a route public or protected? | Contract metadata plus hooks | It is transport-level policy. | | Can this user update this resource? | Use case via `ctx.gate.authorize(...)` | It needs domain data. | | Can this tenant access this record? | Repository filter plus policy check | Filtering prevents leaks; policy checks protect direct lookups. | | Should a job perform the action? | Job handler or shared use case | Jobs do not pass through HTTP hooks. | ## Define policies Policies are plain TypeScript modules created with `definePolicy(...)`. Keep them feature-owned when they protect feature-owned resources: ```typescript import { allow, definePolicy, deny, type ActivityActor, type ActivityTenant, } from "@beignet/core/ports"; import type { Post } from "@/features/posts/ports"; export type AuthorizationContext = { actor: ActivityActor; tenant?: ActivityTenant; }; function sameTenant(ctx: AuthorizationContext, post: Post) { if (ctx.tenant?.id === post.tenantId) return allow(); return deny({ reason: "Post belongs to another tenant.", code: "TENANT_MISMATCH", }); } export const postPolicy = definePolicy({ "posts.update": (ctx: AuthorizationContext, post: Post) => { const tenant = sameTenant(ctx, post); if (!tenant.allowed) return tenant; if (ctx.actor.type === "user" && ctx.actor.id === post.authorId) { return true; } return deny("Only the post author can update this post."); }, "posts.publish": (ctx: AuthorizationContext, post: Post) => { const tenant = sameTenant(ctx, post); if (!tenant.allowed) return tenant; if (ctx.actor.type === "user" && ctx.actor.id === "admin") return true; return deny("Only admins can publish posts."); }, }); ``` Return `true` or `allow()` to permit the action. Return `false` or `deny(...)` to block it. Use `deny(...)` when you want a reason for logs, devtools, tests, or the response message. ## Subject-based ownership policies Most record-scoped rules are ownership checks: the policy receives the loaded resource as its subject and compares it against the current identity. Keep the check in the policy — not inline in the use case — so the rule is testable in a matrix and reusable from jobs and scripts: ```typescript import { type ActivityActor, definePolicy, deny } from "@beignet/core/ports"; import type { Tweet } from "@/features/tweets/ports"; import type { AuthSession } from "@/ports/auth"; export type AuthorizationContext = { actor: ActivityActor; auth: AuthSession | null; }; export const tweetPolicy = definePolicy({ "tweets.delete": (ctx: AuthorizationContext, tweet: Tweet) => { if (ctx.actor.type !== "user") { return deny("You must be signed in to delete tweets."); } if (tweet.authorId !== ctx.actor.id) { return deny({ reason: "Only the author can delete this tweet.", code: "NOT_TWEET_AUTHOR", details: { tweetId: tweet.id, authorId: tweet.authorId }, }); } return true; }, }); ``` The use case loads the tweet first, then authorizes with the loaded record as the subject: ```typescript const tweet = await ctx.ports.tweets.get(input.id); if (!tweet) { throw appError("TweetNotFound", { details: { id: input.id } }); } await ctx.gate.authorize("tweets.delete", tweet); ``` A `deny(...)` with a `code` keeps the denial identity stable for tests, devtools, and `onDeny` error mapping even when the human-readable reason copy changes. ### Where an ability lives An ability lives in the policy of the feature that owns the authorized resource — the subject the rule inspects — not the feature that performs the action. For example, `comments.create` authorizes against an `Issue` (may this actor comment on this issue?), so it belongs in the issues feature's policy even though the comments feature performs the write: ```typescript // features/issues/policy.ts export const issuePolicy = definePolicy({ // ... "comments.create": (ctx: AuthorizationContext, issue: Issue) => { const tenantDecision = canAccessTenant(ctx, issue); if (!tenantDecision.allowed) return tenantDecision; return isAuthenticated(ctx) || deny("You must be signed in."); }, }); ``` This keeps every rule about a resource in one module, so reviewing "who can touch an issue" means reading one policy instead of grepping every feature that interacts with issues. ## Create a gate Register policies once in infra: ```typescript import { createGate } from "@beignet/core/ports"; import { appError } from "@/features/shared/errors"; import { postPolicy } from "@/features/posts/policy"; export const gate = createGate({ policies: [postPolicy], onDeny(decision) { return appError("Forbidden", { message: decision.reason ?? "Forbidden", }); }, }); ``` `onDeny` lets the app map policy failures to its own error catalog. If omitted, `authorize(...)` throws Beignet's default `GateAuthorizationError`, which the server maps to a standard framework-owned `403` response. Install the gate as a port and declare it in the `server/context.ts` blueprint: ```typescript // infra/port-wiring.ts export const initialPorts = definePorts({ gate, // other ports... }); ``` ```typescript // server/context.ts import { createAnonymousActor, createTenant, createUserActor, } from "@beignet/core/ports"; import { defineServerContext } from "@beignet/core/server"; import type { AppContext } from "@/app-context"; function resolveRequestTenant(auth: AppContext["auth"]) { const tenantId = tenantIdFromAuthoritativeAuthClaims(auth); return tenantId ? createTenant(tenantId) : undefined; } function tenantIdFromAuthoritativeAuthClaims(auth: AppContext["auth"]) { const tenantId = auth?.session?.tenantId; return typeof tenantId === "string" && tenantId.trim() ? tenantId.trim() : undefined; } export const appContext = defineServerContext< AppContext, AppContext["ports"] >()({ gate: (ports) => ports.gate, request: async ({ ports, req, requestId, trace }) => { const auth = await ports.auth.getSession({ headers: req.headers, raw: req }); return { requestId, ...trace, actor: auth ? createUserActor(auth.user.id, { displayName: auth.user.name }) : createAnonymousActor(), auth, ports, tenant: resolveRequestTenant(auth), }; }, }); ``` This claim-based helper is valid only when the auth provider guarantees that its server-issued tenant claim represents current membership. Otherwise, replace it with an app-owned membership lookup before creating the tenant context. Caller-supplied tenant IDs and unverified session fields are not authorization. This keeps the policy registry in `ctx.ports.gate` and gives use cases the context-bound `ctx.gate` surface, which always evaluates against the current `actor` and `tenant` — including identity added later by auth hooks. The practical rule: never hand-bind the gate (it is a compile error), and never spread-copy the context to change identity — `{ ...ctx }` drops the gate, so use `ctx.ports.gate.attach({ ...ctx, actor })` for a derived context instead. ## Tenant context Request context should carry the current actor and tenant. Authentication hooks or server context creation usually derive them from a session, signed token, subdomain, or trusted gateway header. Repositories should accept tenant scope for list and lookup operations when the record belongs to a tenant: ```typescript import { requireTenantScope } from "@beignet/core/tenancy"; const scope = requireTenantScope(ctx); const result = await ctx.ports.posts.findMany({ page }, scope); ``` Still authorize after loading a record. That catches direct lookups, jobs, scripts, and future code paths that might not share the same repository filter. When a workflow cannot proceed without a tenant scope, require it with `requireTenantScope(ctx)` from `@beignet/core/tenancy`. The helper throws `TenantRequiredError`, which the server maps to a framework-owned `403` with code `TENANT_REQUIRED`: ```typescript import { requireTenantScope } from "@beignet/core/tenancy"; const scope = requireTenantScope(ctx); ``` For sensitive records, prefer repository methods that require tenant scope: ```typescript const record = await ctx.ports.records.findById(input.recordId, scope); if (!record) { throw appError("RecordNotFound", { details: { recordId: input.recordId }, }); } await ctx.gate.authorize("records.view", record); ``` ## Workspace tenancy Multi-tenant SaaS apps usually resolve the tenant from workspace membership: a user belongs to one or more workspaces, one of them is active per request, and authorization rules depend on the actor's role in that workspace. Scaffold the whole slice for a Drizzle-backed app: ```bash beignet make tenancy beignet db generate beignet db migrate beignet db status ``` The generator replaces the starter tenant resolution so `server/context.ts` loads the user's memberships, picks the active workspace from the `beignet-workspace` cookie (falling back to the first membership), and carries both on the request context: `ctx.tenant.id` is the active workspace id and `ctx.membership` is the actor's membership in it, or `null`. The generated `features/workspaces/policy.ts` makes policies membership-aware with a small set of helpers: ```typescript export type WorkspaceMembershipContext = { workspaceId: string; role: WorkspaceRole; }; export type AuthorizationContext = { actor: ActivityActor; tenant?: ActivityTenant; membership?: WorkspaceMembershipContext | null; }; export function membershipFor( ctx: AuthorizationContext, workspaceId: string, ): WorkspaceMembershipContext | null { if (!ctx.membership) return null; return ctx.membership.workspaceId === workspaceId ? ctx.membership : null; } export function isWorkspaceAdmin( ctx: AuthorizationContext, workspaceId: string, ): boolean { return membershipFor(ctx, workspaceId)?.role === "admin"; } ``` The generated `workspacePolicy` uses them for `workspaces.update`, `workspaces.members.view`, and `workspaces.members.manage`, denying with membership-specific reasons and codes. Feature policies can import the same helpers to gate their own abilities on workspace roles — the ability still lives in the policy of the feature that owns the authorized resource. Workspace routes cover create, switch, members, and invites under `/api/workspaces`; invites are idempotent, emailed through the notifications port, and accepted by token. Users without a workspace have no tenant, so `requireTenantScope(ctx)` keeps tenant-scoped workflows honest either way. See the [authentication](/authentication) page for where session identity comes from; membership resolution builds on top of it. ## Use policies Load the resource first, then authorize the action: ```typescript const updatePost = useCase .command("posts.update") .input(UpdatePostInput) .output(PostOutput) .run(async ({ ctx, input }) => { const post = await ctx.ports.posts.findById(input.id); if (!post) { throw appError("PostNotFound", { details: { id: input.id } }); } await ctx.gate.authorize("posts.update", post); return ctx.ports.posts.update(input.id, input); }); ``` Use `ctx.gate.can(...)` when you need a boolean and `ctx.gate.inspect(...)` when a UI, test, or devtools integration needs the full decision: ```typescript const canPublish = await ctx.gate.can("posts.publish", post); const decision = await ctx.gate.inspect("posts.publish", post); ``` Use `canMany(...)` or `inspectMany(...)` when a workflow needs a stable permission map. Batch checks use object keys so API responses and UI code do not depend on array order: ```typescript const permissions = await ctx.gate.canMany({ update: ["posts.update", post], publish: ["posts.publish", post], delete: ["posts.delete", post], }); return { post, permissions, }; ``` These maps are useful for read models that drive UI affordances. For example, a detail query can return `{ post, permissions }`, and the page can hide or disable edit, publish, and delete actions from that permission map. Treat permission maps as presentation hints only. Mutating use cases should still load the resource and enforce the decision with `authorize(...)` before performing the write. ## Observe decisions `createGate(...)` can observe policy decisions without changing authorization behavior. The observer is best effort: thrown or rejected observer errors are ignored, and `onDeny` still controls denied `authorize(...)` errors. ```typescript import { createGate } from "@beignet/core/ports"; import type { AppContext } from "@/app-context"; import { postPolicy } from "@/features/posts/policy"; const gate = createGate({ policies: [postPolicy], onDecision(event) { event.ctx.ports.devtools?.record({ type: "custom", watcher: "policies", name: event.ability, summary: event.decision?.allowed ? "allowed" : "denied", requestId: event.requestId, traceId: event.traceId, details: { ability: event.ability, allowed: event.decision?.allowed, code: event.decision?.allowed ? undefined : event.decision?.code, reason: event.decision?.allowed ? undefined : event.decision?.reason, source: event.source, batchKey: event.batchKey, durationMs: event.durationMs, }, }); }, }); ``` Devtools includes a `policies` watcher and Policies view for these custom events. When the event comes from `canMany(...)` or `inspectMany(...)`, include `batchKey` so the timeline can show which permission-map entry allowed or denied each affordance. Durable audit logging is still app-owned: record only the policy decisions your compliance model requires. ## Test policies Use `@beignet/core/testing` for matrix tests that document tenant, ownership, and role decisions without going through HTTP: ```typescript import { createPolicyTester } from "@beignet/core/testing"; import { postPolicy } from "@/features/posts/policy"; const tester = createPolicyTester({ policies: [postPolicy] }); const sameTenantPost = { id: "post_1", tenantId: "tenant_1", authorId: "alice", status: "draft", // ...remaining Post fields, built by a feature test factory }; await tester.assertMatrix([ { name: "author can update same tenant post", ctx: { actor: { type: "user", id: "alice" }, tenant: { id: "tenant_1" }, }, ability: "posts.update", subject: sameTenantPost, expected: "allow", }, { name: "admin cannot publish another tenant post", ctx: { actor: { type: "user", id: "admin" }, tenant: { id: "tenant_2" }, }, ability: "posts.publish", subject: sameTenantPost, expected: "deny", code: "TENANT_MISMATCH", }, ]); ``` Also test the use case so you prove the workflow enforces the policy: ```typescript await expect( updatePost.run({ ctx: makeContext({ user: { id: "other_user" } }), input: { id: "post_1", title: "New title" }, }), ).rejects.toMatchObject({ code: "FORBIDDEN", }); ``` ## Error catalog Expected authorization failures should be declared on route contracts when the app maps policy failures to app errors: ```typescript export const errors = defineErrors({ Unauthorized: httpErrors.Unauthorized, Forbidden: httpErrors.Forbidden, }); export const updatePost = posts .put("/:id") .errors({ Unauthorized: errors.Unauthorized, Forbidden: errors.Forbidden, PostNotFound: errors.PostNotFound, }); ``` Clients can branch on stable error identity: ```typescript if (updatePostEndpoint.isError(error, { code: "FORBIDDEN" })) { showAccessMessage(); } ``` ## Privileged access Impersonation and emergency access should be explicit application workflows, not hidden branches inside generic policies. Model them as separate abilities, capture the reason in input, and record durable audit events: ```typescript await ctx.gate.authorize("records.breakGlass", record); await ctx.ports.audit.record({ action: "records.break-glass", resource: { type: "record", id: record.id }, message: "Break-glass record access granted.", metadata: { reason: input.reason, severity: "high", }, }); ``` The important rule is that dangerous access paths must be searchable later: actor, tenant, resource, reason, request id, and timestamp should all be present in the audit entry. ## Generate a policy The CLI can create a starter policy: ```bash beignet make policy posts ``` That writes `features/posts/policy.ts`. Replace the starter abilities with your domain rules, register the policy with `createGate(...)`, and declare the gate in the `server/context.ts` blueprint with `gate: (ports) => ports.gate`. --- # Tenancy Source: https://www.beignetjs.com/tenancy Tenancy answers "which account, workspace, organization, or tenant is this work scoped to?" Keep it separate from authentication. A request can be authenticated and still lack a valid tenant for the resource it is trying to read or mutate. The recommended shape is: 1. Resolve a tenant scope from verified request state. 2. Store it on `ctx.tenant` as an `ActivityTenant`. 3. Require a branded `TenantScope` at tenant-owned repository boundaries. 4. Re-check loaded resources in policies so direct lookups and background work cannot cross tenant boundaries. ## Resolve tenant scope Put tenant resolution in an app-owned helper instead of scattering tenant parsing across routes or use cases. Verified sources should come first: session membership, API key principals, JWT or OIDC claims, host/path mapping, or request metadata that has already been checked against app state. ```typescript import { createTenant } from "@beignet/core/ports"; import type { AuthSession } from "@/ports/auth"; export function resolveRequestTenant({ auth, }: { auth: AuthSession | null; }) { const tenantId = tenantIdFromAuthoritativeAuthClaims(auth); return tenantId ? createTenant(tenantId) : undefined; } function tenantIdFromAuthoritativeAuthClaims(auth: AuthSession | null) { const session = auth?.session; return isRecord(session) ? nonEmptyString(session.tenantId) : undefined; } function nonEmptyString(value: unknown) { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } ``` Use that helper only when the auth provider guarantees that the server-issued claim reflects current membership. Otherwise, replace the claim lookup with an app-owned membership query and create the tenant only from its verified result. Keeping that decision behind one helper makes the trust boundary visible and replaceable. Treat caller-supplied headers such as `x-tenant-id` as untrusted unless another trusted layer has verified that the caller belongs to that tenant and stamped the request. Production apps should usually resolve tenant scope from verified session membership, API key principal data, organization claims, subdomains, or path segments checked against membership records. ## Request context The server context is the right default place to resolve tenant scope once per request: ```typescript import { createAnonymousActor, createUserActor } from "@beignet/core/ports"; import { defineServerContext } from "@beignet/core/server"; import { resolveRequestTenant } from "@/lib/tenant"; import type { AppContext } from "@/app-context"; export const appContext = defineServerContext< AppContext, AppContext["ports"] >()({ gate: (ports) => ports.gate, request: async ({ req, ports, requestId, trace }) => { const auth = await ports.auth.getSession(req); const tenant = resolveRequestTenant({ auth }); return { actor: auth ? createUserActor(auth.user.id) : createAnonymousActor(), auth, requestId, ...trace, ports, tenant, }; }, }); ``` The exact resolver is app-owned because tenant models vary. Some apps are single-tenant, some use workspace memberships, and API-only apps may resolve tenant scope from an API key or OAuth client. ## API keys and service actors For API-key routes, resolve the tenant from the verified principal returned by the credential verifier. Do not trust a caller-supplied tenant header on those routes: ```typescript import { createServiceActor, createTenant } from "@beignet/core/ports"; import { createAuthHooks } from "@beignet/core/server"; import { z } from "zod"; import type { AppContext } from "@/app-context"; const apiKeyHeadersSchema = z.object({ authorization: z.string().startsWith("Bearer "), }); export const apiKeyAuth = createAuthHooks()({ headers: apiKeyHeadersSchema, resolve: async ({ ctx, headers }) => { const apiKey = headers.authorization.replace(/^Bearer\s+/i, ""); const principal = await ctx.ports.apiKeys.verify({ apiKey, }); if (!principal) return null; return { actor: createServiceActor(principal.id, { metadata: { scopes: [...principal.scopes] }, }), tenant: createTenant(principal.tenantId), }; }, }); ``` This keeps the tenant, actor, and scopes tied to one verified credential. Route metadata and OpenAPI security can then describe the transport, while policies still enforce business access. ## Use cases and policies Use cases that touch tenant-owned data should require a tenant before loading records: ```typescript import { requireTenantScope, type TenantScope } from "@beignet/core/tenancy"; type RecordSummary = { id: string; tenantId: string; }; export interface RecordRepository { findById(id: string, scope: TenantScope): Promise; } const scope = requireTenantScope(ctx); const record = await ctx.ports.records.findById(input.recordId, scope); ``` Repository adapters unwrap the scope at the persistence boundary with `tenantScopeId(scope)` and filter by the tenant ID so unrelated records are not loaded. Policies should still verify loaded subjects belong to the current tenant: ```typescript function sameTenant(ctx: AuthorizationContext, record: RecordSummary) { if (ctx.tenant?.id === record.tenantId) return allow(); return deny({ reason: "Record belongs to another tenant.", code: "TENANT_MISMATCH", }); } ``` Filtering prevents accidental disclosure. Policy checks make the rule explicit, testable, visible in devtools, and reusable from jobs, tasks, schedules, and scripts. `beignet doctor --strict` includes a conservative tenant-scope baseline for Drizzle repositories. It warns when generated tenant-scoped resources lose their `TenantScope` repository boundary or `tenantScopeId(scope)` predicates, when hand-authored repositories expose raw `tenantId` or `workspaceId` app-facing methods, and when scoped adapters for either column convention no longer derive their storage key from `tenantScopeId(scope)`. Provider-correlation lookups are separate from authorization. A webhook handler may still look up a billing account by `customerId` after signature verification; that lookup should not be treated as proof of tenant authority. Once app code acts inside a tenant boundary, pass `TenantScope` through the repository API and keep policy checks around loaded subjects. Doctor is a drift detector, not a replacement for tenant-aware use-case and policy tests. ## Read next - [Authentication](/authentication) for resolving users and service actors. - [Authorization](/authorization) for policy placement and tenant mismatch checks. - [Server](/server) for request context wiring. --- # Config Source: https://www.beignetjs.com/config Reading `process.env` directly means typos surface as `undefined` at request time and nothing documents which variables a deploy needs. `@beignet/core/config` validates configuration at boot, gives the app a typed `env` object, and makes config loading testable. Beignet apps should define server-only and client-safe variables explicitly so secrets cannot be read from client code by accident. ```bash bun add @beignet/core ``` ## App env Use `createEnv(...)` from `lib/env.ts`: ```typescript import { createEnv } from "@beignet/core/config"; import { z } from "zod"; export const env = createEnv({ server: { NODE_ENV: z.enum(["development", "test", "production"]).default("development"), DATABASE_URL: z.string().url(), LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), }, clientPrefix: "NEXT_PUBLIC_", client: { NEXT_PUBLIC_APP_URL: z.string().url(), }, runtimeEnv: process.env, }); ``` Server variables are available on the server. Client variables must start with `clientPrefix`. If a server-only key is read through the returned env object in a client runtime, Beignet throws a descriptive error. Server runtimes validate both server and client variables at startup. Client runtimes validate only client variables, so a public bundle does not need server secrets just to import the shared `env` object. ## Strict runtime env Some frameworks only bundle environment variables that are explicitly accessed. Use `runtimeEnvStrict` to make those accesses visible: ```typescript export const env = createEnv({ server: { DATABASE_URL: z.string().url(), }, clientPrefix: "NEXT_PUBLIC_", client: { NEXT_PUBLIC_APP_URL: z.string().url(), }, runtimeEnvStrict: { DATABASE_URL: process.env.DATABASE_URL, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, }, }); ``` Every key validated in the current runtime must exist on `runtimeEnvStrict`, even if its value is `undefined`. Server runtimes require all declared keys; client runtimes require only client keys. That catches missed destructures during build without forcing server secrets into client bundles. ## Empty strings `createEnv(...)` treats empty strings as `undefined` by default. This keeps schema defaults working when `.env` contains values like: ```env LOG_LEVEL= ``` Set `emptyStringAsUndefined: false` when an empty string should be validated as an actual value. ## Prefix stripping Use `createEnvLoader(...)` when you already have a whole-object schema or need prefix stripping: ```typescript import { createEnvLoader } from "@beignet/core/config"; import { z } from "zod"; const appEnv = createEnvLoader({ prefix: "APP_", schema: z.object({ DATABASE_URL: z.string().url(), SECRET_KEY: z.string().min(1), }), }); export const config = appEnv.load(); ``` This reads `APP_DATABASE_URL` and `APP_SECRET_KEY`, strips `APP_`, validates the resulting object, and returns `{ DATABASE_URL, SECRET_KEY }`. ## Testing Pass a custom env object instead of reading from `process.env`: ```typescript const env = createEnv({ server: { DATABASE_URL: z.string().url(), }, runtimeEnv: { DATABASE_URL: "postgres://localhost/test", }, }); ``` ## Provider config Provider configuration uses the same Standard Schema helpers internally. A provider can declare an `envPrefix`, and the server strips the prefix before validating provider config: ```typescript import { createProvider } from "@beignet/core/providers"; import { z } from "zod"; createProvider({ name: "mail", config: { envPrefix: "MAIL_", schema: z.object({ HOST: z.string(), PORT: z.coerce.number().int(), }), }, async setup({ config }) { // config.HOST and config.PORT are validated }, }); ``` --- # Testing Source: https://www.beignetjs.com/testing Beignet apps should test the boundary that owns the behavior. There are four test idioms, one per situation: | Situation | Idiom | | --- | --- | | Business rules in a use case | `createTestPorts(...)` + `createTestContextFactory(...)` + `createUseCaseTester(...)` | | Contract request and response over HTTP | `createTestApp(...)` with the app's shared `appContext` blueprint | | Workflow artifacts: jobs, listeners, schedules, notifications, tasks, uploads | `createTestContext(...)` fixture with `dispose()` | | Repository and persistence behavior | `createDatabaseTestHarness(...)` against a real test database | Test placement follows the same ownership rule: feature behavior tests live in `features//tests/`, while infra and server modules may keep adjacent `*.test.ts` files beside the module they exercise. Generated features and resources include starter tests for the generated behavior; see [CLI](/cli) for what each generator scaffolds. The generated starter ships `lib/beignet-test.ts`, a small wrapper around Node's test runner and assertions. Import test helpers from that file so the same tests run through the generated `test` package script with npm, pnpm, yarn, or Bun. The script starts `lib/beignet-test-runner.ts` with `node --import tsx`, which ensures Node remains the test runtime even when Bun starts the package script. The runner discovers app test files and passes them explicitly to Node's test runner. Discovery honors path overrides from `beignet.config.ts`, `.json`, `.mjs`, or `.js`. Run `beignet doctor --fix` to upgrade an older generated test script; custom test commands are left alone. ## Use case tests Use case tests are the default for business behavior because they avoid HTTP setup and run against app-owned ports. A minimal test builds a port fixture, a context factory, and a tester, then runs the use case: ```typescript import { expect, test } from "@/lib/beignet-test"; import { createUseCaseTester } from "@beignet/core/application"; import { createTestUserActor } from "@beignet/core/testing"; import { createTestContextFactory, createTestPorts } from "@beignet/core/testing"; import type { AppContext } from "@/app-context"; import { createProjectUseCase } from "@/features/projects/use-cases"; import { initialPorts } from "@/infra/port-wiring"; import { createInMemoryProjectRepository } from "@/infra/projects/in-memory-project-repository"; test("creates a project", async () => { const fixture = createTestPorts({ base: initialPorts, overrides: { gate: initialPorts.gate, projects: createInMemoryProjectRepository() }, }); const createContext = createTestContextFactory({ ports: fixture.ports, actor: createTestUserActor("user_test"), }); const tester = createUseCaseTester(createContext); const project = await tester.run(createProjectUseCase, { name: "Roadmap" }); expect(project.name).toBe("Roadmap"); }); ``` Port overrides are typed partials: a test declares only the port surface it exercises, and any missing member throws a named error on use. The context factory also accepts `auth` and a `tenant` built with `createTestTenant(...)`, and it attaches a live `ctx.gate` automatically when `ports.gate` exposes `bind(...)`, so authorization runs against the final test identity. Use `createTestImpersonatedUserActor(...)` when an admin acting as another user must appear in audit metadata. `createTestPorts(...)` also supplies a recording `bestEffortWork` port. Deferred callbacks remain in `fixture.pendingBestEffortWork` until the test calls `await fixture.flushBestEffortWork()`. A flush runs one FIFO snapshot, so work deferred by a running callback remains pending for the next explicit flush. It attempts the complete snapshot before rejecting with any callback failures. When the behavior under test runs inside `ctx.ports.uow.run(...)`, add the app's production transaction wiring to the same fixture and turn on `transaction.outbox` so events recorded inside the transaction commit atomically with the data: ```typescript import { assertOutboxPending } from "@beignet/core/testing"; import { createDomainEventRecorder } from "@beignet/core/ports"; import { createTransactionPorts } from "@/infra/db/transaction-ports"; import type { AppTransactionPorts } from "@/ports"; const projects = createInMemoryProjectRepository(); const fixture = createTestPorts({ base: initialPorts, overrides: { gate: initialPorts.gate, projects }, transaction: { outbox: true, ports: (ports) => createTransactionPorts({ audit: ports.audit, repositories: { projects }, idempotency: ports.idempotency, outbox: ports.outbox, events: createDomainEventRecorder(), }), }, }); // ...run the use case as above, then: assertOutboxPending(fixture.outbox, { kind: "event", name: "projects.created" }); ``` `infra/db/transaction-ports.ts` is a pure module that assembles the app's transaction-scoped ports, so production providers and tests share one definition of what runs inside a transaction. Keep vendor SDK mocks out of these tests; mock or implement the app-owned port instead. ## Route tests Use route tests when the behavior belongs to HTTP: request parsing, contract validation, response validation, hooks, auth, rate limits, and error ownership. Route tests reuse the app's real context blueprint. `server/context.ts` declares the blueprint once with `defineServerContext(...)`, `server/index.ts` passes it to the production server, and route tests pass the same value to `createTestApp(...)`: ```typescript import { expect, it } from "@/lib/beignet-test"; import { createTestPorts } from "@beignet/core/testing"; import { defineRoutes } from "@beignet/core/server"; import { createTestApp } from "@beignet/web/testing"; import type { AppContext } from "@/app-context"; import { createProject } from "@/features/projects/contracts"; import { projectRoutes } from "@/features/projects/routes"; import { initialPorts } from "@/infra/port-wiring"; import { createInMemoryProjectRepository } from "@/infra/projects/in-memory-project-repository"; import { appContext } from "@/server/context"; it("creates a project through the contract", async () => { const fixture = createTestPorts({ base: initialPorts, overrides: { auth: { getSession: async () => ({ user: { id: "user_test" } }) }, gate: initialPorts.gate, projects: createInMemoryProjectRepository(), }, }); const app = await createTestApp({ ports: fixture.ports, context: appContext, routes: defineRoutes([projectRoutes]), }); const project = await app.request(createProject, { body: { name: "Roadmap" } }); await app.stop(); expect(project.name).toBe("Roadmap"); }); ``` `createTestApp(...)` runs `@beignet/web` under the hood. Two defaults differ from production servers, and an explicit option always wins: `onUnboundPorts` defaults to `"ignore"` so apps with deferred provider ports still boot, and `mapUnhandledError` surfaces `err.message` in the 500 body so failing tests show the real error. Contract metadata such as `rateLimit` and `idempotency` is only enforced when the matching hook is in the test app's `hooks` and its port is bound — pass the same `createRateLimitHooks(...)` / `createIdempotencyHooks(...)` the server configures to assert real 429s and idempotency replays. `createTestPorts(...)` already binds memory `rateLimit` and `idempotency` ports, and `createTestApp(...)` warns at creation when registered contracts declare behavior the test app cannot enforce. Because the test runs the real blueprint, identity comes from the same place it does in production: override the `auth` port to simulate a signed-in session. Use `createTestRequester(...)` from `@beignet/web/testing` to apply shared headers such as a tenant header, and `app.safeRequest(...)` when the test expects an HTTP error as a typed result instead of a thrown `ContractError`. Cover both successful responses and declared business errors. The underlying `app.server` preserves the blueprint's service-input type, so non-HTTP tests can call `app.server.runServiceContext(input?, fn)` without assembling context manually. ### Testing Next route files The `@beignet/next` webhook, payment webhook, schedule, and outbox drain route factories build app context from the incoming request through `server.createRequestContext(...)`, not from `next/headers`, so route modules run under the generated `test` script with a plain `Request`: ```typescript // app/api/webhooks/github/route.test.ts import { expect, it } from "@/lib/beignet-test"; import { POST } from "./route"; it("rejects unsigned webhook deliveries", async () => { const response = await POST( new Request("http://localhost/api/webhooks/github", { method: "POST", body: JSON.stringify({ action: "opened" }), }), ); expect(response.status).toBe(400); }); ``` To drive a route factory against controlled context, pass a fake `server` exposing `createRequestContext` — the option accepts a `NextServer`, a core `ServerInstance`, or any test fake with that method. See the [`@beignet/next` README](https://www.npmjs.com/package/@beignet/next) for a full schedule-route example. ## Workflow artifact tests Jobs, listeners, schedules, notifications, tasks, and uploads run with a service identity instead of an HTTP request. Test them with the one-call `createTestContext(...)` fixture: it builds memory ports, assembles an app context with actor, tenant, request ID, trace ID, and a live bound gate, and enters the ambient request context so enrichment matches production. Dispose the fixture after each test: ```typescript import { afterEach, expect, it } from "@/lib/beignet-test"; import { createTestSystemActor } from "@beignet/core/testing"; import { createTestContext } from "@beignet/core/testing"; import type { AppContext } from "@/app-context"; import { LogProjectArchivedJob } from "@/features/projects/jobs"; const makeContext = createTestContext(); let fixture: ReturnType; afterEach(() => fixture.dispose()); it("audits handled archive jobs", async () => { fixture = makeContext({ actor: createTestSystemActor("test-worker") }); await LogProjectArchivedJob.handle({ job: LogProjectArchivedJob, payload: { projectId: "project_1" }, ctx: fixture.ctx, }); expect(fixture.audit.entries).toMatchObject([ { action: "jobs.projects.log-archived" }, ]); }); ``` The fixture supports `using fixture = makeContext(...)` for explicit resource management, and `ports` accepts the same typed partial overrides as `createTestPorts(...)`. Operational tasks follow the same idiom: build a fixture with `createTestServiceActor(...)` and pass `fixture.ctx` to `runTask(...)` from `@beignet/core/tasks`. Production context creation belongs to the CLI runner via `server/tasks.ts`, not to task tests. ## Repository and persistence tests Repository tests prove that a concrete adapter implements its port contract against a real database. Keep them adjacent to the infra they exercise, and use `createDatabaseTestHarness(...)` with the generated `infra/db/test-database.ts` helper to keep setup, seeding, factory resets, and cleanup in one place: ```typescript import { createDatabaseTestHarness } from "@beignet/core/testing"; const databaseHarness = createDatabaseTestHarness({ create: createTestDatabase, ctx: (database) => ({ ports: database.ports }), reset: (database) => database.reset(), close: (database) => database.close(), factories: [postFactory], seeds: [demoPostsSeed], }); afterEach(async () => { await databaseHarness.cleanup(); }); const { ctx } = await databaseHarness.setup({ seed: true }); const post = await postFactory.create(ctx, { title: "Database conventions", }); ``` ### Factories and seeds Use `@beignet/core/testing` when tests need realistic records or repeatable demo data. Factories should build app-owned data and persist through ports, not through ORM tables or provider SDKs: ```typescript // features/posts/tests/factories/post.ts import { createFactory } from "@beignet/core/testing"; import type { AppContext } from "@/app-context"; export const postFactory = createFactory("posts.post", { defaults: ({ sequence }) => ({ title: `Post ${sequence}`, content: "Created by a Beignet test factory.", }), persist: (ctx: AppContext, post) => ctx.ports.posts.create(post), }); ``` Seeds wrap factories for repeatable demo data: declare them with `defineSeed(...)`, run them with `runSeeds(...)`, and reset factory sequences between tests with `resetFactories(...)`. Generate starter files with `beignet make factory posts.post` and `beignet make seed posts.demo-posts`; keep factories under `features//tests/factories/` and seeds under `features//seeds/`. Use `beignet db seed` for app-level demo data once the app defines a `db:seed` script and a `server/seed.ts` entrypoint. ## Assertion helpers `@beignet/core/testing` ships assertion helpers that work against Beignet ports and memory adapters without importing concrete infra. Each `assertX(...)` has a matching `findX(...)`, and most have an `assertNoX(...)` negation. See the [generated API reference](/api-reference) for exact signatures. | Helper | What it asserts | | --- | --- | | `assertRecordedEvent` | Events captured by `createRecordingEventBus(...)` | | `assertDispatchedJob` | Jobs captured by `createRecordingJobDispatcher(...)` | | `assertScheduleRun` | Run intent captured by `createRecordingScheduleRunner(...)` | | `assertMailDelivery` | Deliveries on a memory mail port | | `assertNotificationDelivery` | Deliveries on a memory notification port | | `assertStorageObject` | Object content and metadata behind a storage port | | `assertAuditEntry` | Entries in a memory audit log | | `assertIdempotencyCompleted` / `assertIdempotencyInProgress` | Idempotency entry state | | `assertOutboxPending` / `assertOutboxDelivered` / `assertOutboxRetryScheduled` / `assertOutboxDeadLettered` | Outbox message state | | `assertOutboxDrainResult` | Claimed and delivered counts from `drainOutbox(...)` | | `assertProviderInstrumentationEvent` | Events captured by `createRecordingProviderInstrumentation(...)` | The recording helpers pair a port implementation with its captured log. Wire the port through the context factory rather than spreading an existing context, because spread copies drop the live `ctx.gate`: ```typescript import { assertRecordedEvent, createRecordingEventBus } from "@beignet/core/testing"; const { bus, events } = createRecordingEventBus(); const ctx = createContext({ ports: { ...fixture.ports, eventBus: bus } }); await publishPostUseCase.run({ ctx, input }); assertRecordedEvent(events, { name: "posts.published", payload: { postId: "post_1" } }); ``` Outbox assertions read a memory outbox or a message snapshot, so tests check durable workflow state without widening the production `OutboxPort` read API: ```typescript import { assertOutboxDelivered, assertOutboxDrainResult } from "@beignet/core/testing"; import { drainOutbox } from "@beignet/core/outbox"; const result = await drainOutbox({ outbox, registry, eventBus, jobs }); assertOutboxDrainResult(result, { claimed: 1, delivered: 1 }); assertOutboxDelivered(outbox.messages, { kind: "event", name: "posts.published" }); ``` Use `createRecordingScheduleRunner(...)` to verify schedule run intent without executing the handler, and `createInlineScheduleRunner` from `@beignet/core/schedules` when the handler itself is under test. For stateful workflows, add at least one test that follows the durable chain from transition use case through outbox, listener, job dispatch, retry, and dead-letter behavior; see [Workflows](/workflows). ## Generated resource checks After generating or editing a resource, run: ```bash bun beignet check ``` It runs every check in one pass: `test` covers behavior, `lint` (Biome) covers code style, `typecheck` catches contract/type drift, `beignet lint` checks dependency direction, and `beignet doctor --strict` checks app wiring that TypeScript cannot fully prove, such as route files that no longer match registered contracts, missing canonical client helpers, and local `AppContext` redeclarations. ## Provider tests Provider tests stay close to the adapter and verify the provider implements its port contract, including startup, teardown, retries, and error translation. Use `installProviderForTest(...)` from `@beignet/core/testing` to run provider setup against test ports; it returns the merged ports, the raw setup result, and `start`/`stop` runners for the lifecycle hooks: ```ts import { installProviderForTest } from "@beignet/core/testing"; import type { CachePort } from "@beignet/core/ports"; const installed = await installProviderForTest(createRedisCacheProvider(), { ports: { devtools }, config: { URL: "redis://localhost:6379" }, }); const cache = installed.ports.cache as CachePort; await cache.set("posts:list", "[]"); await installed.stop(); ``` `config` is passed to provider setup as-is, matching server startup. Pass `createServiceContext` when the provider under test builds service contexts from runtime entrypoints. Application tests should not depend on live providers unless the test is explicitly an integration test. --- # React overview Source: https://www.beignetjs.com/react Beignet has optional React integrations for server state, URL state, forms, and uploads. Each package is independent, so install only the pieces your app needs. | Package | Use it for | |---------|------------| | [`@beignet/react-query`](/react-query) | Typed TanStack Query options, mutations, prefetching, and query keys | | [`@beignet/nuqs`](/nuqs) | URL-backed search, filters, tabs, sorting, and pagination | | [`@beignet/react-hook-form`](/react-hook-form) | Typed React Hook Form setup from a contract body schema | | [`@beignet/react-uploads`](/react-uploads) | Typed upload state, progress, errors, and results from a Beignet upload client | ## Adapter shape Every adapter follows the same shape: create the package adapter once, then bind contracts or upload names from the returned helper. ```typescript const rq = createReactQuery(client); const rhf = createReactHookForm(); const nq = createNuqs(); const reactUploads = createReactUploads({ uploads }); ``` ## Feature workflow Keep shared adapter factories in `client/` and product UI in the feature: ```txt client/ auth-client.ts forms.ts index.ts features/ todos/ client/ queries.ts components/ todo-app.tsx contracts.ts ``` Feature-specific query options and hooks can live under `features//client/` when colocating data-fetching workflows keeps the component simpler. For tiny features, importing the contract directly from the component is fine. ```typescript // features/todos/client/queries.ts import { rq } from "@/client"; import { createTodo, listTodos } from "@/features/todos/contracts"; export function listTodosQueryOptions() { return rq(listTodos).queryOptions({ query: {} }); } export function createTodoMutationOptions() { return rq(createTodo).mutationOptions(); } ``` The feature component imports the feature client helper and uses shared client adapters: ```typescript "use client"; import { useMutation, useQuery } from "@tanstack/react-query"; import { rhf } from "@/client/forms"; import { createTodoMutationOptions, listTodosQueryOptions, } from "@/features/todos/client/queries"; import { createTodo } from "@/features/todos/contracts"; const createTodoForm = rhf(createTodo); export function TodoApp() { const todosQuery = useQuery(listTodosQueryOptions()); const form = createTodoForm.useForm({ defaultValues: { title: "" } }); const createTodoMutation = useMutation(createTodoMutationOptions()); const onSubmit = form.handleSubmit((body) => { createTodoMutation.mutate({ body }); }); return
{/* fields */}
; } ``` This is feature colocation, not server colocation. Components can import contracts, feature client helpers, and frontend helpers, but they should not or `app-context.ts`. ## Server context and prefetching When a Next.js layout or Server Component needs request-scoped Beignet state, use an app-owned server-only helper such as `lib/server-context.ts` that wraps `server.createContextFromNext()` in React `cache(...)`. Reading `ctx.auth`, `ctx.tenant`, or request metadata there is fine for redirects and shell state; feature data and business workflows should still go through use cases. When a Server Component needs to hydrate React Query data and the route is a thin `{ contract, useCase }` binding, keep the contract-derived query key from `rq(contract).queryOptions(...)` and replace only the server `queryFn` with an app-owned `lib/server-react-query.ts` helper that consumes the request context. Use the normal HTTP `rq(contract).queryOptions(...)` path when the route handler owns response mapping, headers, streaming, or other HTTP-layer behavior. ## Install ```bash bun add @beignet/react-query @tanstack/react-query bun add @beignet/nuqs nuqs bun add @beignet/react-hook-form react-hook-form @hookform/resolvers bun add @beignet/react-uploads ``` --- # React Query Source: https://www.beignetjs.com/react-query `@beignet/react-query` creates typed TanStack Query options from your contracts. Queries, mutations, query keys, cancellation, and prefetching all stay tied to the same contract types as the server and client. ```bash bun add @beignet/react-query @tanstack/react-query ``` ## Setup ```typescript import { createClient } from "@beignet/core/client"; import { createReactQuery } from "@beignet/react-query"; import { QueryClient } from "@tanstack/react-query"; export const apiClient = createClient({ validateInput: true, }); export const rq = createReactQuery(apiClient); export function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, }, }, }); } ``` Bind the contract builder directly with `rq(contract)`. The exposed `helper.endpoint` property is there for endpoint-specific narrowing and advanced client access; normal query and mutation code should use `queryOptions()`, `mutationOptions()`, filter helpers such as `contractFilter()`, `invalidate(queryClient, ...)`, and `key()` when TanStack APIs need the raw key. ## Feature client helpers Keep shared client setup in root `client/`, and put feature-specific query options, mutation options, invalidation helpers, and hooks under `features//client/`. This keeps components focused on UI state while the cache shape stays close to the contracts it calls. ```typescript // features/todos/client/queries.ts import type { QueryClient } from "@tanstack/react-query"; import { rq } from "@/client"; import { createTodo, listTodos } from "@/features/todos/contracts"; export function listTodosQueryOptions() { return rq(listTodos).queryOptions({ query: {} }); } export function createTodoMutationOptions() { return rq(createTodo).mutationOptions(); } export function invalidateTodos(queryClient: QueryClient) { return rq(listTodos).invalidate(queryClient); } ``` Components import those helpers and keep TanStack Query ownership local: ```typescript "use client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createTodoMutationOptions, invalidateTodos, listTodosQueryOptions, } from "@/features/todos/client/queries"; export function TodoList() { const queryClient = useQueryClient(); const todosQuery = useQuery(listTodosQueryOptions()); const createTodoMutation = useMutation({ ...createTodoMutationOptions(), onSuccess: () => invalidateTodos(queryClient), }); // render todos and call createTodoMutation.mutate(...) } ``` For tiny components, calling `rq(contract)` directly is still fine. Prefer a feature client helper as soon as the same query key, filters, invalidation, or mutation options are used from more than one component. ## Queries Use `rq(contract).queryOptions()` with `useQuery`. ```typescript import { useQuery } from "@tanstack/react-query"; import { getTodo } from "@/features/todos/contracts"; import { rq } from "@/client"; function TodoDetail({ id }: { id: string }) { const { data, isLoading, error } = useQuery( rq(getTodo).queryOptions({ path: { id } }), ); if (isLoading) return

Loading...

; if (error) return

Error: {error.message}

; return

{data.title}

; } ``` React Query passes its `AbortSignal` through the generated query function, so cancellation works automatically. `select` threads through `queryOptions(...)` and `infiniteQueryOptions(...)`, so the observed data narrows while the generated query function still returns and caches the raw contract response: ```typescript const { data } = useQuery( rq(getTodo).queryOptions({ path: { id }, select: (todo) => todo.title, }), ); // data: string | undefined ``` ## Mutations ```typescript import { useMutation, useQueryClient } from "@tanstack/react-query"; import { createTodo, listTodos } from "@/features/todos/contracts"; import { rq } from "@/client"; function CreateTodoButton() { const queryClient = useQueryClient(); const mutation = useMutation( rq(createTodo).mutationOptions({ onSuccess: () => { rq(listTodos).invalidate(queryClient); }, onError: (error) => { if (error.hasStatus(422)) { console.log("Validation failed:", error.details); } else { console.log("Request failed:", error.body ?? error.message); } }, }), ); return ( ); } ``` The integration uses the client's throwing `call()` path because TanStack Query already models failed requests through its error channel. Use client `safeCall()` outside React Query when explicit result handling reads better. Errors are typed from the endpoint contract. Declare business failures with `.errors(...)` when you want stable catalog `code` narrowing, and keep the helper around when you want endpoint-specific narrowing: ```typescript const todo = rq(getTodo); const { error } = useQuery(todo.queryOptions({ path: { id: "123" } })); if (todo.endpoint.isError(error, { code: "TODO_NOT_FOUND" })) { console.log(error.details); } ``` ### Idempotency keys and retries For contracts with [idempotency metadata](/idempotency), the generated `mutationFn` derives one idempotency key per variables object and keeps it stable across TanStack retry attempts. TanStack Query re-invokes `mutationFn` with the same variables object on every retry, so a mutation configured with `retry` sends the same key on each attempt and the server replays the stored result instead of executing the command again: ```typescript const mutation = useMutation( rq(createTodo).mutationOptions({ retry: 2, }), ); // All three attempts (initial + 2 retries) share one idempotency key. mutation.mutate({ body: { title: "New todo" } }); ``` Fresh variables objects passed to separate `mutate(...)` calls get separate keys, so retry stability does not normally deduplicate double-clicks. The integration keys retry state by variables-object identity: reusing the exact same object for a later intentional mutation also reuses its key. Create a fresh variables object for a distinct command, disable the submit button while the mutation is pending, or pass an explicit key when multiple invocations should count as one logical command: ```typescript mutation.mutate({ body: { title: "New todo" }, idempotencyKey: key }); ``` One caveat: calling `mutate()` with no variables skips per-invocation key derivation, and the client generates a fresh key per attempt instead. Pass a variables object (even an empty one) when an idempotent mutation should keep its key across retries. ## Query keys `rq(contract)` generates stable, contract-aware query keys and TanStack Query filters for cache operations. Contracts created from `defineContractGroup().namespace("todos")` include that namespace in the key so normal TanStack Query prefix invalidation can target a whole resource. ```typescript queryClient.invalidateQueries(rq(getTodo).namespaceFilter()); rq(getTodo).invalidate(queryClient); rq(getTodo).invalidate(queryClient, { path: { id: "123" } }); ``` The default key shapes behind those filters are: ```typescript rq(getTodo).namespaceKey(); // ["beignet", "todos"] rq(getTodo).contractKey(); // ["beignet", "todos", "getTodo", "GET /todos/:id"] rq(getTodo).key({ path: { id: "123" } }); // ["beignet", "todos", "getTodo", "GET /todos/:id", { path: { id: "123" } }] ``` Contract keys include the contract route after the local name, so two contracts with the same derived local name but different routes — two un-namespaced groups with `/v1` and `/v2` prefixes, for example — never share a cache key. Use the smallest filter that matches the data you want to refresh: | Filter helper | Scope | Use it for | | --- | --- | --- | | `namespaceFilter()` | Every contract in one namespace | A resource-wide write changed list, detail, search, or count data. | | `contractFilter()` | Every call to one contract | A write changed any filtered or paginated result from that contract. | | `filter({ path, query, body })` | One parameter-scoped contract key | A write changed one detail page, path group, or known filter set. | `helper.invalidate(queryClient, params?, options?)` wraps those filters for the common mutation path. With no params it invalidates every cached call to the contract — the usual choice after a create. With params it targets one detail key or parameter prefix — the usual choice after an update, paired with a contract-level invalidation of the list. `queryOptions(...)` uses the same required args as the base client call. If the contract requires path params, query params, or a body, the React Query options require them too. The generated key includes path, query, and body inputs, and omits `null` or `undefined` path and query entries so cache keys match URL serialization. Request bodies keep `null` values because JSON distinguishes an explicit `null` from an omitted property. Body keys follow JSON serialization, which omits `undefined` object properties while preserving explicit nulls and empty objects. When a route has filters, put the normalized filter values in `queryOptions`. The generated key then separates each filter set automatically: ```typescript const todosQuery = useQuery( rq(listTodos).queryOptions({ query: { status, search, limit: 20, offset: 0, }, }), ); ``` ### Headers and query keys Headers are excluded from generated query keys by default. Keys end up in persisted caches and dehydrated server payloads, so including headers automatically would leak credentials such as `Authorization` tokens. When a header changes response data — a preview or locale header, for example — opt that specific header into keys at the adapter level: ```typescript export const rq = createReactQuery(apiClient, { keyHeaders: ["X-Preview-Mode"], }); ``` With `keyHeaders` set, `queryOptions(...)`, `infiniteQueryOptions(...)`, and `key(...)` include a normalized `headers` component built only from the whitelisted names present on the call. Names match case-insensitively and are stored lowercased: ```typescript rq(listTodos).queryOptions({ headers: { "X-Preview-Mode": "draft", Authorization: "Bearer ..." }, }); // queryKey: ["beignet", "todos", "listTodos", "GET /todos", // { headers: { "x-preview-mode": "draft" } }] ``` Never whitelist credential headers. For one-off cases, the per-call `key` override remains the escape hatch. ## Infinite queries For paginated data, use `infiniteQueryOptions`. Contracts that follow the framework cursor convention — an optional `cursor` query param and a `PageResult` response with `page.nextCursor` — spread `cursorPagination()` into the options: ```typescript import { useInfiniteQuery } from "@tanstack/react-query"; import { cursorPagination } from "@beignet/react-query"; import { listTodos } from "@/features/todos/contracts"; import { rq } from "@/client"; const { data, fetchNextPage, hasNextPage } = useInfiniteQuery( rq(listTodos).infiniteQueryOptions({ query: { status: "open", limit: 20 }, ...cursorPagination(), }), ); ``` The stable filters stay in the generated cache key, the first page is fetched without a cursor, each next page sends `lastPage.page.nextCursor`, and `null` stops fetching. `data.pages` is typed per page because the options observe `InfiniteData` of the contract response. Type safety is structural: contracts without a `cursor` query param or without `page.nextCursor` in the response fail to typecheck at the spread site. Contracts that paginate differently pass `initialPageParam`, `getNextPageParam`, and `page(...)` by hand: ```typescript const { data, fetchNextPage } = useInfiniteQuery( rq(listTodos).infiniteQueryOptions({ query: { limit: 10 }, initialPageParam: 0, page: ({ pageParam = 0 }) => ({ query: { offset: pageParam }, }), getNextPageParam: (lastPage) => lastPage.page.hasMore ? lastPage.page.offset + lastPage.items.length : undefined, }), ); ``` Body-paginated contracts, such as a POST search endpoint, keep stable filters in the static `body` and put the cursor in `page(...)`. The static body is part of the generated key, and each page request sends the merged body: ```typescript const searchQuery = useInfiniteQuery( rq(searchTodos).infiniteQueryOptions({ body: { term: "beignet" }, initialPageParam: null as string | null, page: ({ pageParam }) => ({ body: { cursor: pageParam }, }), getNextPageParam: (lastPage) => lastPage.page.nextCursor ?? undefined, }), ); ``` If all params are computed dynamically, pass a custom `key`. That makes the cache scope explicit instead of hiding an unstable key inside the helper. ## Server rendering and prefetching `queryOptions(...)` works anywhere TanStack Query accepts query options, including Server Component prefetching: ```typescript const queryClient = makeQueryClient(); await queryClient.prefetchQuery( rq(getTodo).queryOptions({ path: { id: "123" } }), ); ``` The hydration setup around that call — a per-request `QueryClient`, `HydrationBoundary`, and `dehydrate` — is standard TanStack Query; follow the [TanStack Query SSR guide](https://tanstack.com/query/latest/docs/framework/react/guides/ssr). The Beignet-specific point: prefetching through `rq(...)` makes an HTTP request back into your own app. Use that HTTP path when the route handler owns response mapping, headers, streaming, or other HTTP-layer behavior. First define the cached request-context helper once: ```typescript // lib/server-context.ts import "@beignet/core/server-only"; import { cache } from "react"; import { getServer } from "@/server"; export const getAppRequestContext = cache(async () => { const server = await getServer(); return server.createContextFromNext(); }); ``` Layouts and Server Components may use that context directly for request metadata, `ctx.auth`, and `ctx.tenant`. For ordinary `{ contract, useCase }` routes where the use case output is the contract success body, add a separate server-only React Query helper that keeps the same contract-derived query key but calls the use case in process: ```typescript // lib/server-react-query.ts import "@beignet/core/server-only"; import type { QueryFunction, QueryKey } from "@tanstack/react-query"; import type { AppContext } from "@/app-context"; type UseCase = { run(args: { ctx: AppContext; input: TInput }): Promise; }; type QueryOptionsLike = { queryKey: QueryKey; queryFn: QueryFunction; }; type QueryOptionsOutput = Awaited< ReturnType >; export function serverUseCaseQueryOptions< TInput, TOptions extends QueryOptionsLike, >( options: TOptions, useCase: UseCase>, ctx: AppContext, input: TInput, ): Omit & { queryFn: QueryFunction, QueryKey>; } { return { ...options, queryFn: () => useCase.run({ ctx, input }), }; } ``` ```typescript import { getAppRequestContext } from "@/lib/server-context"; import { serverUseCaseQueryOptions } from "@/lib/server-react-query"; const ctx = await getAppRequestContext(); const queryClient = makeQueryClient(); await queryClient.prefetchQuery( serverUseCaseQueryOptions( rq(getTodo).queryOptions({ path: { id } }), getTodoUseCase, ctx, { id }, ), ); ``` When a Server Component only needs server-rendered data and no hydrated client cache, call the use case directly with `getAppRequestContext()` and skip React Query. ## Optimistic updates Optimistic updates are standard TanStack Query — `onMutate`, cancel, snapshot, and rollback all work unchanged; follow the [TanStack Query optimistic updates guide](https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates). The Beignet part is the cache key: `rq(getTodo).key({ path: vars.path })` gives `cancelQueries`, `getQueryData`, and `setQueryData` the exact entry to touch, and `invalidate(queryClient, ...)` handles the `onSettled` refetch. --- # React Hook Form Source: https://www.beignetjs.com/react-hook-form `@beignet/react-hook-form` creates typed React Hook Form options from a contract body schema. Use it when a form submits to a contract and should reuse the same validation rules on the client. ```bash bun add @beignet/react-hook-form react-hook-form @hookform/resolvers ``` React Hook Form only owns the request body fields. Path params, query params, required headers, idempotency keys, and auth context still belong to the endpoint call. The contract body input must be object-shaped; scalar and array body inputs should use the typed client directly instead of a form adapter. The schema output may still be any transformed submit value. ## Setup ```typescript import { createReactHookForm } from "@beignet/react-hook-form"; import { createTodo } from "@/features/todos/contracts"; const rhf = createReactHookForm(); const createTodoForm = rhf(createTodo); ``` Bind the contract builder directly with `rhf(contract)`. Use `contract.config` only for integration code that cannot accept the builder. `beignet make feature --with ui` generates this binding together with React Query mutation handling, field and root errors, success reset, and list invalidation. The `full-slice` recipe includes the same UI addon. ## Basic form ```typescript "use client"; function CreateTodoForm() { const form = createTodoForm.useForm({ defaultValues: { title: "", completed: false }, }); const onSubmit = form.handleSubmit(async (data) => { await createTodoEndpoint.call({ body: data }); }); return (
{form.formState.errors.title && ( {form.formState.errors.title.message} )}
); } ``` Validation runs automatically using your contract's body schema. Field names, values, and error messages are inferred from the contract. ## Input and output types Form types follow React Hook Form's input/output split. Live field values — `register`, `watch`, `setValue`, `getValues`, and `defaultValues` — use the body schema's input: what the user edits before validation runs. `handleSubmit` callbacks receive the schema's output: the parsed values after coercion, transforms, and defaults run. For plain schemas the two are identical. ```typescript const createPayment = payments .post("/api/payments") .body( z.object({ amount: z.string().transform(Number), note: z.string().optional(), }), ) .responses({ 201: paymentSchema }); const form = rhf(createPayment).useForm({ defaultValues: { amount: "" }, // input: string }); form.watch("amount"); // string (input) form.handleSubmit((values) => { values.amount; // number (output) }); ``` The typed client posts the schema input — the server validates and transforms the body when it receives the request. Parsed output is still valid input for plain, defaulted, and coerced schemas, so passing `handleSubmit` values to the endpoint call or mutation keeps working for those. When a transform changes a field's type, the parsed output no longer matches the contract body and TypeScript rejects it. Send the raw field values instead — validation has already passed by the time the submit handler runs: ```typescript const onSubmit = form.handleSubmit(() => { mutation.mutate({ body: form.getValues() }); }); ``` ## With React Query ```typescript "use client"; import { rootFormError } from "@beignet/react-hook-form"; function CreateTodoForm() { const form = createTodoForm.useForm({ defaultValues: { title: "", completed: false }, }); const mutation = useMutation( rq(createTodo).mutationOptions({ onSuccess: () => form.reset(), onError: (error) => { form.setError("root", rootFormError(error, "Could not create the todo.")); }, }), ); const onSubmit = form.handleSubmit((data) => { form.clearErrors("root"); mutation.mutate({ body: data }); }); return (
{form.formState.errors.title && ( {form.formState.errors.title.message} )} {form.formState.errors.root && ( {form.formState.errors.root.message} )}
); } ``` `rootFormError(error, fallback, overrides?)` wraps `contractErrorMessage` from `@beignet/core/client` into the `form.setError("root", ...)` shape: non-contract errors get the fallback copy, and catalog codes can override copy per form. Map server failures into `form.setError("root", ...)` for form-level failures, or into a specific field when the server response explicitly identifies one. See [Errors](/errors#map-errors-to-ui) for the underlying message mapping. ## Form options Get raw form options if you want to call `useForm` yourself. ```typescript import { useForm } from "react-hook-form"; const form = useForm( createTodoForm.formOptions({ defaultValues: { title: "" }, mode: "onBlur", }), ); ``` ## Disable automatic validation Set `resolverEnabled` to `false` when you want React Hook Form typing without the schema resolver. ```typescript const form = createTodoForm.useForm({ resolverEnabled: false, }); ``` React Hook Form controls when the resolver runs. With the default React Hook Form settings, Beignet's generated resolver validates before submit and then revalidates changed fields after a failed submit. Pass normal React Hook Form options such as `mode: "onBlur"` or `reValidateMode: "onChange"` when a form needs different timing. --- # React uploads Source: https://www.beignetjs.com/react-uploads `@beignet/react-uploads` adds React hook state on top of the typed browser upload client from `@beignet/core/uploads/client`. It does not define another upload protocol. The core client still owns prepare, direct upload, server fallback, completion, typed route names, metadata, and errors. Use it when a component needs upload progress, pending state, errors, reset, or abort behavior. ## Install ```bash bun add @beignet/react-uploads ``` In a generated full-stack app, scaffold the upload definition and a connected React component together: ```bash beignet make upload posts.attachment --ui ``` The command creates a client-safe constraint manifest shared with the server definition, a typed upload client, an uploader with progress and cancellation, and a component test. Use `make upload posts.attachment` without `--ui` for a backend-only workflow. ## Create the adapter Create the upload client once, then wrap it for React: ```typescript // client/uploads.ts import { createUploadClient } from "@beignet/core/uploads/client"; import { createReactUploads } from "@beignet/react-uploads"; import type { postUploads } from "@/features/posts/uploads"; type AppUploads = typeof postUploads; export const uploads = createUploadClient({ baseUrl: "/api/uploads", }); export const reactUploads = createReactUploads({ uploads, }); ``` Same-origin requests carry the session cookie automatically, so the client needs no identity headers. Apps that need per-request client metadata can pass a `headers` function, but tenant and identity decisions should still be made from verified server-side state. Import the upload registry as a type so browser code does not bundle server-only upload hooks. ## Use an upload hook Bind the hook to an upload name: ```tsx "use client"; import { reactUploads } from "@/client/uploads"; export function AttachmentInput({ postSlug }: { postSlug: string }) { const attachment = reactUploads.useUpload("posts.attachment"); return (
{ const files = Array.from(event.currentTarget.files ?? []); if (files.length === 0) return; attachment.upload({ metadata: { postSlug }, files, }); }} /> {attachment.isUploading &&

{attachment.progress}%

} {attachment.isError &&

Upload failed

}
); } ``` The `upload(...)` call is fire-and-forget: it never rejects, so event handlers like the `onChange` above do not need `await` or `.catch(...)`. Failures land in hook state and the `onError` callback. The hook returns: | Property | Purpose | | --- | --- | | `status` | `"idle"`, `"preparing"`, `"uploading"`, `"completing"`, `"success"`, or `"error"` | | `progress` | Aggregate progress from `0` to `100` | | `progressFraction` | Aggregate progress from `0` to `1` | | `files` | Per-file progress state | | `result` | Successful upload completion result | | `error` | Latest upload error | | `accept` | File input `accept` value from the upload manifest | | `constraints` | Client-safe file constraints from the upload manifest | | `upload(...)` | Start an upload with an explicit `files` array; never rejects | | `uploadAsync(...)` | Start an upload and return the result; rejects on failure | | `uploadFile(...)` | Start an upload with one file; never rejects | | `uploadFileAsync(...)` | Start an upload with one file and return the result; rejects on failure | | `abort()` | Abort the active upload request | | `reset()` | Abort active work and clear local hook state | ## Awaiting an upload Use `uploadAsync(...)` or `uploadFileAsync(...)` when the caller needs the completion result or wants to sequence work after the upload. The promise rejects when the upload fails or is aborted, so handle the rejection: ```tsx try { const completed = await attachment.uploadAsync({ metadata: { postSlug }, files, }); console.log(completed.result); } catch { // The failure is also stored in hook state and passed to onError. } ``` ## Many-file ergonomics Use `useUploadMany(...)` when the component already has a file array and you want a file-first call signature: ```tsx const attachments = reactUploads.useUploadMany("posts.attachment"); attachments.upload(files, { metadata: { postSlug }, }); ``` `useUploadMany(...)` exposes the same split: `upload(files, options)` never rejects, and `uploadAsync(files, options)` returns the completion result and rejects on failure. ## Success and error callbacks Callbacks can be defined on the hook or per upload call. Use `onSuccess` to invalidate React Query state or close a dialog after the upload creates app records. ```tsx const attachment = reactUploads.useUpload("posts.attachment", { onSuccess() { rq(getPost).invalidate(queryClient, { path: { slug: postSlug } }); }, }); ``` The adapter intentionally does not hide TanStack Query. Uploads are imperative side effects; cache invalidation should stay app-owned and explicit. Callbacks run after the upload settles and never change upload state. When a callback throws, `uploadAsync(...)` rejects with the callback error and `upload(...)` reports it through `console.error`; a succeeded upload stays `status: "success"` either way. ## Progress reporting Progress depends on the transport the core upload client selects: - Direct uploads report real per-file progress from the browser's `XMLHttpRequest` upload events. - Server-strategy uploads stream the whole multipart request through the app server and only report request completion, so `progress` jumps from `0` to `100` in one step when the request finishes. Treat progress bars as an enhancement for direct uploads and prefer indeterminate pending UI when forcing `strategy: "server"`. --- # nuqs Source: https://www.beignetjs.com/nuqs `@beignet/nuqs` connects contract query schemas to URL-backed state. Use it for search, filters, tabs, sorting, and pagination when the URL should reflect the current view. ```bash bun add @beignet/nuqs @beignet/react-query @tanstack/react-query nuqs ``` ## Setup ```typescript import { createNuqs } from "@beignet/nuqs"; export const nq = createNuqs(); ``` Bind the contract builder directly with `nq(contract)`. The helper reads the contract query schema and keeps URL state aligned with the same input shape used by the client. In Next.js App Router, mount the `NuqsAdapter` once: ```typescript import { NuqsAdapter } from "@beignet/nuqs/next/app"; export function Providers({ children }: { children: React.ReactNode }) { return {children}; } ``` ## URL-backed filters ```typescript "use client"; import { useQuery } from "@tanstack/react-query"; import { parseAsString, parseAsStringLiteral } from "nuqs"; import { listContacts } from "@/features/contacts/contracts"; import { nq, rq } from "@/client"; const contactsSearch = nq(listContacts).query({ parsers: { search: parseAsString, group: parseAsStringLiteral(["personal", "work", "family", "other"]), }, history: "replace", }); function ContactsPage() { const [filters, setFilters] = contactsSearch.useState(); const query = useQuery( contactsSearch.toQueryOptions(rq(listContacts), filters, { query: { limit: 50, offset: 0 }, }), ); return null; } ``` `toQueryOptions(...)` composes with `@beignet/react-query`, so URL state and query input stay aligned with the same contract. Nuqs parsers may return richer state than primitive query values. `parseAsIsoDateTime` values are sent as ISO-8601 strings, and `parseAsJson` object values are sent as JSON. The typed client still rejects invalid dates and values that cannot be JSON-serialized with `INVALID_QUERY_PARAM`. ## Query helper options `nq(contract).query(config)` requires one property: `parsers`, a map of nuqs parsers keyed by the contract's query schema keys. Parser keys are checked against the contract query shape, and you only declare parsers for the keys the URL should own — other query params can stay in normal component state or static query input. Everything else in `config` is an optional nuqs `useQueryStates` option passed through to the hook: `history` (`"replace"` by default, or `"push"` to create history entries), `shallow`, `scroll`, `clearOnDefault`, `limitUrlUpdates`, `startTransition`, and `urlKeys` for renaming search params. See the [nuqs options reference](https://nuqs.dev/docs/options) for what each one does. `useState(options)` accepts the same options as per-call overrides. --- # Providers Source: https://www.beignetjs.com/providers Providers are startup-time adapters. They install concrete ports for databases, caches, storage, mail, payments, feature flags, auth, logging, jobs, rate limits, and other external services while handlers and use cases depend only on `ctx.ports`. Read [Ports and adapters](/ports) first if you want the dependency boundary. Read the production feature pages when you want a task-specific guide, and [Writing a provider](/writing-a-provider) when you are building a reusable provider package. ## How providers fit ```typescript import { createNextServer, createNextServerLoader } from "@beignet/next"; import { createPinoLoggerProvider } from "@beignet/provider-logger-pino"; import { createRedisCacheProvider } from "@beignet/provider-cache-redis"; import { initialPorts } from "@/infra/port-wiring"; export const getServer = createNextServerLoader(() => createNextServer({ ports: initialPorts, providers: [createPinoLoggerProvider(), createRedisCacheProvider()], context: ({ ports }) => ({ requestId: crypto.randomUUID(), ports, }), }), ); ``` Provider-installed ports are available in context factories, route handlers, hooks, use cases, and `server.ports`. Generated apps keep provider wiring in two places: - `infra/port-wiring.ts` binds app-owned ports such as the policy gate and declares the rest as deferred provider-contributed keys with `definePorts()({ bound, deferred })`. - `server/providers.ts` registers runtime providers in startup order, exported `as const` so port types can be inferred from the list. App-owned infra providers, such as a database provider that wires repositories, belong under `infra/` and are registered from `server/providers.ts` after the provider that installs the lower-level port they need. For common first-party production providers, let the CLI apply the wiring: ```bash beignet provider add mail-resend beignet provider add search-meilisearch beignet provider add cache-redis beignet provider add event-bus-redis beignet provider add storage-s3 ``` Each preset updates dependencies, `server/providers.ts`, `AppPorts`, `infra/port-wiring.ts`, `.env.example`, and `docs/providers.md`. Use `--dry-run --json` to review the exact changes before writing, then run `beignet provider audit` and `beignet doctor --strict`. Choose one provider per app-facing port: for example, use `mail-resend` or `mail-smtp`, not both. After all providers have started, the server verifies that every deferred port was contributed and fails boot with the missing keys otherwise. See [Defer ports to providers](/ports#defer-ports-to-providers) for the `onUnboundPorts` options. ## Typed provider ports `InferProviderPorts` extracts and merges the ports a provider list contributes, so app code can type `ctx.ports` without hand-written casts: ```typescript // app-context.ts import type { InferProviderPorts } from "@beignet/core/providers"; import type { AppPorts } from "@/ports"; import type { providers } from "@/server/providers"; export type AppRuntimePorts = AppPorts & InferProviderPorts; export type AppContext = { requestId: string; ports: AppRuntimePorts; }; ``` The import of `providers` is type-only, so `app-context.ts` stays free of runtime server dependencies. App-local providers can declare the ports they require from earlier providers, plus their app context and service-context input, through the curried `createProvider()` form: ```typescript import { createProvider } from "@beignet/core/providers"; import type { DbPort } from "@beignet/provider-db-drizzle/sqlite"; import type { AppContext } from "@/app-context"; import type { AppServiceContextInput } from "@/server"; import type { AppPorts } from "@/ports"; import type * as schema from "./schema"; export const appDatabaseProvider = createProvider< { db: DbPort }, AppContext, AppServiceContextInput >()({ name: "app-database", async setup({ ports }) { const providedPorts: Pick = { ...createRepositories(ports.db.drizzle), uow: createUnitOfWork(ports.db.drizzle), }; return { ports: providedPorts }; }, }); ``` Annotate the returned ports with a `Pick` of the keys the provider fulfills. [Writing a provider](/writing-a-provider) covers the typing guidance for setup results and lifecycle hooks in detail. ## Naming conventions Provider exports follow a small naming rule: ```typescript // Provider factories with env-backed defaults need no options createRedisCacheProvider() createPinoLoggerProvider() createSmtpMailProvider() // Provider factories accept app-owned runtime input and return a provider createDrizzleSqliteProvider({ schema }) createBetterAuthProvider({ auth }) createMemoryEventBusProvider() // Direct port factories return concrete implementations for manual wiring createMemoryEventBus() createMemoryMailer() createRedisCache({ client }) createPinoLogger({ logger }) createUpstashRateLimit({ client }) createBetterAuthPort({ auth }) ``` Use `createXProvider(...)` for Beignet lifecycle providers registered with `providers: []`. Provider packages expose factories rather than shared singleton instances, so each server composition owns its provider object. Use `createXPort()` or a domain-specific factory name for direct implementations assigned under `ports`. Webhook verifier packages are server integrations, not providers. They do not install ports or appear in provider audits. They adapt vendor signature rules to `@beignet/core/webhooks` and are passed at the route/server boundary, usually through `createWebhookRoute(...)`. When a vendor also has a full app-facing port, prefer that capability provider for that workflow; for example Stripe billing uses `@beignet/provider-payments-stripe` and `createPaymentWebhookRoute(...)`, while `@beignet/webhooks-stripe` is for generic Stripe inbound events. ## Provider vs port factory A port factory is just app code that returns one concrete port. It is the right shape for simple dependencies, tests, and one-off adapters. ```typescript import { createMemoryMailer } from "@beignet/core/mail"; import { definePorts } from "@beignet/core/ports"; export const initialPorts = definePorts({ logger: fallbackLogger, mailer: createMemoryMailer(), }); ``` A provider participates in server startup. Use one when infrastructure needs configuration loading, setup order, startup checks, teardown, provider instrumentation, or reusable packaging. ```typescript export const getServer = createNextServerLoader(() => createNextServer({ ports: initialPorts, providers: [createPinoLoggerProvider(), createSmtpMailProvider()], context: appContextBlueprint, }), ); ``` ## Setup order Providers run in the order you pass them to the server. Each provider sees base ports plus ports returned by earlier providers. ```typescript export const getServer = createNextServerLoader(() => createNextServer({ ports: initialPorts, providers: [ createPinoLoggerProvider(), // installs ctx.ports.logger createRedisCacheProvider(), // can see ctx.ports.logger during setup ], context: appContextBlueprint, }), ); ``` When two providers return the same port key, the later provider wins. Use that deliberately for environment-specific overrides. ## Lifecycle `setup` runs during server creation. `start` runs after all providers have contributed ports. `stop` runs when the server is stopped. Provider lifecycle hooks should do bounded resource work: create clients, install ports, run startup checks, and close resources. Do not start polling loops, queue consumers, or other unbounded background work from `setup` or `start` in serverless apps. Put background work behind explicit runtime entrypoints such as cron routes, scheduled handlers, job functions, or worker processes. See [Runtime recipes](/runtime-recipes) for the process layouts and readiness checks those entrypoints should use. ```typescript import { createProvider } from "@beignet/core/providers"; import { z } from "zod"; const CacheConfigSchema = z.object({ URL: z.string().url(), }); export const cacheProvider = createProvider({ name: "cache", config: { schema: CacheConfigSchema, envPrefix: "CACHE_" }, async setup({ config }) { const client = await connectToCache(config.URL); return { ports: { cache: { get: (key) => client.get(key), set: async (key, value, options) => { if (options?.ttlSeconds) { await client.set(key, value, { ttlSeconds: options.ttlSeconds }); } else { await client.set(key, value); } }, delete: async (key) => client.delete(key), has: async (key) => (await client.exists(key)) > 0, remember: async (key, factory, options) => { const cached = await client.get(key); if (cached != null) return cached; const value = await factory(); await client.set(key, value, options?.ttlSeconds); return value; }, }, }, async stop() { await client.close(); }, }; }, }); ``` The `envPrefix` strips the prefix before validation. For example, `CACHE_URL=redis://localhost:6379` becomes `{ URL: "redis://localhost:6379" }`. ## Escape hatches First-party providers expose stable app-facing ports for normal use and raw clients as escape hatches for provider-specific features. ```typescript await ctx.ports.mailer.send({ to: "user@example.com", subject: "Welcome", text: "Hello", }); await ctx.ports.resend.client.emails.send({ from: "sender@example.com", to: "user@example.com", subject: "Invoice", html: "

Attached.

", attachments: [{ filename: "invoice.pdf", content: pdfBuffer }], }); ``` Application code should prefer the stable port. Use the escape hatch only when the provider has a feature the port intentionally does not model. Each capability page lists the escape-hatch port its providers install. ## First-party providers Provider packages are named `provider--`. When an implementation spans multiple database backends, each backend is a subpath export: the Drizzle package ships `@beignet/provider-db-drizzle/sqlite`, `/postgres`, and `/mysql`, with database drivers as optional peer dependencies so apps install only the driver they use. | Concern | Package | Installs | Read next | | --- | --- | --- | --- | | Database | `@beignet/provider-db-drizzle` | `db` plus per-backend Drizzle helpers via `/sqlite`, `/postgres`, and `/mysql` | [Database and transactions](/database) | | Cache | `@beignet/provider-cache-redis` | `cache`, plus `redis` escape hatch | [Cache](/cache) | | Search | `@beignet/provider-search-meilisearch` | `search`, plus `meilisearch` escape hatch | [Search](/search) | | Storage | `@beignet/provider-storage-local`, `@beignet/provider-storage-s3`, `@beignet/provider-storage-vercel-blob` | `storage`, plus `s3Storage`/`vercelBlob` provider escape hatches | [Storage](/storage) | | Mail | `@beignet/provider-mail-resend`, `@beignet/provider-mail-smtp` | `mailer`, plus `resend` or `smtp` escape hatch | [Mail](/mail) | | Payments | `@beignet/provider-payments-stripe` | `payments`, plus `stripe` escape hatch | [Payments and billing](/payments) | | Feature flags | `@beignet/provider-flags-openfeature` | `flags`, plus `openFeature` escape hatch | [Feature flags](/feature-flags) | | Error reporting | `@beignet/provider-error-reporting-sentry` | `errorReporter`, plus `sentry` escape hatch | [Error reporting](/error-reporting) | | Locks | `@beignet/provider-locks-redis` | `locks`, plus `redisLocks` escape hatch | [Locks and leases](/locks) | | Logger | `@beignet/provider-logger-pino` | `logger` | [Logging](/logging) | | Rate limiting | `@beignet/provider-rate-limit-upstash` | `rateLimit`, plus `upstash` escape hatch | [Rate limiting](/rate-limiting) | | Event bus | `@beignet/provider-event-bus-memory`, `@beignet/provider-event-bus-redis` | `eventBus`, plus `redisEventBus` escape hatch for Redis | [Events](/events) | | Auth | `@beignet/provider-auth-better-auth` | `auth` | [Authentication](/authentication) | | Jobs | `@beignet/provider-jobs-bullmq`, `@beignet/provider-jobs-inngest` | `jobs`, plus `bullMQJobs` or `inngest` escape hatch | [Jobs](/jobs) | Webhook signature integrations are listed separately because they do not install lifecycle providers or ports: use `@beignet/webhooks-github` for GitHub deliveries and `@beignet/webhooks-stripe` for generic Stripe events. See [Webhooks](/webhooks). ## Provider packages Reusable provider packages carry conventions beyond the runtime object: a static `beignet.provider` metadata manifest in `package.json` that `beignet doctor` reads, provider instrumentation so external work appears in devtools, and explicit durable-workflow semantics for providers that participate in jobs, events, schedules, or outbox delivery. Run `beignet provider audit` for a report-only inventory of installed provider metadata, registration, env, tables, and app ports; JSON output also includes active variants and watchers. Run `beignet provider add ` when you want the CLI to install and wire one of the stable presets (`flags-openfeature`, `mail-resend`, `mail-smtp`, `search-meilisearch`, `error-reporting-sentry`, `rate-limit-upstash`, `cache-redis`, `event-bus-redis`, `locks-redis`, `storage-s3`, or `storage-vercel-blob`) instead of copying the setup recipe by hand. First-party provider READMEs follow the same setup shape: install, env, wiring, installed ports, escape hatches, instrumentation, failure behavior, local/test substitutes, and deployment notes. Providers that touch remote dependencies should either document a fail-fast stance with caller-owned retries, or expose bounded retry configuration. Mail providers are fail-fast because sends are not idempotent and retries belong in jobs or the outbox; the S3 provider delegates bounded transient retries to the AWS SDK. Providers with cheap, non-mutating dependency probes expose explicit `checkHealth()` helpers on their provider-owned ports or escape hatches for app-owned `/api/ready` routes. [Writing a provider](/writing-a-provider) covers all of these. ## Process boundaries of memory providers Memory ports — cache, rate limit, locks, search, event bus, payments — keep their state in the process that created them. A seed script and a dev server are different processes, so state written by one is invisible to the other: documents a seed script indexes into `createMemorySearchProvider()` do not exist in the dev server's search port, and cache entries or rate-limit counters written in one process never appear in another. When a workflow depends on derived state such as a search index: - Rebuild the derived state inside the serving process. Run a boot or backfill [task](/tasks) against the server's own ports — for example an `issues.backfill-search` task that re-indexes every issue — or keep the state current with sync listeners driven by the [outbox](/outbox). - Swap the memory provider for a shared-store provider in the provider list: `@beignet/provider-search-meilisearch` for search, `@beignet/provider-locks-redis` for locks, or `@beignet/provider-rate-limit-upstash` for rate limits. Every process then reads and writes the same store. - Never treat a memory provider as a fixture that a separate process can preload. If a script must prepare state for the server, that state has to live in a shared store the server also reads. ## Testing For tests, pass mock or memory ports directly instead of booting production providers: ```typescript const testPorts = definePorts({ posts: createInMemoryPostRepository(), mailer: createMemoryMailer(), logger: { info: () => {}, error: () => {}, }, }); ``` Handlers and use cases still receive `ctx.ports`, so production and test code paths stay the same. --- # Database and transactions Source: https://www.beignetjs.com/database Beignet keeps database access behind app-owned ports. It gives you repository and Unit of Work conventions, but it does not hide Drizzle, Prisma, Kysely, or SQL behind a generic ORM abstraction. The recommended framework path today is Drizzle through `@beignet/provider-db-drizzle`. The default starter uses the `/sqlite` subpath, a libSQL-backed provider that works with local SQLite files in development and Turso's hosted libSQL in production. Pass `--db postgres` or `--db mysql` to `bun create beignet` to scaffold the same structure against the other backends — see [Other databases](#other-databases). Read this page when a feature needs durable persistence, transactions, repository tests, seeds, or local database lifecycle commands. ## Recommended structure Keep schema, app repositories, and feature ports in predictable places: ```txt infra/ db/ schema/ index.ts posts.ts comments.ts repositories.ts test-database.ts posts/ drizzle-post-repository.ts features/ posts/ ports.ts seeds/ demo-posts.ts tests/ factories/ post.ts index.ts persistence.test.ts drizzle/ *.sql drizzle.config.ts ``` Feature code owns the repository interface. Infra owns the Drizzle implementation. Server wiring adapts the raw Drizzle port into app-facing repository ports. ## Repository ports Use cases should depend on repository ports, not a raw database client: ```typescript // features/posts/ports.ts import type { CursorPage, CursorPageInfo, PageResult, SortOption, } from "@beignet/core/pagination"; export interface PostRepository { findMany(input: { page: CursorPage; cursor?: { sortValue: string; id: string } | null; filters?: { status?: PostStatus }; sort?: SortOption<"createdAt" | "title">; }): Promise>; findBySlug(slug: string): Promise; create(input: CreatePostInput): Promise; } ``` Infrastructure adapts a concrete database to that port: ```typescript // infra/posts/drizzle-post-repository.ts import { cursorPageResult } from "@beignet/core/pagination"; import { desc, eq } from "drizzle-orm"; import type { DrizzleSqliteDatabase } from "@beignet/provider-db-drizzle/sqlite"; import type { PostRepository } from "@/features/posts/ports"; import * as schema from "@/infra/db/schema"; import { encodePostCursor } from "./post-cursor"; export function createDrizzlePostRepository( db: DrizzleSqliteDatabase, ): PostRepository { return { async findMany(input) { const rows = await db .select() .from(schema.posts) .orderBy(desc(schema.posts.createdAt)) .limit(input.page.limit + 1); const pageRows = rows.slice(0, input.page.limit); const nextCursor = rows.length > input.page.limit && pageRows.length > 0 ? encodePostCursor(pageRows[pageRows.length - 1]) : null; return cursorPageResult(pageRows.map(toPost), input.page, nextCursor); }, async findBySlug(slug) { const [row] = await db .select() .from(schema.posts) .where(eq(schema.posts.slug, slug)) .limit(1); return row ? toPost(row) : null; }, }; } ``` The key detail is the `DrizzleSqliteDatabase` parameter. It accepts both the root Drizzle database and a transaction client, so the same repository factory works for normal reads and transaction-scoped writes. Cursor encoding is app plumbing: generated resources include small app-owned base64url cursor encode/decode helpers next to the repository, and hand-written repositories should keep equivalent helpers. ## Factories and seeds Factories and seeds should stay feature-owned and persist through repository ports. This keeps test/demo data on the same app boundary as use cases: ```typescript // features/posts/tests/factories/post.ts import { createFactory } from "@beignet/core/testing"; import type { AppContext } from "@/app-context"; export const postFactory = createFactory("posts.post", { defaults: ({ sequence }) => ({ name: `Post ${sequence}`, }), persist: (ctx: AppContext, input) => ctx.ports.posts.create(input), }); ``` ```typescript // features/posts/seeds/demo-posts.ts import { defineSeed } from "@beignet/core/testing"; import type { AppContext } from "@/app-context"; import { postFactory } from "@/features/posts/tests/factories"; export const demoPostsSeed = defineSeed("posts.demo-posts", { run: async (ctx: AppContext) => { await postFactory.createList(ctx, 3); }, }); ``` Generate the starter files with: ```bash beignet make factory posts.post beignet make seed posts.demo-posts ``` The app-owned `server/seed.ts` entrypoint decides which feature seeds run for local/demo environments; it lives beside the server runtime because it boots the app through `getServer()`, which `beignet lint` forbids from `infra/`. New apps do not scaffold seeds; `beignet make seed` creates `server/seed.ts` and the `db:seed` package script alongside your first generated seeds. Beignet never auto-runs seeds during migrations or application startup. ## Repository factory Collect app repositories in one infra factory: ```typescript // infra/db/repositories.ts import type { DrizzleSqliteDatabase } from "@beignet/provider-db-drizzle/sqlite"; import { createDrizzlePostRepository } from "@/infra/posts/drizzle-post-repository"; import type { AppRepositoryPorts } from "@/ports"; import * as schema from "./schema"; export function createRepositories( db: DrizzleSqliteDatabase, ): AppRepositoryPorts { return { posts: createDrizzlePostRepository(db), }; } ``` This keeps `server/index.ts` from importing every repository adapter directly and gives Unit of Work one place to create transaction-scoped ports. ## Server wiring The Drizzle provider installs the provider-owned `db` port. An app-owned database provider in `infra/db/provider.ts` turns it into repository ports, idempotency, and Unit of Work. Use the curried `createProvider()` form so the required `db` port, the app context, and the provided ports stay typed without casts: ```typescript // infra/db/provider.ts import { createProvider } from "@beignet/core/providers"; import { createDrizzleSqliteIdempotencyPort, createDrizzleSqliteUnitOfWork, type DbPort, } from "@beignet/provider-db-drizzle/sqlite"; import type { AppContext } from "@/app-context"; import type { AppPorts } from "@/ports"; import type { AppServiceContextInput } from "@/server"; import { createRepositories } from "./repositories"; import type * as schema from "./schema"; export const appDatabaseProvider = createProvider< { db: DbPort }, AppContext, AppServiceContextInput >()({ name: "app-database", async setup({ ports }) { const repositories = createRepositories(ports.db.drizzle); const idempotency = createDrizzleSqliteIdempotencyPort(ports.db.drizzle); const providedPorts: Pick = { ...repositories, idempotency, uow: createDrizzleSqliteUnitOfWork({ db: ports.db.drizzle, createTransactionPorts: (tx) => ({ ...createRepositories(tx), idempotency: createDrizzleSqliteIdempotencyPort(tx), }), }), }; return { ports: providedPorts }; }, }); ``` Register it in `server/providers.ts` after `createDrizzleSqliteProvider`, which installs the `db` port it requires. The repository keys stay deferred in `infra/port-wiring.ts`, and the server fails boot if a deferred port is still unbound after providers have started. `ctx.ports.db.drizzle` is an infrastructure escape hatch. Keep it out of use cases; use cases should call `ctx.ports.posts` or `ctx.ports.uow.transaction(...)`. ## Connection ownership and pool sizing Create one database client per running app process and share it with every integration that uses the same database. Generated apps keep that singleton in `infra/db/client.ts`; Better Auth and the Beignet Drizzle provider both import it. Keep that shared client app/process-owned: a failed server boot may clean up providers and retry, while Better Auth still references the same module-scoped client. ```typescript // infra/db/client.ts (Postgres) import pg from "pg"; import { env } from "@/lib/env"; export const databaseClient = new pg.Pool({ connectionString: env.POSTGRES_DB_URL, max: env.POSTGRES_DB_POOL_MAX, }); let closePromise: Promise | undefined; export function closeDatabaseClient(): Promise { closePromise ??= Promise.resolve().then(() => databaseClient.end()); return closePromise; } ``` ```typescript // server/providers.ts const drizzlePostgresProvider = createDrizzlePostgresProvider({ schema, client: databaseClient, }); ``` Injected clients are caller-owned by default. Set `closeOnStop: true` only when the client belongs exclusively to that Beignet server and can be discarded after failed initialization. When a client is shared with Better Auth, leave it app-owned. Generated `server/index.ts` wraps the successfully created server's public `stop()` so it stops providers first and then calls the idempotent `closeDatabaseClient()`. Failed boot cleanup does not close the shared client, so `createNextServerLoader(...)` can retry safely. Process termination reclaims it when a serverless host does not call `stop()`. When no client is injected, the provider creates its own env-backed client and always closes it. Provider audit treats each database URL as required for the standard setup. If an injected platform client genuinely does not use that variable, declare the exception explicitly without weakening runtime validation: ```typescript import { defineConfig } from "@beignet/cli/config"; export default defineConfig({ providerAudit: { ignoreRequiredEnv: ["POSTGRES_DB_URL"], }, }); ``` Budget connections across the whole deployment: ```text per-process pool max <= (database connection limit - operational reserve) / maximum concurrent processes sharing the database ``` Count web replicas, serverless instances, queue workers, previews, tasks, and migration jobs. Generated Postgres and MySQL apps default their per-process pool maximum to `5`; this is a conservative starting point, not an automatic capacity decision. Serverless deployments should generally use a smaller pool and a provider-managed pooled database endpoint. Worker concurrency can exceed pool size, but database-heavy jobs will then wait for connections. Local `file:` SQLite is suitable for one writable host. Use hosted libSQL, Postgres, or MySQL when multiple hosts need concurrent database access. Migration, reset, and test commands may create separate transient clients, but they must close those clients before exiting. ## List queries Use `@beignet/core/pagination` for list boundaries. Contracts still own their query schema, while use cases normalize the validated input before calling a repository: ```typescript import { normalizeCursorPage } from "@beignet/core/pagination"; const page = normalizeCursorPage(input, { defaultLimit: 20, maxLimit: 100, }); return ctx.ports.posts.findMany({ page, cursor: input.cursor ? decodePostCursor(input.cursor) : null, filters: { status: input.status }, sort: { field: "createdAt", direction: "desc" }, }); ``` List responses should use `items` for the records and `page` for pagination metadata. Generated resources use cursor metadata with `nextCursor` and `hasMore`, filter names with case-insensitive contains matching, and sort only by allowlisted fields. Keep filters and sort values as app-owned plain objects so Beignet does not become a query builder. ## Aggregates When a feature needs summary data — counts, grouped counts — give the repository port a purpose-built aggregate method instead of paging `findMany` and counting rows in the use case. Paging a list method to compute a count is an anti-pattern: it transfers every row to count them and couples the summary to pagination limits. Name grouped counts `countBy` and return an app-typed shape: ```typescript // features/issues/ports.ts import type { TenantScope } from "@beignet/core/tenancy"; export interface IssueRepository { // ... countByStatus(scope: TenantScope): Promise>; } ``` The adapter implements the aggregate as one grouped query. Aggregates share the port's row-visibility semantics: the same soft-delete, archive, and tenant scoping that filters `findMany` applies to the counts, so a summary never reports records the list would hide. ## Optimistic concurrency Generated CRUD resources include this convention by default: schemas expose a numeric `version`, update bodies send it back, repositories compare and increment it in one statement, and stale updates map to the generated conflict catalog error. Repository writes include the expected version in the `WHERE` clause and increment it in the same statement: ```typescript import { tenantScopeId } from "@beignet/core/tenancy"; const [row] = await db .update(schema.posts) .set({ title: input.title, version: input.expectedVersion + 1, updatedAt: new Date().toISOString(), }) .where( and( eq(schema.posts.slug, input.slug), eq(schema.posts.tenantId, tenantScopeId(scope)), eq(schema.posts.version, input.expectedVersion), isNull(schema.posts.deletedAt), ), ) .returning(); ``` If no row is updated, check whether the active row still exists. Return a not-found result when it does not, and a conflict result when the row exists with a different version. Use cases can map that conflict to an app error such as `POST_VERSION_CONFLICT`. Action routes that have no request body can carry the expected version in a header instead. ## Soft delete and archive For records that matter later, prefer lifecycle columns over hard deletes: ```typescript export const posts = sqliteTable("posts", { id: text("id").primaryKey(), tenantId: text("tenant_id").notNull(), version: integer("version").notNull().default(1), deletedAt: text("deleted_at"), archivedAt: text("archived_at"), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }); ``` Normal `findMany` and `findBy...` repository methods should filter out `deletedAt` and `archivedAt` records by default; expose explicit recovery or admin methods when an app needs the rest. Use soft delete to retain records for recovery, audit, or compliance; use archive to move a record out of the active workflow; reserve hard delete for records your app may physically erase. ## Record history Audit logs answer "who did what"; record history answers "what changed on this record." When a feature needs history, keep it behind a feature-owned repository port (for example `PostHistoryRepository.record(...)` with `before` and `after` snapshots, actor fields, and `occurredAt`), and write history rows inside the same Unit of Work transaction as the business change so history commits and rolls back with the data. For large or sensitive records, store redacted snapshots or field-level patches instead of full JSON. The important convention is that history is append-only and transaction-scoped. ## Transactions Use `ctx.ports.uow.transaction(...)` when a workflow needs multiple operations to commit or rollback together: ```typescript const createPostUseCase = useCase .command("posts.create") .input(CreatePostInputSchema) .output(PostSchema) .run(async ({ ctx, input }) => ctx.ports.uow.transaction((tx) => tx.posts.create(input)), ); ``` When a use case records domain events, expose the transaction-local recorder in your transaction ports and publish events after commit: ```typescript type AppTransactionPorts = AppRepositoryPorts & { events: DomainEventRecorderPort; }; uow: createDrizzleSqliteUnitOfWork({ db: ports.db.drizzle, eventBus: ports.eventBus, createTransactionPorts: (tx, events) => ({ ...createRepositories(tx), events, }), }); ``` Then record events inside the transaction: ```typescript const post = await ctx.ports.uow.transaction(async (tx) => { const created = await tx.posts.create(input); await events.record(tx.events, postCreated, { postId: created.id }); return created; }); ``` The mechanics: events recorded inside the transaction are discarded on rollback; on commit, the helper validates, parses, and flushes them to `eventBus`. If flushing fails after commit, `transaction(...)` rejects but the database transaction is already committed. A caller cannot infer rollback from that rejection and should not blindly retry non-idempotent work. See [Workflows and state machines](/workflows) for the after-commit concept and [Outbox](/outbox) when events or jobs need durable delivery guarantees. Put every durable write that must commit with the business change behind a transaction-scoped port created from the Unit of Work transaction client: repository writes, history rows, audit entries, outbox records, and durable idempotency reservations. The Drizzle/libSQL convention rebuilds those ports from `tx` inside `createTransactionPorts`; root ports stay useful for reads and background work but do not join the current transaction. SQLite allows one writer at a time. Beignet queues its SQLite Unit of Work, root outbox-claim, and root idempotency-reservation transactions per Drizzle client so concurrent requests wait instead of receiving a local `SQLITE_BUSY` error. That coordination is intentionally process-local. Use Postgres or MySQL when outbox workers run across multiple hosts; their adapters use transactional row locking and `SKIP LOCKED` for competing claims. Beignet's shared provider conformance suite races these paths in CI and verifies single execution, claim-token ownership, lease recovery, and idempotency replay behavior across the supported dialects. InnoDB may choose a transaction as a deadlock victim under heavy contention. The MySQL adapter retries Beignet-owned outbox-claim and idempotency-reservation transactions up to eight times with exponential backoff, proportional jitter, and a 250 ms per-delay cap. App-owned Unit of Work callbacks are never retried by this mechanism. ## Other databases `@beignet/provider-db-drizzle` ships one subpath per backend — `/sqlite` (libSQL), `/postgres` (node-postgres), and `/mysql` (`mysql2`, MySQL 8.0+) — and all three expose the same provider, Unit of Work, outbox, and idempotency surface. Everything on this page carries over: contracts, use cases, policies, and routes keep depending on ports; only the infra adapter and provider wiring change. Pick the backend when you create the app: ```bash bun create beignet my-app --db postgres ``` `--db` accepts `sqlite` (the default), `postgres`, and `mysql`; in interactive mode a database prompt appears alongside the other setup prompts. The starter scaffolds the chosen backend end to end: provider wiring, an idiomatic Drizzle schema, the vendored initial migration (including the provider's idempotency setup statements), `POSTGRES_DB_URL` or `MYSQL_DB_URL` env examples, and a matching `infra/db/test-database.ts`. Later `make resource` and `make feature` runs detect the app's backend from `infra/db/repositories.ts` and generate dialect-correct schema and repository code. Postgres apps need a running Postgres 14+ server for development and builds; MySQL apps need MySQL 8.0+. `beignet db generate` and `beignet db migrate` work unchanged for every dialect — each starter sets the matching drizzle-kit dialect — but Postgres and MySQL need the server running first. See [Quickstart](/getting-started) for docker one-liners. ### Timestamps are ISO-8601 text in every dialect Scaffolded app tables are idiomatic per dialect — native booleans, `varchar` ids on MySQL — with one deliberate exception: timestamp columns are ISO-8601 UTC strings in text columns in all three dialects. Cursors, optimistic concurrency checks, and contract responses compare and serialize timestamps as strings, so keeping the storage format identical keeps pagination and conflict semantics identical across backends. A later release may move the Postgres starter to native `timestamptz`. ### Testing per backend Each starter writes a dialect-matched `infra/db/test-database.ts`. SQLite tests use an in-memory libSQL database, and Postgres tests run against in-process PGlite — both are zero infrastructure. MySQL has no in-process engine, so tests that go through `createTestDatabase()` need a real server: the generated helper reads `MYSQL_TEST_URL` and throws with a docker one-liner when it is unset. The MySQL starter's own generated tests use in-memory fakes and pass without a server. ### Switching an existing app Apps created before `--db` existed, or apps changing backends after creation, switch manually. For Postgres, install the driver (`bun add pg`), set `POSTGRES_DB_URL`, change the `drizzle.config.ts` dialect to `"postgresql"`, and swap the subpath imports: ```typescript // server/providers.ts import { createDrizzlePostgresProvider } from "@beignet/provider-db-drizzle/postgres"; import * as schema from "@/infra/db/schema"; export const providers = [createDrizzlePostgresProvider({ schema })]; ``` ```typescript // infra/db/provider.ts — inside the app database provider's setup({ ports }) import { createDrizzlePostgresIdempotencyPort, createDrizzlePostgresUnitOfWork, } from "@beignet/provider-db-drizzle/postgres"; uow: createDrizzlePostgresUnitOfWork({ db: ports.db.drizzle, createTransactionPorts: (tx) => ({ ...createRepositories(tx), idempotency: createDrizzlePostgresIdempotencyPort(tx), }), }), ``` Repository factories take `DrizzlePostgresDatabase` instead of `DrizzleSqliteDatabase`, and the outbox and idempotency tables come from `createDrizzlePostgresOutboxSetupStatements()` and `createDrizzlePostgresIdempotencySetupStatements()` run through your migration flow. MySQL mirrors this with `@beignet/provider-db-drizzle/mysql`, `MYSQL_DB_URL`, and `DrizzleMysql` naming. The [`@beignet/provider-db-drizzle` README](https://www.npmjs.com/package/@beignet/provider-db-drizzle) is the deep per-backend reference, including pool options, the PlanetScale mode for MySQL, and the design notes shared across backends. ## Migrations and local setup Keep Drizzle CLI config at the app root: ```typescript // drizzle.config.ts export default { schema: "./infra/db/schema/index.ts", out: "./drizzle", dialect: "sqlite", dbCredentials: { url: process.env.SQLITE_DB_URL ?? "file:local.db", authToken: process.env.SQLITE_DB_AUTH_TOKEN, }, }; ``` New apps ship with the initial migration vendored into the scaffold's `drizzle/` folder, so `beignet db migrate` is the first database command you run. There is no bootstrap DDL at application boot: the schema comes entirely from migrations, the vendored one plus the ones you generate. When you add Beignet's Drizzle-backed operational ports for audit, idempotency, or outbox, first bring your app schema's provider table re-exports in sync with the installed providers: ```bash beignet db schema sync ``` That command idempotently writes `infra/db/schema/beignet.ts`, re-exports it from `infra/db/schema/index.ts`, and leaves SQL generation to your app's normal Drizzle Kit scripts. Pass `--tables audit,idempotency,outbox` to sync only the tables you are adopting in the next migration. If your Drizzle schema lives in a shared package, list that source in `beignet.config.ts` so `doctor` and provider audits can verify operational tables there: ```typescript import { defineConfig } from "@beignet/cli/config"; export default defineConfig({ database: { schemaSources: ["@acme/db/schema"], }, }); ``` Use Beignet database lifecycle commands from the app root when the schema or local data changes: ```bash beignet db generate beignet db migrate beignet db status beignet db seed beignet db reset ``` `beignet db generate` and `beignet db migrate` delegate to the app's Drizzle Kit scripts. `beignet db status` delegates to the read-only `infra/db/migration-status.ts` entrypoint, compares checked-in timestamps and SQL hashes with database history, and exits 2 when migrations are pending. `beignet db seed` and `beignet db reset` delegate to app-owned entrypoints such as `server/seed.ts` and `infra/db/reset.ts`. The CLI checks prerequisites before it runs the package script, and `doctor` reports drift in the same places, plus missing schema index exports and reset files that no longer mention `BEIGNET_ALLOW_DATABASE_RESET`. MCP-aware coding agents can stay on the structured Beignet surface throughout this workflow. Call `db_schema_sync` to preview or apply provider-table schema re-exports, then call `db` with `generate` and `migrate`, followed by the read-only `db_status` tool. The `db` tool also supports `seed` and `reset`, bounds captured output, cancels the complete child process tree with the request, and applies a configurable timeout. All three tools return the same versioned reports as the CLI/library APIs. Lifecycle `dryRun` validates prerequisites and reports the app-owned script without executing it; it does not simulate the script's SQL or data changes. For local SQLite development, keep `SQLITE_DB_URL` unset or set it to a `file:` URL. For hosted libSQL deployments such as Turso, set `SQLITE_DB_URL` and `SQLITE_DB_AUTH_TOKEN` in the deployment environment and run migrations as an explicit deployment step. Treat seeds as local/demo data unless the app owns a separate production seed entrypoint. The generated reset script refuses to run against non-local database URLs unless `BEIGNET_ALLOW_DATABASE_RESET=true` is set. ## Testing Repository tests should run against an isolated local database. The starter writes `infra/db/test-database.ts` with this shape: an in-memory database that applies the app's migrations, the same DDL path production uses. Keep the helper in infra and the behavior test with the feature: ```typescript // infra/db/test-database.ts import { createClient } from "@libsql/client"; import { drizzle } from "drizzle-orm/libsql"; import { migrate } from "drizzle-orm/libsql/migrator"; import { createRepositories } from "./repositories"; import * as schema from "./schema"; export async function createTestDatabase() { const client = createClient({ url: "file::memory:" }); const db = drizzle(client, { schema }); await migrate(db, { migrationsFolder: "drizzle" }); return { repositories: createRepositories(db), reset: async () => { await client.execute("DELETE FROM posts"); }, close: async () => { client.close(); }, }; } ``` ```typescript // features/posts/tests/persistence.test.ts import { createDatabaseTestHarness } from "@beignet/core/testing"; import { demoPostsSeed } from "@/features/posts/seeds"; import { postFactory } from "@/features/posts/tests/factories"; const databaseHarness = createDatabaseTestHarness({ create: createTestDatabase, ctx: (database) => ({ repositories: database.repositories }), reset: (database) => database.reset(), close: (database) => database.close(), factories: [postFactory], seeds: [demoPostsSeed], }); afterEach(async () => { await databaseHarness.cleanup(); }); const { ctx } = await databaseHarness.setup({ seed: true }); const post = await postFactory.create(ctx, { title: "Database conventions", content: "Use repository ports from use cases.", }); expect( await ctx.repositories.posts.findBySlug({ slug: post.slug, tenantId: post.tenantId, }), ).toMatchObject({ id: post.id }); ``` Use `createNoopUnitOfWork(...)` for pure use-case tests that do not need a real database transaction. Use a real local database test when the behavior belongs to SQL, indexes, joins, constraints, or repository mapping. Factories and seeds live with the feature because they describe app data, not database tables. Their `persist` functions should call repository ports so the same setup works against memory ports, isolated local databases, or transaction scoped test contexts. ## When to use what Use `ctx.ports.posts` directly for simple reads and operations that do not need a transaction. Use `ctx.ports.uow.transaction(...)` for writes that coordinate multiple repositories, emit domain events, enqueue jobs, send notifications, or need a clear commit boundary. --- # Cache Source: https://www.beignetjs.com/cache Cache is an application dependency behind `CachePort`. Use it when a workflow can reuse expensive reads, keep short-lived computed data, or share lightweight state across requests without coupling use cases to Redis. The important boundary is simple: application code talks to `ctx.ports.cache`; the runtime chooses the adapter. ## Setup Use the Redis provider when production needs a shared cache: ```bash bun add @beignet/provider-cache-redis ioredis ``` ```typescript import { createNextServer, createNextServerLoader } from "@beignet/next"; import { createRedisCacheProvider } from "@beignet/provider-cache-redis"; import { initialPorts } from "@/infra/port-wiring"; export const getServer = createNextServerLoader(() => createNextServer({ ports: initialPorts, providers: [createRedisCacheProvider()], context: appContextBlueprint, }), ); ``` The provider reads `REDIS_URL` and optional `REDIS_DB`, `REDIS_CONNECT_TIMEOUT_MS`, `REDIS_MAX_RETRIES_PER_REQUEST`, and `REDIS_CONNECT_MAX_ATTEMPTS` from environment variables and installs `ctx.ports.cache`. Startup fails fast with a clear error when Redis is unreachable instead of retrying forever; after a successful connection, lost connections reconnect with capped exponential backoff. Use `createRedisCacheProvider(options)` when the app should own connection defaults. Defined options override matching environment variables: ```typescript import { createRedisCacheProvider } from "@beignet/provider-cache-redis"; const provider = createRedisCacheProvider({ connectTimeoutMs: 2000, maxRetriesPerRequest: 1, }); ``` When app infrastructure already owns a connected ioredis client, adapt it without provider lifecycle or environment loading: ```typescript import { createRedisCache } from "@beignet/provider-cache-redis"; const cache = createRedisCache({ client: appRedis }); ``` The caller owns connection, retries, health checks, and shutdown in this mode. Pass `instrumentation` to preserve cache provider events. ## Port API `CachePort` stores string values: ```typescript export interface CachePort { get(key: string): Promise; set( key: string, value: string, options?: { ttlSeconds?: number }, ): Promise; delete(key: string): Promise; has(key: string): Promise; remember( key: string, factory: () => Promise, options?: { ttlSeconds?: number }, ): Promise; } ``` Omit `ttlSeconds` when a value should persist until explicit invalidation. When provided, `ttlSeconds` must be a positive safe integer, so memory and Redis adapters interpret the same write identically. Zero, negative, fractional, non-finite, and unsafe integer values throw before the cache is read or written. Custom cache adapters can call `resolveCacheTtlSeconds(options)` from `@beignet/core/ports` before reading or writing. The helper returns `undefined` for a persistent value and throws for an invalid TTL. Keep serialization at the application boundary so cached values stay typed: ```typescript import { z } from "zod"; const ProjectSummarySchema = z.object({ id: z.string(), name: z.string(), openIssueCount: z.number().int().nonnegative(), }); export async function getProjectSummary(ctx: AppContext, projectId: string) { const key = `project:${projectId}:summary`; const serialized = await ctx.ports.cache.remember( key, async () => { const summary = await ctx.ports.projects.getSummary(projectId); return JSON.stringify(summary); }, { ttlSeconds: 60 }, ); return ProjectSummarySchema.parse(JSON.parse(serialized)); } ``` ## Key conventions Use predictable keys that include the resource and scope: ```typescript const projectKey = `project:${projectId}`; const userFeedKey = `user:${userId}:feed`; const tenantStatsKey = `tenant:${tenantId}:stats:${day}`; ``` Prefer short TTLs for derived reads. Use explicit invalidation when writes make cached data stale: ```typescript await ctx.ports.projects.update(projectId, input); await ctx.ports.cache.delete(`project:${projectId}:summary`); ``` If invalidation becomes hard to reason about, move the invalidation rule into the use case or an event listener so HTTP routes, jobs, scripts, and tests all share it. ## Escape hatch The Redis provider contributes `ctx.ports.redis` with the underlying `ioredis` client for operations the stable cache port does not model: ```typescript await ctx.ports.redis.client.incr("project:created-count"); ``` Use the stable `CachePort` for normal application behavior. Use the raw client only when the Redis-specific operation is intentional. See [escape hatches](/providers#escape-hatches) for the convention. ## Devtools Cache operations appear in the Cache view of [devtools](/devtools) when the devtools provider is installed before the Redis provider. Cached values are not recorded. ## Testing Tests can use the first-party in-memory adapter instead of booting Redis: ```typescript import { createMemoryCache } from "@beignet/core/ports"; const cache = createMemoryCache(); ``` This keeps tests focused on cache behavior without depending on networked infrastructure. Memory cache state is per-process: entries written by a script or another process are invisible to the dev server. See [Process boundaries of memory providers](/providers#process-boundaries-of-memory-providers). --- # Search Source: https://www.beignetjs.com/search Use `SearchPort` when application code needs to index and query searchable read models without depending on a specific search service. Search indexes are application read models. Keep writes, authorization, and business invariants in your database and use search for discovery, filtering, sorting, facets, and ranking. ## Define an index Define index metadata near the feature that owns the searchable document: ```typescript // features/issues/search.ts import { defineSearchIndex } from "@beignet/core/search"; export type IssueSearchDocument = { id: string; tenantId: string; key: string; title: string; status: "open" | "resolved"; createdAt: string; }; export const issueSearchIndex = defineSearchIndex( "issues", { searchableAttributes: ["key", "title"], filterableAttributes: ["tenantId", "status"], sortableAttributes: ["createdAt"], }, ); ``` ## Index documents Use cases, listeners, jobs, and tasks can index documents through the same port: ```typescript await ctx.ports.search.indexDocuments(issueSearchIndex, { id: issue.id, tenantId: issue.tenantId, key: issue.key, title: issue.title, status: issue.status, createdAt: issue.createdAt, }); ``` For durable indexing, prefer after-commit listeners or outbox-backed workflows so the search index follows committed database state. Use tasks for backfills: ```typescript await ctx.ports.search.configureIndex(issueSearchIndex); await ctx.ports.search.indexDocuments( issueSearchIndex, issues.map(issueToSearchDocument), ); ``` ## Query documents ```typescript const results = await ctx.ports.search.search(issueSearchIndex, { query: "billing", filters: { tenantId, status: "open", }, sort: ["createdAt:desc"], facets: ["status"], limit: 20, offset: 0, }); ``` `filters` are provider-neutral exact-match filters. Providers translate them to their native query language. Keep user authorization in use cases and policies; include tenant or visibility filters when the index contains multi-tenant data. The Meilisearch provider accepts filters and facets only for fields declared in `filterableAttributes`, and sort entries only for fields declared in `sortableAttributes`. Map request input to app-owned allow-listed field names before calling `ctx.ports.search.search(...)`. Non-2xx Meilisearch responses throw `MeilisearchHttpError` with the request path, status, and response body. The adapter preserves plain-text or HTML responses from proxies and gateways, so an upstream outage does not turn into an unrelated JSON parse error. ## Setup with Meilisearch Install the Meilisearch search provider: ```bash bun add @beignet/provider-search-meilisearch ``` Register it in `server/providers.ts`: ```typescript import { createMeilisearchSearchProvider } from "@beignet/provider-search-meilisearch"; export const providers = [ createMeilisearchSearchProvider({ indexPrefix: "my-app", }), ]; ``` Set `MEILISEARCH_HOST` in production. Optional env vars include `MEILISEARCH_API_KEY`, `MEILISEARCH_INDEX_PREFIX`, and `MEILISEARCH_TIMEOUT_MS`. The provider contributes `ctx.ports.search` and `ctx.ports.meilisearch` as an escape hatch with the raw client and configured index prefix. ## Testing `createTestPorts(...)` includes an in-memory search port by default: ```typescript const { ports, search } = createTestPorts(); await ports.search.indexDocuments(issueSearchIndex, issueDocument); await expect( search.search(issueSearchIndex, { query: "billing", filters: { tenantId: "tenant_example" }, }), ).resolves.toMatchObject({ hits: [issueDocument], }); ``` You can also import the memory adapter directly: ```typescript import { createMemorySearch } from "@beignet/core/search"; const search = createMemorySearch(); ``` > **Memory search is per-process.** Documents indexed by a seed script are > invisible to a dev server running `createMemorySearchProvider()` — they are > different processes. Rebuild the index inside the serving process with a > backfill [task](/tasks) or outbox-driven sync listeners, or use > `@beignet/provider-search-meilisearch` so both processes share one store. > See > [Process boundaries of memory providers](/providers#process-boundaries-of-memory-providers). ## Related pages - [Ports and adapters](/ports) for app-facing dependency boundaries. - [Tasks](/tasks) for search backfills. - [Outbox](/outbox) for durable post-commit indexing. - [Providers](/providers) for provider setup and escape hatches. --- # Storage Source: https://www.beignetjs.com/storage Storage is an application dependency behind `StoragePort`. Use it when a workflow needs to read or write files, exports, imports, attachments, generated documents, or uploaded objects without coupling use cases to S3, R2, GCS, Vercel Blob, or local disk. The boundary is intentionally small: application code talks to `ctx.ports.storage`; infra chooses the adapter. ## Setup Install the storage port and local provider: ```bash bun add @beignet/core @beignet/provider-storage-local ``` Use the local filesystem provider in development: ```typescript import { createLocalStorageProvider } from "@beignet/provider-storage-local"; export const providers = [createLocalStorageProvider()]; ``` The provider reads `STORAGE_` config: ```bash STORAGE_ROOT=storage/app STORAGE_PUBLIC_BASE_URL=/storage ``` `STORAGE_ROOT` defaults to `storage/app`. `STORAGE_PUBLIC_BASE_URL` is optional and may be an absolute URL or app-relative path. It only controls the URL returned by `publicUrl(...)`; when using local filesystem storage, add a storage route for that path. ```typescript // app/storage/[...key]/route.ts import { createStorageRoute } from "@beignet/next"; import { getServer } from "@/server"; export const { GET, HEAD } = createStorageRoute( async () => (await getServer()).ports.storage, { basePath: "/storage", }, ); ``` The route serves public objects only. Missing objects, private objects, invalid keys, and paths outside `basePath` all return 404. Responses include `X-Content-Type-Options: nosniff`. The default `contentDisposition: "auto"` serves active public content types such as HTML, SVG, XML, and JavaScript as downloads while keeping ordinary assets inline. Override the route's `contentDisposition` or `headers` only when the app intentionally serves active public assets from that origin. Use the memory adapter in tests and pure in-memory examples: ```typescript import { createMemoryStorage, definePorts } from "@beignet/core/ports"; export const testPorts = definePorts({ storage: createMemoryStorage(), }); ``` Production apps can swap in the S3-compatible provider or another app-owned storage provider. The application-facing API should stay `ctx.ports.storage` either way. ## S3-compatible storage Use `@beignet/provider-storage-s3` when storage needs to survive deploys, work across multiple app instances, or run on infrastructure with ephemeral local disk. The provider works with AWS S3 and S3-compatible services such as Cloudflare R2, MinIO, Backblaze B2, and DigitalOcean Spaces. ```bash bun add @beignet/provider-storage-s3 @aws-sdk/client-s3 @aws-sdk/lib-storage@3.1050.0 @aws-sdk/s3-request-presigner ``` ```typescript import { createS3StorageProvider } from "@beignet/provider-storage-s3"; export const providers = [createS3StorageProvider()]; ``` For AWS S3: ```bash STORAGE_S3_BUCKET=my-app-assets STORAGE_S3_REGION=us-east-1 STORAGE_S3_PUBLIC_BASE_URL=https://cdn.example.com ``` For Cloudflare R2: ```bash STORAGE_S3_BUCKET=my-app-assets STORAGE_S3_REGION=auto STORAGE_S3_ENDPOINT=https://.r2.cloudflarestorage.com STORAGE_S3_ACCESS_KEY_ID=... STORAGE_S3_SECRET_ACCESS_KEY=... STORAGE_S3_PUBLIC_BASE_URL=https://assets.example.com ``` `STORAGE_S3_KEY_PREFIX` can scope every object key for an app or environment. `STORAGE_S3_FORCE_PATH_STYLE=true` is available for S3-compatible services that need path-style bucket addressing. S3 operations are idempotent, so bounded retry is appropriate. Beignet does not wrap S3 calls in its own retry loop; when the provider creates the AWS SDK client, the SDK retries transient failures itself — `standard` retry mode with 3 attempts, including the first, by default. Tune that with `STORAGE_S3_MAX_ATTEMPTS` and `STORAGE_S3_RETRY_MODE` (`standard` or `adaptive`), or the matching `maxAttempts` and `retryMode` provider options. Choose `adaptive` when the app regularly hits S3 throttling. Injected clients keep their own retry configuration. The S3 provider stores Beignet visibility as reserved object metadata and does not set bucket ACLs. Configure bucket policies, public buckets, custom domains, or a CDN outside the provider when public objects should be reachable. The provider also installs `ctx.ports.s3Storage` as an [escape hatch](/providers#escape-hatches) for S3-specific operations that do not belong in `StoragePort`. Use `ctx.ports.s3Storage.objectKey(key)` when a direct S3 call needs to address an object written through `ctx.ports.storage`; use `ctx.ports.s3Storage.objectPrefix(prefix)` for direct S3 list operations. Both helpers apply the configured `STORAGE_S3_KEY_PREFIX`. On AWS, grant `s3:ListBucket` on the bucket in addition to the object actions the app uses. Without it, S3 commonly returns `403 AccessDenied` rather than `404 NotFound` for an absent key, so `get`, `stat`, `exists`, and `delete` may throw instead of returning their missing-object result. Beignet does not map a generic 403 to “missing” because that would hide real credential or bucket policy failures. Scope object permissions to the configured key prefix where possible. ## Vercel Blob storage Use `@beignet/provider-storage-vercel-blob` on Vercel deployments. A connected Blob store can use `BLOB_READ_WRITE_TOKEN`, or Vercel OIDC with `VERCEL_OIDC_TOKEN` plus `BLOB_STORE_ID`: ```bash bun add @beignet/provider-storage-vercel-blob @vercel/blob # or scaffold everything: bun beignet provider add storage-vercel-blob ``` ```typescript import { createLocalStorageProvider } from "@beignet/provider-storage-local"; import { createVercelBlobStorageProvider } from "@beignet/provider-storage-vercel-blob"; const hasVercelBlobCredentials = Boolean(process.env.BLOB_READ_WRITE_TOKEN) || Boolean(process.env.BLOB_STORE_ID && process.env.VERCEL_OIDC_TOKEN); export const providers = [ // Vercel Blob in deployed environments (local disk does not survive // serverless); the local provider keeps dev working with zero setup. hasVercelBlobCredentials ? createVercelBlobStorageProvider() : createLocalStorageProvider(), ]; ``` The store is uniform-visibility: Vercel Blob does not report per-object access back from the API, so every object shares the configured `BLOB_ACCESS` (default `private`), and writes that request a different `visibility` are rejected instead of silently misreporting visibility on later reads. Serve private objects through app routes that authorize and then stream `ctx.ports.storage.get(...)`; run a second provider over a second store when an app genuinely needs both levels. `ctx.ports.vercelBlob` is the [escape hatch](/providers#escape-hatches) for raw SDK access, key-prefix helpers, and a health check. For Vercel OIDC authentication, set `BLOB_STORE_ID` or pass `storeId` directly to `createVercelBlobStorage(...)`; the Blob SDK reads `VERCEL_OIDC_TOKEN`. Beignet forwards the store id to every Blob SDK operation, including reads, writes, deletes, lists, and health checks; the escape hatch exposes the resolved value as `ctx.ports.vercelBlob.storeId`. Because either a read-write token or OIDC is valid, doctor, provider audit, and preflight accept either `BLOB_READ_WRITE_TOKEN` or the complete `BLOB_STORE_ID` plus `VERCEL_OIDC_TOKEN` pair. Run `beignet preflight --connect` to verify the selected credential path against the store. ## Port API `StoragePort` models object storage: ```typescript export interface StoragePort { put( key: string, body: StorageBody, options?: { contentType?: string; cacheControl?: string; metadata?: Record; visibility?: "private" | "public"; }, ): Promise; get(key: string): Promise; stat(key: string): Promise; delete(key: string): Promise; exists(key: string): Promise; publicUrl(key: string): Promise; } ``` `StorageBody` accepts `string`, `Uint8Array`, `ArrayBuffer`, `Blob`, or a `ReadableStream`. `get(...)` returns object metadata plus helpers for reading the body as bytes, text, an array buffer, or a stream: The S3-compatible adapter sends an exact `Content-Length` for strings, byte arrays, and Blobs. Generic `ReadableStream` bodies remain streaming through the AWS SDK's managed uploader, which holds a bounded set of 5 MiB parts in memory, uses multipart upload for larger streams, and aborts failed multipart uploads. Grant `s3:AbortMultipartUpload` when the app accepts generic streams. Large browser files should still use the signed direct-upload workflow so their bytes do not pass through the application server. ```typescript export interface StorageObjectBody extends StorageObject { readonly bodyUsed: boolean; stream(): ReadableStream; bytes(): Promise; arrayBuffer(): Promise; text(): Promise; } ``` Object bodies are one-shot reads, similar to Fetch responses. Choose one read method per returned object. Call `get(...)` again if the workflow needs a fresh body. ## Use storage in a workflow Keep storage keys predictable and make ownership explicit: ```typescript export async function exportProject(ctx: AppContext, projectId: string) { const project = await ctx.ports.projects.findById(projectId); const body = JSON.stringify(project, null, 2); const key = `projects/${projectId}/exports/latest.json`; const object = await ctx.ports.storage.put(key, body, { contentType: "application/json", cacheControl: "private, max-age=0", metadata: { projectId }, visibility: "private", }); return { key: object.key, size: object.size, }; } ``` For public assets, write with public visibility and ask the adapter for a URL: ```typescript await ctx.ports.storage.put("avatars/user_123.png", avatarBytes, { contentType: "image/png", visibility: "public", }); const url = await ctx.ports.storage.publicUrl("avatars/user_123.png"); ``` `publicUrl(...)` returns `null` when the object is missing, private, or the adapter does not expose public URLs. For local filesystem storage, `createStorageRoute(...)` streams public objects and preserves `Content-Type`, `Cache-Control`, `Content-Length`, and `Last-Modified` response headers. ## Key conventions Prefer keys that include the resource, owner, and purpose: ```typescript const avatarKey = `users/${userId}/avatar/original.png`; const importKey = `imports/${tenantId}/${importId}/source.csv`; const exportKey = `projects/${projectId}/exports/${exportId}.json`; ``` Keys must be relative object keys: no empty strings, control characters, empty path segments, leading or trailing `/`, backslashes, or `.` / `..` path segments. Avoid putting untrusted file names directly at the front of the key. Normalize names in infra or place them after an app-owned prefix so user input cannot escape the intended namespace. Custom adapters should reuse Beignet's shared storage-key helpers instead of creating provider-specific rules: ```typescript import { assertValidStorageKey, createStoragePublicUrl, normalizeStorageKeyPrefix, prefixStorageKey, } from "@beignet/core/ports"; ``` `assertValidStorageKey(...)` enforces the common contract. `normalizeStorageKeyPrefix(...)` and `prefixStorageKey(...)` apply an optional app or environment namespace, while `createStoragePublicUrl(...)` preserves path separators and encodes each key segment. Adapters may add restrictions for provider-owned internal paths after the shared assertion. ## Handling uploads Use [Uploads](/uploads) for browser-upload workflows. Upload definitions own file constraints, metadata validation, authorization, key generation, direct upload signing, and completion hooks. They write accepted files through `StoragePort`, then let app-owned repositories persist attachment ownership, status, display names, scanning state, or moderation state. Use `ctx.ports.storage.put(...)` directly for app-generated files, imports, exports, and other workflows that already have trusted bytes inside the server. ## Testing Use `createMemoryStorage()` in use case tests: ```typescript import { createMemoryStorage } from "@beignet/core/ports"; const storage = createMemoryStorage(); await storage.put("reports/test.txt", "hello"); expect(await (await storage.get("reports/test.txt"))?.text()).toBe("hello"); ``` This keeps storage behavior testable without networked infrastructure. --- # Uploads Source: https://www.beignetjs.com/uploads Uploads are typed application workflows above `StoragePort`. Use them when a route needs file constraints, authorization, metadata validation, storage keys, and completion behavior in one predictable place. `StoragePort` stores objects. Upload definitions decide who may upload, what files are accepted, where objects are written, and what app records or audit events are created after upload completion. ## Generate an upload workflow Generate the backend definition, registry, route, local storage wiring, and a client-safe constraint manifest: ```bash beignet make upload posts.attachment ``` In a full-stack app, add `--ui` to generate the connected browser workflow as well: ```bash beignet make upload posts.attachment --ui ``` The UI option adds `features/posts/client/attachment-upload.ts`, `features/posts/components/attachment-uploader.tsx`, and `features/posts/tests/attachment-uploader.test.tsx`. The uploader derives its file input hints from the same feature-root manifest used by the server definition, and includes progress, cancellation, error, reset, and completion states. API-only apps support the backend command but reject `--ui` before writing files. ## Define an upload Create the app-bound `defineUpload` builder once in `lib/uploads.ts` with `createUploads()` (see [app-bound builders](/workflows#app-bound-builders)), then put feature-owned upload definitions under `features//uploads/`: ```typescript // features/posts/uploads/attachment.ts import { z } from "zod"; import { defineUpload } from "@/lib/uploads"; const Metadata = z.object({ postSlug: z.string().min(1), }); export const PostAttachmentUpload = defineUpload("posts.attachment", { metadata: Metadata, file: { contentTypes: ["application/pdf", "text/plain"], maxSizeBytes: 5 * 1024 * 1024, maxFiles: 3, visibility: "private", cacheControl: "private, max-age=0", }, authorize({ ctx }) { return ctx.actor.type === "user"; }, key({ ctx, metadata, uploadId, file }) { const tenantId = ctx.tenant?.id ?? "default"; const actorId = ctx.actor.type === "user" ? ctx.actor.id : "anonymous"; const extension = file.name.split(".").pop(); return `posts/${tenantId}/${actorId}/${metadata.postSlug}/${uploadId}.${extension}`; }, storageMetadata({ ctx, metadata }) { return { tenantId: ctx.tenant?.id ?? "default", postSlug: metadata.postSlug, }; }, async onComplete({ ctx, metadata, files }) { const attachments = await Promise.all( files.map((file) => ctx.ports.postAttachments.upsertByUploadId({ id: file.uploadId, tenantId: ctx.tenant?.id ?? "default", postSlug: metadata.postSlug, key: file.key, fileName: file.name, contentType: file.contentType, size: file.object.size, }), ), ); await ctx.ports.audit.record({ action: "posts.attachment.upload", actor: ctx.actor, tenant: ctx.tenant, requestId: ctx.requestId, resource: { type: "post", id: metadata.postSlug }, metadata: { attachmentCount: attachments.length }, }); return { attachmentIds: attachments.map((item) => item.id) }; }, }); ``` The completion hook is where app-owned database records, audit entries, domain events, jobs, notifications, and scanning state belong. Beignet does not create a framework upload table. Direct-upload completion is intentionally stateless: Beignet does not retain an issuance record or single-use marker between `prepare` and `complete`. Authorize every request, include the relevant actor, tenant, or resource owner in `key(...)`, and make `onComplete(...)` idempotent by upload ID or object key. Back the app record with a unique constraint or upsert so replaying `complete` does not duplicate records or side effects. Apps that require single-use issuance or revocation should store that state in an app-owned table. Uploads are protected by default. A definition must either provide `authorize(...)` or explicitly opt into public preparation with `access: "public"`. Use public access only for workflows where anonymous callers are allowed to write the configured object keys. Collect feature uploads in a registry: ```typescript // features/posts/uploads/index.ts import { defineUploads } from "@beignet/core/uploads"; import { PostAttachmentUpload } from "./attachment"; export const postUploads = defineUploads({ postAttachment: PostAttachmentUpload, }); ``` ### Names vs registry keys Upload routes and clients resolve the `defineUpload(...)` name, such as `"posts.attachment"` — not the `defineUploads({...})` registry key, such as `postAttachment`. Registry keys only organize the registry object. Requesting an unknown name returns `UPLOAD_NOT_FOUND` with the registered names listed, and `createUploadRouter(...)` throws at construction when two definitions share the same name. ## Expose the route Use a focused Next.js route for uploads: ```typescript // app/api/uploads/[uploadName]/[action]/route.ts import { createUploadRouter, uploadsFromRegistry } from "@beignet/core/uploads"; import { createUploadRoute } from "@beignet/next"; import { postUploads } from "@/features/posts/uploads"; import { getServer } from "@/server"; export const { POST } = createUploadRoute(async () => { const server = await getServer(); return createUploadRouter({ uploads: uploadsFromRegistry(postUploads), ctx: () => server.createContextFromNext(), storage: server.ports.storage, limits: { jsonMaxBytes: 256 * 1024, multipartMaxBytes: 25 * 1024 * 1024, }, instrumentation: server.ports.devtools, }); }); ``` The `action` segment is one of: | Action | Purpose | | --- | --- | | `prepare` | Validate metadata and file intent, authorize the upload, compute keys, and return direct-upload instructions when a signer is configured. | | `upload` | Accept a server-handled multipart upload and write files through `StoragePort`. | | `complete` | Verify direct-uploaded objects exist and match the prepared file before running `verifyFile` and `onComplete`. | ## Verification Upload definitions always validate file count, content type, size, and authorization before writing app records. For supported binary types, Beignet also checks the declared content type against the file signature instead of trusting only browser metadata. Signature verification currently covers PDF, ZIP, GIF, JPEG, PNG, SVG, and WebP. Set `contentTypeVerification: false` only when a workflow intentionally accepts one of those media types without matching bytes. ```typescript export const PostImageUpload = defineUpload("posts.image", { metadata: Metadata, file: { contentTypes: ["image/png", "image/jpeg", "image/webp"], maxSizeBytes: 5 * 1024 * 1024, contentTypeVerification: "signature", }, authorize({ ctx }) { return ctx.actor.type === "user"; }, key({ ctx, metadata, uploadId }) { const actorId = ctx.actor.type === "user" ? ctx.actor.id : "anonymous"; return `posts/${actorId}/${metadata.postSlug}/images/${uploadId}`; }, }); ``` Direct uploads can require a browser-computed SHA-256 checksum. When a signer is configured and `checksum.required` is not `false`, `prepare` and `complete` require the checksum, and completion reads the stored object bytes before running `onComplete`. ```typescript export const PostAttachmentUpload = defineUpload("posts.attachment", { metadata: Metadata, file: { contentTypes: ["application/pdf", "text/plain"], maxSizeBytes: 5 * 1024 * 1024, checksum: { algorithm: "sha256" }, }, authorize({ ctx }) { return ctx.actor.type === "user"; }, key({ ctx, metadata, uploadId }) { const actorId = ctx.actor.type === "user" ? ctx.actor.id : "anonymous"; return `posts/${actorId}/${metadata.postSlug}/attachments/${uploadId}`; }, }); ``` Checksum-enabled browser clients need a client-safe manifest so Beignet can know which uploads require a digest before `prepare`: ```typescript // client/upload-manifest.ts import type { UploadManifestEntry } from "@beignet/core/uploads"; export const uploadManifest = [ { name: "posts.attachment", file: { contentTypes: ["application/pdf", "text/plain"], maxSizeBytes: 5 * 1024 * 1024, checksum: { algorithm: "sha256" }, }, }, ] satisfies UploadManifestEntry[]; ``` ```typescript // client/index.ts import { createUploadClient } from "@beignet/core/uploads/client"; import type { postUploads } from "@/features/posts/uploads"; import { uploadManifest } from "./upload-manifest"; type AppUploads = typeof postUploads; export const uploads = createUploadClient({ baseUrl: "/api/uploads", manifest: uploadManifest, }); ``` Use `verifyFile(...)` for app-owned scanning, moderation, or quarantine decisions that need the object to exist in storage before records are created. The hook runs after Beignet's built-in object verification and before `onComplete`. ```typescript export const PostAttachmentUpload = defineUpload("posts.attachment", { metadata: Metadata, file: { contentTypes: ["application/pdf"], maxSizeBytes: 5 * 1024 * 1024, }, authorize({ ctx }) { return ctx.actor.type === "user"; }, key({ ctx, metadata, uploadId }) { const actorId = ctx.actor.type === "user" ? ctx.actor.id : "anonymous"; return `posts/${actorId}/${metadata.postSlug}/attachments/${uploadId}`; }, async verifyFile({ ctx, file }) { const scan = await ctx.ports.fileScanner.scanObject(file.key); return scan.clean ? true : { valid: false, reason: "Upload did not pass scanning.", details: { scanner: scan.provider, finding: scan.finding }, }; }, async onComplete({ ctx, files }) { await ctx.ports.postAttachments.upsertByUploadId({ id: files[0]!.uploadId, key: files[0]!.key, }); }, }); ``` ## Use the upload client Create a browser client typed by the upload registry. Import the registry as a type so client code does not bundle server-only upload hooks: ```typescript // client/index.ts import { createUploadClient } from "@beignet/core/uploads/client"; import type { postUploads } from "@/features/posts/uploads"; type AppUploads = typeof postUploads; export const uploads = createUploadClient({ baseUrl: "/api/uploads", }); ``` Upload by route name: ```typescript const result = await uploads.upload("posts.attachment", { metadata: { postSlug: "hello-world" }, files: [file], strategy: "auto", onProgress({ progress }) { console.log(Math.round(progress * 100)); }, }); ``` `upload(...)` uses direct upload instructions when the route returns them and falls back to server-handled multipart upload otherwise. Use `direct(...)` when direct upload is required, or `server(...)` when a form should always stream through the app server. Progress reporting depends on the transport. Direct uploads report real per-file progress from the browser's `XMLHttpRequest` upload events. Server-handled uploads stream the whole multipart request through the app server and only report request completion, so `onProgress` fires once with `progress: 1` when the request finishes. ## React components React apps can wrap the typed upload client with `@beignet/react-uploads` to track status, progress, errors, aborts, and completion results: ```typescript // client/uploads.ts import { createUploadClient } from "@beignet/core/uploads/client"; import { createReactUploads } from "@beignet/react-uploads"; import type { postUploads } from "@/features/posts/uploads"; type AppUploads = typeof postUploads; export const uploads = createUploadClient({ baseUrl: "/api/uploads", }); export const reactUploads = createReactUploads({ uploads, }); ``` ```tsx const attachment = reactUploads.useUpload("posts.attachment"); attachment.upload({ metadata: { postSlug: "hello-world" }, files, }); ``` `upload(...)` is fire-and-forget and never rejects; failures land in hook state and `onError`. Use `uploadAsync(...)` when the caller needs the completion result or a rejecting promise. See [React uploads](/react-uploads) for hook state and callback details. ## Direct uploads Direct uploads use an `UploadSignerPort`. The S3-compatible provider includes a signer for AWS S3, Cloudflare R2, MinIO, Spaces, and similar services: ```typescript import { createUploadRouter, uploadsFromRegistry } from "@beignet/core/uploads"; import { createUploadRoute } from "@beignet/next"; import { createS3UploadSigner } from "@beignet/provider-storage-s3"; import { postUploads } from "@/features/posts/uploads"; import { getServer } from "@/server"; export const { POST } = createUploadRoute(async () => { const server = await getServer(); return createUploadRouter({ uploads: uploadsFromRegistry(postUploads), ctx: () => server.createContextFromNext(), storage: server.ports.storage, signer: createS3UploadSigner({ bucket: env.STORAGE_S3_BUCKET, region: env.STORAGE_S3_REGION, endpoint: env.STORAGE_S3_ENDPOINT, credentials: { accessKeyId: env.STORAGE_S3_ACCESS_KEY_ID, secretAccessKey: env.STORAGE_S3_SECRET_ACCESS_KEY, }, keyPrefix: env.STORAGE_S3_KEY_PREFIX, }), }); }); ``` The upload client handles the direct flow for browser code: it calls `prepare`, PUTs each file to the returned provider URL with the returned headers, then calls `complete` with the prepared file metadata. Completion re-checks the object key, content type, declared size, `maxSizeBytes`, supported content signatures, and configured checksums before running `verifyFile` and `onComplete`. The upload router limits `prepare` and `complete` JSON bodies to 256 KiB by default. Override `limits.jsonMaxBytes` if metadata is larger. ## Server uploads Server uploads use `multipart/form-data` and are useful for small forms, local development, and tests: ```typescript await uploads.server("posts.attachment", { metadata: { postSlug: "hello-world" }, files: [file], }); ``` The router parses metadata and validates file count, content type, and size, then authorizes each file before reading its bytes for supported content signatures or configured checksums. Accepted files are written through `ctx.ports.storage`. Server-handled multipart uploads reject declared `Content-Length` values over `limits.multipartMaxBytes`, then enforce the same limit while reading the actual request stream before calling `formData()`. The limit therefore applies to chunked requests and requests without a length header. The default is 25 MiB. If key derivation, storage, or verification fails before app-owned completion begins, the router deletes every object already written by that request before returning the original error. ## Error codes On the server, upload failures throw condition-specific subclasses of `UploadError` — `UploadNotFoundError`, `InvalidUploadActionError`, `InvalidUploadMetadataError`, `InvalidUploadFileError`, `UnauthorizedUploadError`, `UploadObjectNotFoundError`, `UploadBodyTooLargeError`, and `InvalidUploadBodyError` — each carrying its `code` literal and HTTP `status`. Catch the base `UploadError` for blanket handling or a subclass for one condition; `UploadError` itself is not constructed directly. Upload routes use the standard flat Beignet error body with an optional `details` value: ```json { "code": "INVALID_UPLOAD_METADATA", "message": "Invalid metadata for upload \"posts.attachment\".", "details": { "issues": [] } } ``` | Code | Status | Meaning | | --- | --- | --- | | `UPLOAD_NOT_FOUND` | 404 | No upload is registered under the requested `defineUpload(...)` name. The message lists the registered names. | | `INVALID_UPLOAD_ACTION` | 400 | The route action segment is not `prepare`, `upload`, or `complete`. | | `INVALID_UPLOAD_BODY` | 400 | The request body is not valid JSON, is missing a `files` array, contains non-object file entries, omits `uploadId` or `key` on completed files, or a multipart upload has no `file` field. Shape problems include `details.issues`. | | `INVALID_UPLOAD_METADATA` | 422 | Metadata failed the upload's schema. `details.issues` carries the schema issues. | | `INVALID_UPLOAD_FILE` | 413, 415, or 422 | File count, size (413), content type/signature (415), checksum, scanner, or completed-object verification failed (422). | | `UNAUTHORIZED_UPLOAD` | 403 | The upload omitted authorization, or its `authorize` hook denied the request. | | `UPLOAD_OBJECT_NOT_FOUND` | 404 | Completion could not find the direct-uploaded object in storage. | | `UPLOAD_BODY_TOO_LARGE` | 413 | The upload route body exceeded the configured JSON or multipart body limit. | The typed upload client and `@beignet/react-uploads` surface these as `UploadClientError` values with the same `code`, `status`, and `details`. ## Testing Use memory storage and the memory signer in tests: ```typescript import { createMemoryUploadSigner, createUploadRouter, uploadsFromRegistry, } from "@beignet/core/uploads"; import { createMemoryStorage } from "@beignet/core/ports"; import { postUploads } from "@/features/posts/uploads"; const router = createUploadRouter({ uploads: uploadsFromRegistry(postUploads), ctx, storage: createMemoryStorage(), signer: createMemoryUploadSigner(), id: () => "upload_1", }); const prepared = await router.prepare("posts.attachment", { metadata: { postSlug: "hello-world" }, files: [{ name: "note.txt", contentType: "text/plain", size: 5 }], }); ``` Use app-owned fake repositories to assert attachment rows, audit entries, and events created by `onComplete`. ## Scanning and quarantine Virus scanning, malware detection, moderation, and quarantine are app or provider concerns. Use `verifyFile(...)` to block completion synchronously when that is appropriate. Before `onComplete(...)` begins, a failed server-handled multipart batch deletes all objects stored earlier by that request; cleanup failures appear as `upload.server.cleanup.failed` instrumentation without masking the original error. Once `onComplete(...)` begins, the app may have persisted durable references, so Beignet leaves the objects app-owned and the app owns transaction or compensation for a later completion failure. Direct-upload completion does not delete pre-existing objects, so lifecycle expiration remains necessary for abandoned or rejected direct uploads. For asynchronous scanners, create an attachment row with a pending or quarantined status in `onComplete`, dispatch a job, and publish the object only after the app-owned scanner marks it clean. --- # Payments and billing Source: https://www.beignetjs.com/payments Beignet treats payments as a provider-backed port and billing as app-owned product logic. Application workflows call `ctx.ports.payments`; providers adapt Stripe or another payment service. Your `features/billing` feature owns plans, prices, subscription records, customer state, and the app mapping from that state to product access. `@beignet/core/entitlements` owns the common decision shape and port for checking that access from use cases. The port is intentionally small: hosted checkout, billing portal sessions, refunds, and verified webhook events. It does not try to model tax, metering, ledgers, invoices, or every provider-specific payment API. ## App-facing port ```bash bun add @beignet/core @beignet/next @beignet/provider-payments-stripe stripe ``` For a standard Drizzle-backed Beignet app, scaffold the billing slice: ```bash bun beignet make payments bun beignet db generate bun beignet db migrate bun beignet db status ``` `make payments` creates `features/billing`, a `billing_accounts` Drizzle schema and repository, `app/api/webhooks/payments/route.ts`, a typed pricing module with a free/pro plan model and entitlement keys, a billing-backed entitlements port, a demo billing seed, memory payments provider wiring for local development, and `BILLING_PRO_PRICE_ID` env validation. On apps with the frontend shell it also emits a plan settings page at `/settings/plan` with typed React Query hooks and adds it to the settings navigation. The free plan is a fully working tier: accounts without an active subscription resolve to it instead of being locked out, and `FREE_PLAN_LIMITS` in `features/billing/pricing.ts` names the quotas to enforce once usage grows. The generated app stays provider neutral; swap to the Stripe provider when you are ready to use live Stripe credentials. Add the port to your app: ```typescript // ports/index.ts import type { PaymentsPort } from "@beignet/core/payments"; import type { EntitlementsPort } from "@beignet/core/entitlements"; export type AppPorts = { entitlements: EntitlementsPort; payments: PaymentsPort; // app-owned billing repositories and other ports... }; ``` When providers contribute the ports at startup, list `entitlements` and `payments` as deferred keys in `infra/port-wiring.ts`: ```typescript export const initialPorts = definePorts()({ bound: { gate, }, deferred: ["entitlements", "payments", "logger", "uow"], }); ``` ## Stripe provider Wire the Stripe provider in `server/providers.ts`: ```typescript import { createStripePaymentsProvider } from "@beignet/provider-payments-stripe"; export const providers = [ // other providers... createStripePaymentsProvider(), ] as const; ``` `createStripePaymentsProvider(options)` supplies the Stripe keys in code; options override env-derived values. Set the provider env vars: ```env STRIPE_SECRET_KEY=sk_live_... STRIPE_WEBHOOK_SECRET=whsec_... STRIPE_PUBLISHABLE_KEY=pk_live_... ``` The provider installs `ctx.ports.payments` and exposes `ctx.ports.stripe.client` as an [escape hatch](/providers#escape-hatches) for Stripe-specific operations the stable port does not model. ## Cut over to Stripe The generated billing slice starts with memory payments so local development and tests do not need Stripe credentials. Move to Stripe deliberately: 1. Scaffold billing and create the database migration: ```bash bun beignet make payments bun beignet db generate bun beignet db migrate bun beignet db status ``` 2. Create the product and price in Stripe, then set `BILLING_PRO_PRICE_ID` to the Stripe Price ID for the paid plan your app exposes. The free plan has no price and needs no Stripe configuration. 3. Set the Stripe provider env vars for the environment you are deploying: ```env STRIPE_SECRET_KEY=sk_live_... STRIPE_WEBHOOK_SECRET=whsec_... STRIPE_PUBLISHABLE_KEY=pk_live_... ``` Keep test-mode and live-mode keys separate. The webhook secret is specific to the endpoint or local Stripe CLI session that produced it. 4. Replace the generated memory provider in `server/providers.ts`: ```typescript // other providers... createStripePaymentsProvider(), ] as const; ``` 5. Deploy the billing migration before accepting live webhooks. The webhook handler should never be the first code path that discovers the `billing_accounts` table is missing. 6. Configure a Stripe webhook endpoint that forwards to `https:///api/webhooks/payments` and subscribe to these events: - `checkout.session.completed` - `customer.subscription.created` - `customer.subscription.updated` - `customer.subscription.deleted` - `invoice.payment_succeeded` - `invoice.payment_failed` 7. Run `bun beignet doctor --strict`, create a checkout session, complete it in Stripe test mode, and verify that your app-owned billing state changes only after verified webhook events are handled. ## Create checkout from a use case Keep checkout creation behind an application use case. The use case decides which tenant, plan, and price are allowed; the payments port only creates the external session. ```typescript // features/billing/pricing.ts import type { PaymentCheckoutLineItem, PaymentCheckoutMode, } from "@beignet/core/payments"; import { env } from "@/lib/env"; import type { BillingPlan } from "./schemas"; export type BillingPlanDefinition = { id: BillingPlan; label: string; mode: PaymentCheckoutMode; lineItems: readonly PaymentCheckoutLineItem[]; entitlements: readonly string[]; }; export const billingPlans = { free: { id: "free", label: "Free", mode: "subscription", lineItems: [], entitlements: [], }, pro: { id: "pro", label: "Pro", mode: "subscription", lineItems: [{ priceId: env.BILLING_PRO_PRICE_ID, quantity: 1 }], entitlements: ["todos.create"], }, } as const satisfies Record; /** * Free-plan quotas. Call `requireEntitlement` only once usage reaches these * limits, so the free plan stays a fully working tier. */ export const FREE_PLAN_LIMITS = { todos: 100, } as const; export function getBillingPlan(plan: BillingPlan): BillingPlanDefinition { return billingPlans[plan]; } ``` Then use that plan definition from checkout: ```typescript // features/billing/use-cases/create-checkout-session.ts import { requireTenant } from "@beignet/core/ports"; import { z } from "zod"; import { env } from "@/lib/env"; import { useCase } from "@/lib/use-case"; import { getBillingPlan } from "../pricing"; export const createCheckoutSessionUseCase = useCase .command("billing.createCheckoutSession") .input( z.object({ plan: z.enum(["pro"]), }), ) .run(async ({ ctx, input }) => { const tenant = requireTenant(ctx); const plan = getBillingPlan(input.plan); return ctx.ports.payments.createCheckoutSession({ mode: plan.mode, lineItems: plan.lineItems, successUrl: `${env.APP_URL}/billing/success`, cancelUrl: `${env.APP_URL}/billing`, clientReferenceId: tenant.id, metadata: { tenantId: tenant.id, plan: input.plan, }, }); }); ``` Expose that use case through a normal Beignet contract and route. Add idempotency metadata to the contract so browser retries reuse the same logical checkout request. Do not use a tenant-wide provider idempotency key for checkout creation. A future legitimate checkout attempt for the same tenant and plan should create a new provider session instead of replaying an old one. If your app has a per-request idempotency key available at the use-case boundary, passing that to the payments port is reasonable. Treat the returned checkout URL as workflow intent, not proof of payment. A success redirect is a user experience signal only; fulfillment should happen from verified provider webhooks. ## Enforce product access with entitlements Billing records are the source of subscription state. Entitlements are the server-side product access decisions derived from that state. Keep the mapping app-owned, then enforce it from use cases: ```typescript // features/todos/use-cases/create-todo.ts import { requireEntitlement } from "@beignet/core/entitlements"; import { requireTenantScope, tenantScopeId, } from "@beignet/core/tenancy"; import { useCase } from "@/lib/use-case"; import { createTodoSchema, todoSchema } from "../schemas"; export const createTodoUseCase = useCase .command("todos.create") .input(createTodoSchema) .output(todoSchema) .run(async ({ ctx, input }) => { const scope = requireTenantScope(ctx); const tenantId = tenantScopeId(scope); await requireEntitlement(ctx, { entitlement: "todos.create", subject: { type: "tenant", id: tenantId }, }); return ctx.ports.todos.create(input, scope); }); ``` Policies and entitlements answer different questions. Policies decide whether the current actor may perform an action on a resource. Entitlements decide whether the tenant or account has product access to that capability. Paid workflows commonly use both checks in the same use case. ### Observe entitlement decisions Use `onDecision` when you want paid-feature decisions to appear in devtools or logs. The observer is best-effort: it cannot change the allow/deny result, and observer errors are ignored. ```typescript // features/billing/entitlements.ts import { createEntitlements, type EntitlementDecisionObserver, } from "@beignet/core/entitlements"; import { createTenant } from "@beignet/core/ports"; import { createTenantScope } from "@beignet/core/tenancy"; export function createBillingEntitlements( billing: BillingRepository, options: { onDecision?: EntitlementDecisionObserver } = {}, ) { return createEntitlements({ async inspect(input) { if (input.subject.type !== "tenant") return false; const account = await billing.findByTenantScope( createTenantScope(createTenant(input.subject.id)), ); return account?.status === "active"; }, onDecision: options.onDecision, }); } ``` In provider wiring, pass the current instrumentation or devtools port to the observer closure so the billing feature stays independent of runtime infrastructure. The built-in `entitlements` devtools watcher can then be enabled, disabled, searched, and filtered like `payments` and `policies`. ## Billing persistence and migrations Billing state should be app-owned and durable. Store provider customer IDs, subscription IDs, the current app subscription status, period end, cancellation intent, checkout session ID, and last processed webhook event ID in a repository under `features/billing/ports.ts`. For Drizzle apps, keep the billing table in `infra/db/schema/billing.ts` and columns, run: ```bash bun beignet db generate bun beignet db migrate bun beignet db status ``` Local development can bootstrap the billing table directly when that keeps a demo repeatable. Production apps should prefer checked-in Drizzle migrations generated from the schema. ## Verify webhooks Webhook routes are focused boundary adapters because provider signatures need the raw request body. In Next.js, use `createPaymentWebhookRoute(...)` so the raw-body read and signature verification stay consistent: ```typescript // app/api/webhooks/payments/route.ts import { createPaymentWebhookRoute } from "@beignet/next"; import { handlePaymentWebhookUseCase } from "@/features/billing/use-cases"; import { getServer } from "@/server"; export const runtime = "nodejs"; export const { POST } = createPaymentWebhookRoute({ server: getServer, handle: async ({ ctx, event }) => { await handlePaymentWebhookUseCase.run({ ctx, input: event }); return { status: 200, body: { received: true }, }; }, }); ``` This is the canonical route for Beignet billing flows. It verifies through `ctx.ports.payments.verifyWebhook(...)` and passes your handler a normalized `PaymentWebhookEvent`. You do not need a `defineWebhook(...)` catalog for the generated billing slice. Use `@beignet/webhooks-stripe` with `createWebhookRoute(...)` only when Stripe is a generic inbound event source outside the payments port, or when your app deliberately wants to model Stripe events through a typed generic webhook catalog. Do not parse the body as JSON before verification, and do not put Stripe SDK calls in feature use cases. The provider owns signature verification; the feature owns what the verified event means. Webhooks should not go through the normal JSON contract route path: the route must read the raw request body once and verify the signature before running app-owned fulfillment. ## Webhook operations For local Stripe testing, forward events to the same route your deployed app uses: ```bash stripe listen --forward-to http://localhost:3000/api/webhooks/payments ``` Set `STRIPE_WEBHOOK_SECRET` to the `whsec_...` value printed by that command for the local app process. That secret is not interchangeable with a Dashboard endpoint secret or a live-mode endpoint secret. Provider webhooks are at-least-once delivery. The billing use case should key idempotency on the provider and event ID, so replaying an event from Stripe is safe. Events can also arrive out of order; recover tenant state from `client_reference_id`, subscription metadata, or the existing billing account instead of assuming checkout, subscription, and invoice events arrive in one sequence. Treat valid but unhandled payment event types as no-ops and acknowledge them. Stripe endpoint subscriptions are operational configuration, so a newly subscribed event type should not become an infinite retry loop while the app is being updated. Billing-critical event types should still be handled idempotently and tested explicitly. For subscription products, treat `checkout.session.completed` as the event that connects a tenant to provider IDs. Durable access should come from the subscription and invoice state you store after handling `customer.subscription.*`, `invoice.payment_succeeded`, and `invoice.payment_failed`. If webhooks fail: - Confirm the route uses `createPaymentWebhookRoute(...)` and keeps `export const runtime = "nodejs";`. - Confirm the `STRIPE_WEBHOOK_SECRET` belongs to the exact endpoint and mode that sent the event. - Confirm no middleware or route code parsed the request body before signature verification. - Inspect `payments.webhook.failed` provider events in devtools or your provider logs. - Replay the event from the Stripe Dashboard or Stripe CLI after fixing the configuration; the idempotency key should make duplicate delivery safe. ## Handle payment events Provider webhooks are delivered at least once. Key idempotency on the provider event ID, then update app-owned billing state in a Unit of Work: ```typescript // features/billing/use-cases/index.ts import { createIdempotencyFingerprint, runIdempotently, } from "@beignet/core/idempotency"; import type { PaymentWebhookEvent } from "@beignet/core/payments"; import { createTenant } from "@beignet/core/ports"; import { createTenantScope } from "@beignet/core/tenancy"; import { z } from "zod"; import { useCase } from "@/lib/use-case"; const PaymentWebhookEventInput = z.custom(); export const handlePaymentWebhookUseCase = useCase .command("billing.handlePaymentWebhook") .input(PaymentWebhookEventInput) .run(async ({ ctx, input }) => { const fingerprint = await createIdempotencyFingerprint({ type: input.type, data: input.data, }); return runIdempotently(ctx.ports.idempotency, { namespace: "webhooks.payments", key: input.id, scope: { provider: input.provider }, fingerprint, ttlSec: 60 * 60 * 24 * 30, run: () => ctx.ports.uow.transaction(async (tx) => { const data = input.data as { id?: string; client_reference_id?: string; customer?: string | { id?: string }; subscription?: string | { id?: string }; status?: string; metadata?: Record; }; const customerId = typeof data.customer === "string" ? data.customer : data.customer?.id; const subscriptionId = typeof data.subscription === "string" ? data.subscription : data.subscription?.id; if (input.type === "checkout.session.completed") { const tenantId = data.client_reference_id ?? data.metadata?.tenantId; if (tenantId) { const existing = await tx.billing.findByTenantScope( createTenantScope(createTenant(tenantId)), ); await tx.billing.save({ tenantId, provider: input.provider, plan: data.metadata?.plan ?? existing?.plan ?? "free", status: existing?.status ?? "inactive", customerId, subscriptionId, checkoutSessionId: data.id, lastEventId: input.id, updatedAt: new Date().toISOString(), currentPeriodEnd: existing?.currentPeriodEnd, }); } } if (input.type.startsWith("customer.subscription.") && customerId) { const existing = await tx.billing.findByCustomerId( customerId, input.provider, ); const tenantId = data.metadata?.tenantId ?? existing?.tenantId; if (tenantId) { await tx.billing.save({ tenantId, provider: input.provider, plan: data.metadata?.plan ?? existing?.plan ?? "free", status: input.type === "customer.subscription.deleted" ? "canceled" : data.status === "past_due" ? "past_due" : "active", customerId, subscriptionId: data.id ?? existing?.subscriptionId, checkoutSessionId: existing?.checkoutSessionId, lastEventId: input.id, updatedAt: new Date().toISOString(), currentPeriodEnd: existing?.currentPeriodEnd, }); } } return { received: true }; }), }); }); ``` Handle at least checkout completion, subscription create/update/delete, invoice payment success, and invoice payment failure. Use checkout completion to capture provider IDs and tenant association. Use subscription and invoice events to decide durable access, past-due behavior, and cancellation state. Keep canceled accounts from being reactivated by later invoice success events, and prefer subscription metadata or `client_reference_id` for recovering tenant state when events arrive out of order. Record domain events inside the transaction when other workflows need to react, then deliver mail, notifications, analytics syncs, or entitlement fan-out through [outbox](/outbox) and [jobs](/jobs). In a production app, keep the same shape under `features/billing`: checkout and portal use cases, a persistent billing account repository, a `createPaymentWebhookRoute(...)` adapter, and idempotent webhook fulfillment. ## Refunds and portal sessions Use the same port for customer self-service and refunds: ```typescript const portal = await ctx.ports.payments.createBillingPortalSession({ customerId: billingAccount.providerCustomerId, returnUrl: `${env.APP_URL}/settings/billing`, }); await ctx.ports.payments.createRefund({ paymentId: payment.providerPaymentId, amount: 500, reason: "requested_by_customer", idempotencyKey: `refund:${payment.id}:support-credit`, }); ``` Keep the app's billing repository as the source of subscription state and the entitlements port as the source of product access decisions. Provider IDs are external references, not your entitlement model. ## Testing Use the memory adapter in tests: ```typescript import { createMemoryPayments } from "@beignet/core/payments"; const payments = createMemoryPayments({ id: (prefix) => `${prefix}_test`, }); const checkout = await payments.createCheckoutSession({ mode: "subscription", lineItems: [{ priceId: "price_pro" }], successUrl: "https://app.example.test/success", cancelUrl: "https://app.example.test/cancel", }); expect(checkout.provider).toBe("memory"); expect(payments.checkoutSessions).toHaveLength(1); ``` `createTestPorts(...)` from `@beignet/core/testing` also includes a memory payments port. ## Read next - [Idempotency](/idempotency) for retry-safe checkout and webhook handling. - [Outbox](/outbox) for durable post-commit billing side effects. - [Providers](/providers) for provider wiring and escape hatches. - [Devtools](/devtools) for provider instrumentation watchers. --- # Webhooks Source: https://www.beignetjs.com/webhooks Use `@beignet/core/webhooks` when an app needs to receive external HTTP events without scattering raw-body parsing, signature checks, event validation, and handler routing through route files. Inbound webhooks are not ordinary JSON API routes. Signature verification usually depends on the exact raw request body, so the adapter must read the body before it is parsed. ## Choose a route helper Use `createPaymentWebhookRoute(...)` from `@beignet/next` for app billing flows backed by `ctx.ports.payments`. This is the route generated by `beignet make payments`; it verifies through `ctx.ports.payments.verifyWebhook(...)` and passes your handler a normalized `PaymentWebhookEvent` from `@beignet/core/payments`. Use `createWebhookRoute(...)` for generic inbound webhooks backed by a typed `defineWebhook(...)` event catalog. Provider verifier packages such as `@beignet/webhooks-github` and `@beignet/webhooks-stripe` adapt vendor signature rules to this generic webhook primitive. Use the Stripe webhook verifier for non-payment Stripe events, or for apps that deliberately model Stripe through a generic webhook catalog instead of the payments port. ## Define a webhook Define webhook event catalogs near the feature that owns the workflow: ```typescript // features/integrations/webhooks.ts import { defineWebhook } from "@beignet/core/webhooks"; import { z } from "zod"; type StripeWebhookPayload = { id: string; object: string; }; const stripeWebhookPayloadSchema = z.custom( (value): value is StripeWebhookPayload => typeof value === "object" && value !== null && typeof (value as { id?: unknown }).id === "string" && typeof (value as { object?: unknown }).object === "string", ); export const stripeWebhook = defineWebhook("integrations.stripe", { provider: "stripe", events: { "customer.created": stripeWebhookPayloadSchema, "charge.dispute.created": stripeWebhookPayloadSchema, }, }); ``` The event catalog validates the payload passed to your handler. The verifier owns authenticity and converts provider-specific requests into Beignet webhook events. Install vendor verifier packages when a provider has signature details that should not be reimplemented in app code: ```bash bun add @beignet/webhooks-stripe stripe @beignet/core bun add @beignet/webhooks-github @beignet/core ``` ## Expose a Next.js route Use `createWebhookRoute(...)` from `@beignet/next` for raw-body route handling: ```typescript // app/api/webhooks/stripe/route.ts import { createWebhookRoute } from "@beignet/next"; import { createStripeWebhookVerifier } from "@beignet/webhooks-stripe"; import { stripeWebhook } from "@/features/integrations/webhooks"; import { handleStripeWebhookUseCase } from "@/features/integrations/use-cases"; import { env } from "@/lib/env"; import { getServer } from "@/server"; export const runtime = "nodejs"; const stripeWebhookVerifier = createStripeWebhookVerifier({ secret: () => env.STRIPE_WEBHOOK_SECRET, }); export const { POST } = createWebhookRoute({ server: getServer, webhook: stripeWebhook, verify: ({ input }) => stripeWebhookVerifier.verify(input), handle: async ({ ctx, event }) => { await handleStripeWebhookUseCase.run({ ctx, input: event }); return { status: 200, body: { received: true } }; }, }); ``` By default, `createWebhookRoute(...)` rejects verified event types that are not in the catalog and returns a 400. Set `allowUnknownEvents: true` only for broad provider endpoints that intentionally acknowledge valid event types your app does not handle. ```typescript export const { POST } = createWebhookRoute({ server: getServer, webhook: stripeWebhook, verify: ({ input }) => stripeWebhookVerifier.verify(input), allowUnknownEvents: true, handle: async ({ event }) => { if (event.type !== "customer.created") { return { status: 200, body: { ignored: true } }; } return { status: 200, body: { received: true } }; }, }); ``` When the `server` option is a real Beignet server (anything exposing `rawRoute(...)` — a `NextServer` or core `ServerInstance`), the route runs inside the full hooks pipeline: rate limiting, CORS, logging, error reporting, and instrumentation all apply, and the request shows up in devtools like any contract route. Declare metadata-driven hook behavior with the `pipeline` option: ```typescript export const { POST } = createWebhookRoute({ server: getServer, webhook: githubWebhook, verify: ({ input }) => githubWebhookVerifier.verify(input), pipeline: { metadata: { rateLimit: { max: 600, windowSec: 60 } }, }, }); ``` Minimal test fakes that only implement `createRequestContext` keep the direct flow, so route modules stay executable under the app's test runner without a booted server. The same applies to `createPaymentWebhookRoute`, `createScheduleRoute`, and `createOutboxDrainRoute`. For endpoints these factories do not cover — third-party callback routes such as collaboration room auth — build the handler with `server.rawRoute(...)` directly; see [Server](/server#raw-routes). Use the context-aware `verify` option when verification depends on app ports. Otherwise, create a reusable verifier in the route or server layer and pass it through `verify`. ## Where verifiers live Feature webhook definitions stay provider-free. A `defineWebhook(...)` catalog in `features//` is contract-reachable code, and contract-reachable code cannot import `@beignet/provider-*` packages — `beignet lint` enforces this dependency direction. Attach provider verifiers such as `createStripeWebhookVerifier(...)` and `createGitHubWebhookVerifier(...)` at the route boundary through the `verify` option of `createWebhookRoute(...)`, as the examples on this page do. The inline `verifier:` option on `defineWebhook(...)` is reserved for the core verifiers — `createHmacWebhookVerifier(...)` and `createMemoryWebhookVerifier(...)` from `@beignet/core/webhooks` — and for tests. ## GitHub webhooks GitHub event names and delivery IDs live in headers, so use `@beignet/webhooks-github` instead of the generic HMAC verifier: ```typescript // features/integrations/webhooks.ts import { defineWebhook } from "@beignet/core/webhooks"; import { z } from "zod"; export const githubWebhook = defineWebhook("integrations.github", { provider: "github", events: { issues: z.object({ action: z.string(), issue: z.object({ number: z.number() }), repository: z.object({ full_name: z.string() }), }), }, }); ``` ```typescript // app/api/webhooks/github/route.ts import { createWebhookRoute } from "@beignet/next"; import { createGitHubWebhookVerifier } from "@beignet/webhooks-github"; import { githubWebhook } from "@/features/integrations/webhooks"; import { handleGitHubWebhookUseCase } from "@/features/integrations/use-cases"; import { env } from "@/lib/env"; import { getServer } from "@/server"; export const runtime = "nodejs"; const githubWebhookVerifier = createGitHubWebhookVerifier({ secret: () => env.GITHUB_WEBHOOK_SECRET, }); export const { POST } = createWebhookRoute({ server: getServer, webhook: githubWebhook, verify: ({ input }) => githubWebhookVerifier.verify(input), handle: async ({ ctx, event }) => { await handleGitHubWebhookUseCase.run({ ctx, input: event }); return { status: 200, body: { received: true } }; }, }); ``` The verifier reads `X-Hub-Signature-256`, `X-GitHub-Delivery`, and `X-GitHub-Event`, then verifies the raw body before parsing JSON. ## Generic HMAC verification For simple JSON webhooks that use an HMAC signature over the raw body, use the generic verifier: ```typescript import { createHmacWebhookVerifier, defineWebhook, } from "@beignet/core/webhooks"; import { z } from "zod"; export const issueWebhook = defineWebhook("issues.provider", { provider: "provider", events: { "issue.created": z.object({ id: z.string(), type: z.literal("issue.created"), issueId: z.string(), title: z.string(), }), }, verifier: createHmacWebhookVerifier({ secret: process.env.PROVIDER_WEBHOOK_SECRET ?? "", signatureHeader: "x-provider-signature", signaturePrefix: "sha256=", timestamp: { header: "x-provider-timestamp", toleranceSec: 300, }, }), }); ``` `createHmacWebhookVerifier(...)` expects a JSON payload with string `id` and `type` fields by default. Use `eventIdPath` and `eventTypePath` when a provider uses different names. Configure `timestamp` when the provider signs a timestamp header or payload field. Header mode verifies the HMAC over the exact `.` bytes; payload mode verifies the raw body, which already contains the timestamp. Beignet rejects missing, malformed, stale, and too-far-future timestamps before the handler runs. ## Handling safely Webhook providers retry. Handlers should be idempotent: ```typescript await runIdempotently(ctx.ports.idempotency, { namespace: "billing.handlePaymentWebhook", key: event.id, scope: event.provider, fingerprint: event.type, run: () => applyBillingEvent(ctx, event), }); ``` For workflows that must survive process restarts, record a domain event or job through the outbox after verification and acknowledge the provider quickly. Keep long-running work out of the route handler. The route helpers use HTTP status codes as the provider retry contract: - Invalid signatures, malformed payloads, and strict unknown-event failures return 400. - Verified duplicate events should be acknowledged after idempotency confirms the original result. - Handler failures return 500 so at-least-once webhook providers can retry. - For broad provider endpoints configured with `allowUnknownEvents: true`, verified event types your app does not handle should usually be acknowledged and ignored. ## Testing Use the memory verifier for route and use-case tests: ```typescript import { createMemoryWebhookVerifier, defineWebhook, verifyWebhook, } from "@beignet/core/webhooks"; const verifier = createMemoryWebhookVerifier(); verifier.queue({ id: "evt_1", type: "issue.created", payload: { issueId: "issue_1" }, }); const webhook = defineWebhook("issues.provider", { verifier }); const event = await verifyWebhook(webhook, { rawBody: "{}", headers: {}, }); ``` ## Related pages - [Ports and adapters](/ports) for app-facing dependency boundaries. - [Payments](/payments) for Stripe-backed billing webhooks. - [Idempotency](/idempotency) for retry-safe webhook handlers. - [Outbox](/outbox) for durable post-verification work. --- # Feature flags Source: https://www.beignetjs.com/feature-flags Feature flags let production apps ship changes behind typed runtime decisions without coupling use cases to a vendor SDK. Beignet owns the flag definitions and `FlagsPort`; providers decide where evaluation happens. The API follows OpenFeature's core semantics — typed values, targeting context, defaults, evaluation details, and tracking — while keeping imports and tests in Beignet's port/provider model. ## Define flags Declare flags in the feature that owns the behavior: ```typescript // features/billing/flags.ts import { defineFlag, defineFlags } from "@beignet/core/flags"; export const billingFlags = defineFlags({ newCheckout: defineFlag.boolean("billing.new-checkout", { default: false, description: "Route checkout creation through the new billing flow.", }), pricingCopy: defineFlag.string("billing.pricing-copy", { default: "control", }), }); ``` String and number flags widen to `string` and `number` by default. Pass a generic when the app wants a closed set of variants, such as `defineFlag.string<"control" | "treatment">(...)`. ## Evaluate in application code Use cases evaluate flags through `ctx.ports.flags`. Provider failures return the flag default instead of throwing into product workflows; call `details(...)` when you need the reason, variant, metadata, or error summary. ```typescript const enabled = await ctx.ports.flags.evaluate(billingFlags.newCheckout, { context: { targetingKey: requireUserId(ctx), subject: { type: "user", id: requireUserId(ctx) }, tenant: ctx.tenant, attributes: { plan: account.plan }, privateAttributes: ["email"], requestId: ctx.requestId, traceId: ctx.traceId, }, }); ``` OpenFeature receives `attributes` unchanged unless Beignet has an authoritative value for the same reserved key. For example, `tenant.id` replaces an `attributes.tenantId` value, while `attributes.tenantId` remains available when the evaluation context has no tenant. Plain evaluation does not record exposure. Record exposure explicitly when the user actually sees or can be affected by the flagged behavior: ```typescript await ctx.ports.flags.recordExposure(billingFlags.newCheckout, { context: flagContext, value: enabled, }); ``` ## Setup with OpenFeature Use the OpenFeature provider to adapt LaunchDarkly, Statsig, Unleash, flagd, or any other OpenFeature-compatible provider behind Beignet's `FlagsPort`: ```bash bun add @beignet/provider-flags-openfeature @openfeature/server-sdk ``` ```typescript import { createNextServer, createNextServerLoader } from "@beignet/next"; import { createOpenFeatureFlagsProvider } from "@beignet/provider-flags-openfeature"; import { initialPorts } from "@/infra/port-wiring"; import { openFeatureProvider } from "@/infra/flags/openfeature"; export const getServer = createNextServerLoader(() => createNextServer({ ports: initialPorts, providers: [ createOpenFeatureFlagsProvider({ provider: openFeatureProvider, domain: "app", }), ], context: appContextBlueprint, }), ); ``` The provider contributes `ctx.ports.flags` and `ctx.ports.openFeature` as an escape hatch for advanced OpenFeature operations. When the app already owns an OpenFeature client, pass `client`; Beignet uses it without registering the optional `provider` on OpenFeature's process-global API. Shutdown leaves bindings alone by default. `restoreOnStop: true` restores only the effective provider previously bound to this domain, and only while the binding is still owned by this Beignet provider. It never clears providers registered by other application components. ## Testing Use `createMemoryFlags(...)` or `createStaticFlags(...)` for tests: ```typescript import { createMemoryFlags } from "@beignet/core/flags"; import { billingFlags } from "@/features/billing/flags"; const flags = createMemoryFlags(); flags.set(billingFlags.newCheckout, true); ``` `createTestPorts(...)` includes an in-memory `flags` port by default, so most use-case tests only need to override the flag values they exercise. ## Devtools When devtools are installed before a flags provider, evaluations, provider errors, explicit exposures, and tracking events appear under the Feature flags watcher. Private attributes are redacted from instrumentation details. --- # Mail Source: https://www.beignetjs.com/mail Beignet treats mail as a port. Application code calls `ctx.ports.mailer.send(...)`; providers decide whether delivery happens through Resend, SMTP, a memory adapter, or an app-owned service. This keeps email out of use-case internals and makes workflows easy to test. Use [notifications](/notifications) when the application intent is broader than "send one email," such as appointment reminders, message alerts, or delivery that may later fan out to SMS, push, or in-app channels. ## App-facing port ```bash bun add @beignet/core ``` ```typescript import type { MailerPort } from "@beignet/core/mail"; export type AppPorts = { mailer: MailerPort; }; ``` Use cases and jobs depend on `MailerPort`, not a vendor SDK: ```typescript await ctx.ports.mailer.send({ to: "user@example.com", subject: "Welcome", text: "Thanks for joining.", }); ``` `send(...)` requires at least one `to` recipient and accepts `text`, `html`, or both. Recipients can be strings or named address objects: ```typescript await ctx.ports.mailer.send({ from: { email: "support@example.com", name: "Support" }, to: [ "user@example.com", { email: "admin@example.com", name: "Admin" }, ], cc: "audit@example.com", replyTo: "support@example.com", subject: "Account updated", text: "Your account was updated.", html: "

Your account was updated.

", headers: { "X-App-Event": "account.updated", }, }); ``` Empty optional recipient lists are ignored, so callers can safely pass filtered `cc`, `bcc`, or `replyTo` arrays. Beignet's shared formatter rejects carriage returns and line feeds in address strings, email fields, and display names before the Resend or SMTP provider builds its recipient headers. Named-address backslashes and quotes are escaped; full email-syntax validation remains the provider's responsibility. ## Dev-default provider Use `createMemoryMailerProvider(...)` to wire the `mailer` port before choosing a real mail service. It captures deliveries in memory and records `mail.sent` devtools events through the `mail` watcher when instrumentation is installed, including the recipient count, delivery ID, and duration, so local development can see outgoing mail without sending anything. The event excludes addresses, subjects, and message bodies; inspect the memory mailer's captured deliveries only in trusted development and test code when message content is needed. ```typescript // server/providers.ts import { createMemoryMailerProvider } from "@beignet/core/mail"; export const providers = [ createMemoryMailerProvider({ defaultFrom: "App ", }), ] as const; ``` Swap it for a real provider when the app needs delivery; the `mailer` port shape stays the same. ## Provider setup Use Resend when you want an HTTP email service: ```bash bun add @beignet/provider-mail-resend resend ``` The Resend provider supports `resend` `^2`, `^3`, `^4`, and `^6`. Resend v6 uses Beignet's Node.js 22.12-or-newer runtime baseline, which also satisfies the SDK's requirement. ```typescript import { createResendMailProvider } from "@beignet/provider-mail-resend"; export const providers = [createResendMailProvider()]; ``` Resend reads `RESEND_API_KEY` and `RESEND_FROM`. Use SMTP when your deployment already has SMTP credentials: ```bash bun add @beignet/provider-mail-smtp nodemailer@^9.0.1 ``` ```typescript import { createSmtpMailProvider } from "@beignet/provider-mail-smtp"; export const providers = [createSmtpMailProvider()]; ``` `createResendMailProvider(options)` and `createSmtpMailProvider(options)` configure either provider in code; options mirror the env fields and override env-derived values. SMTP reads `MAIL_HOST`, `MAIL_PORT`, `MAIL_USER`, `MAIL_PASS`, `MAIL_FROM`, and optional `MAIL_REQUIRE_TLS`. Encrypted transport is required by default: port `465` uses implicit TLS, while other ports must negotiate STARTTLS. Set `MAIL_REQUIRE_TLS=false` only for a trusted local mail-capture service that does not support TLS. Nodemailer 9.x starting at `9.0.1` is required because earlier releases are affected by a message-level `raw` access-control bypass. Both providers install the same `ctx.ports.mailer` shape. Resend also exposes `ctx.ports.resend.client`; SMTP exposes `ctx.ports.smtp.transporter`. Treat those as [escape hatches](/providers#escape-hatches) for provider-specific features such as attachments or custom delivery APIs. The raw SMTP transporter bypasses Beignet's typed mail validation, so construct raw messages, attachment paths, URLs, and recipients only from trusted application data. Neither provider retries a failed send. Each provider makes one vendor call per `send(...)`; a Resend network error or SMTP `sendMail(...)` failure becomes a `MailDeliveryError`. Mail delivery is not idempotent — a timed-out send may still have been delivered — so the providers fail fast instead of risking duplicate emails. When provider instrumentation is installed, both providers emit `mail.send` followed by `mail.sent` or `mail.failed`. Terminal events include `durationMs` in their details so mail latency is comparable with storage and other provider operations in devtools. Mail instrumentation records the provider, recipient count, delivery ID when available, and duration. It does not record addresses, subjects, message bodies, or raw provider errors. Delivery errors retain the provider error as `cause` for app-owned handling, so redact or restrict access when logging that cause. When mail needs retries, dispatch it from [jobs](/jobs) or the [outbox](/outbox) and own idempotency there. The job or outbox row should choose attempts and delay, and the application should record one delivery per business event before sending. ## Send mail from jobs For production workflows, prefer dispatching a job from the use case and sending mail in the job handler. The use case stays focused on the business decision, and mail delivery can be retried independently. ```typescript import { retry } from "@beignet/core/jobs"; import { z } from "zod"; import { defineJob } from "@/lib/jobs"; export const SendWelcomeEmailJob = defineJob("mail.welcome", { payload: z.object({ email: z.string().email(), }), retry: retry.exponential({ attempts: 3, }), async handle({ payload, ctx }) { await ctx.ports.mailer.send({ to: payload.email, subject: "Welcome", text: "Thanks for joining.", }); }, }); ``` Then dispatch the job from the workflow: ```typescript await ctx.ports.jobs.dispatch(SendWelcomeEmailJob, { email: user.email, }); ``` Use [Jobs](/jobs) for dispatcher and provider worker wiring. ## Testing Use the memory adapter in tests and local examples: ```typescript import { createMemoryMailer } from "@beignet/core/mail"; const mailer = createMemoryMailer({ defaultFrom: "noreply@example.com", }); await mailer.send({ to: "user@example.com", subject: "Welcome", text: "Hello", }); expect(mailer.deliveries).toHaveLength(1); expect(mailer.deliveries[0].message.to).toEqual(["user@example.com"]); ``` Because the memory adapter implements `MailerPort`, the same use case or job can run against production and test adapters. ## Devtools and errors Mail operations appear in the Mail view of [devtools](/devtools) when the devtools provider is installed. Delivery failures throw `MailDeliveryError` from `@beignet/core/mail`. Catch that error when mail delivery is expected to fail independently from the main workflow; otherwise let the job runner record the failure and retry. ## Read next - [Jobs](/jobs) for retryable mail workflows. - [Providers](/providers) for provider lifecycle and escape-hatch conventions. - [Config](/config) for validating provider environment variables. --- # Rate limiting Source: https://www.beignetjs.com/rate-limiting Rate limiting protects routes at the HTTP boundary while keeping the storage backend behind `RateLimitPort`. Contracts declare the limit, hooks enforce it, and providers decide where counters live. ## Setup Use the Upstash provider for distributed rate limiting: ```bash bun add @beignet/provider-rate-limit-upstash @upstash/redis @upstash/ratelimit ``` ```typescript import { createNextServer, createNextServerLoader } from "@beignet/next"; import { createAnonymousActor } from "@beignet/core/ports"; import { createRateLimitHooks } from "@beignet/core/server"; import { createUpstashRateLimitProvider } from "@beignet/provider-rate-limit-upstash"; import { initialPorts } from "@/infra/port-wiring"; export const getServer = createNextServerLoader(() => createNextServer({ ports: initialPorts, providers: [createUpstashRateLimitProvider()], hooks: [createRateLimitHooks()], context: ({ ports }) => ({ actor: createAnonymousActor(), ports, }), }), ); ``` `createUpstashRateLimitProvider(options)` configures the Upstash connection, prefix, and algorithm in code; options override env-derived values. The provider reads `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN`, and the optional `UPSTASH_PREFIX`, `UPSTASH_ALGORITHM`, `UPSTASH_TIMEOUT_MS`, and `UPSTASH_TIMEOUT_POLICY` values from environment variables. It contributes the standard `rateLimit` port plus `ctx.ports.upstash` with the raw Upstash Redis client as an [escape hatch](/providers#escape-hatches) for Upstash-specific operations. `UPSTASH_ALGORITHM` selects the rate limit algorithm: `fixed-window` (the default) is cheaper but can allow bursts at window boundaries, while `sliding-window` smooths those bursts at slightly more Redis work per hit. Switching algorithms changes how counters are keyed in Redis, so in-flight windows reset when the algorithm changes. Rate-limit decisions are bounded to `5000` milliseconds and fail closed by default. Set `UPSTASH_TIMEOUT_MS` to another positive integer when the deployment needs a tighter budget; the maximum supported value is `2147483647` milliseconds. Set `UPSTASH_TIMEOUT_POLICY=fail-open` only when availability is more important than enforcement during an Upstash outage. When app infrastructure already owns an Upstash Redis client, use the direct adapter instead of the env-backed lifecycle provider: ```typescript import { createUpstashRateLimit } from "@beignet/provider-rate-limit-upstash"; const rateLimit = createUpstashRateLimit({ client: appUpstashRedis, prefix: "myapp:ratelimit", algorithm: "sliding-window", timeoutMs: 2500, timeoutPolicy: "fail-closed", }); ``` The caller owns the client lifecycle. The adapter retains the provider's dynamic per-contract limits and accepts an optional instrumentation target. For `scope: "user"` limits, attach auth route hooks to the protected route or route group so the signed-in user actor is present before the server `beforeHandle` phase enforces the limit. ## Contract metadata Declare route-specific limits on the contract: ```typescript export const createComment = comments .post("/") .meta({ rateLimit: { max: 10, windowSec: 60, scope: "user" }, }) .body(CreateCommentSchema) .responses({ 201: CommentSchema }); ``` The built-in hook reads `contract.metadata.rateLimit` and calls `ctx.ports.rateLimit.hit(...)`. ## Scopes | Scope | Runs | Default key | | --- | --- | --- | | `global` | `onRequest`, before parsing and context | `contract::global` | | `ip` | `onRequest`, before parsing and context | `contract::ip:` | | `user` | `beforeHandle`, after route hooks resolve identity | `contract::user:` | Use `global` for one counter shared by every caller of that contract, `ip` for anonymous traffic, and `user` for signed-in workflows. `global` does not merge counters across contracts; use a custom key when an app-wide aggregate is intentional. For `user` limits, attach an auth route hook so `ctx.actor` is assigned to a user actor before the server `beforeHandle` phase. If the request actor is missing, anonymous, service, or system, the hook fails with `AuthUnauthorizedError` instead of sharing a global bucket. Default keys include the contract name, so one route cannot consume another route's limit. ## Custom keys Use custom key functions when your app needs tenant, plan, route, or API token scoping: ```typescript createRateLimitHooks({ key: ({ ctx, req, scope }) => { if (scope === "user") { const actorId = ctx.actor.type === "user" && ctx.actor.id ? ctx.actor.id : "anonymous"; return `path:${new URL(req.url).pathname}:tenant:${ctx.tenant?.id ?? "global"}:user:${actorId}`; } return `path:${new URL(req.url).pathname}`; }, earlyKey: ({ req, scope }) => { const token = req.headers.get("x-api-key"); return token ? `api-key:${token}` : `${scope}:${new URL(req.url).pathname}`; }, }); ``` Use `earlyKey` only for `global` and `ip` scopes because it runs before request parsing and context creation. A custom `key` or `earlyKey` replaces the complete default key. Include route or contract identity when different routes should have independent counters. ## Trusted proxies and client IPs `ip`-scoped limits require an explicit server-level `trustedProxy.clientIp`, hook-local `trustedProxy.clientIp`, `ipSource`, or custom `earlyKey`. When a registered contract declares `scope: "ip"` and no client-IP strategy exists, the hook fails `createServer(...)` startup with a configuration error that names the contract — the alternative would be silently collapsing all clients into one shared bucket. Routes added later through `server.route(...)` are covered by the same error at enforcement time. Proxies append the address they saw to the end of `x-forwarded-for`, so the last entry is the one written by your platform's trusted reverse proxy when the app always sits behind that proxy. Earlier entries — including the first — are sent by the client and can be forged to rotate buckets and bypass IP limits. Configure `trustedProxy` only when the app is always behind an edge that strips or normalizes those headers before they reach application code. ```typescript createNextServer({ // Last entry, appended by the platform's trusted proxy. trustedProxy: { clientIp: "x-forwarded-for-last" }, hooks: [createRateLimitHooks()], // ... }); createNextServer({ // First entry. Safe only behind an edge that strips and rewrites the header. trustedProxy: { clientIp: "x-forwarded-for-first" }, hooks: [createRateLimitHooks()], // ... }); createNextServer({ // Platform-specific header set by a trusted edge. trustedProxy: { clientIp: "cf-connecting-ip" }, hooks: [createRateLimitHooks()], // ... }); createNextServer({ // Custom platform header. trustedProxy: { clientIp: { header: "x-client-ip" } }, hooks: [createRateLimitHooks()], // ... }); // Explicit opt-out: trust no headers; each contract's ip-scoped traffic // shares one unknown-client bucket. createRateLimitHooks({ ipSource: "none" }); ``` Use `"x-forwarded-for-first"` only when a trusted edge normalizes the header before it reaches the app. When a configured client-IP source cannot resolve an IP for a request, the key falls back to the current contract's `contract::ip:unknown` bucket. With `ipSource: "none"`, every request to that contract lands in its unknown-client bucket. This turns the contract's `ip`-scoped limit into one shared limit for unidentified traffic — an explicit choice, never a silent default. The server resolves this policy once per request. The context factory and every server-hook phase receive the same `requestInfo`, and `createCsrfHooks(...)` uses its external origin automatically. A hook-local `trustedProxy` option overrides the server policy only for that hook. ## Failure behavior When the limit is exceeded, `createRateLimitHooks` throws an `AppError` using Beignet's `429 Too Many Requests` catalog error. Because the error comes from a hook, the response is framework-owned and does not need to appear in every route's `.responses(...)`. Denial details sent to clients contain `scope`, `retryAfterSeconds`, and `resetAt`, and the 429 response carries a standard `Retry-After` header whenever the limiter reports a reset time, so generic HTTP clients back off without parsing the Beignet error body. The bucket key — which can embed user IDs, client IPs, or API token fragments — is never serialized into the response body. Each denial also emits a `rateLimit.denied` instrumentation event with the key, scope, limit, and window so operators can see which bucket was exhausted in the devtools Rate limits tab. If your app wants other custom headers or response bodies, add a Beignet error mapping hook or implement a small app-owned rate limit hook that still calls `ctx.ports.rateLimit`. Backend errors are not rate-limit denials. The Upstash provider records the failed check and rethrows the original error, so the request fails instead of continuing without enforcement. When Upstash does not respond before `UPSTASH_TIMEOUT_MS`, the default `fail-closed` policy records `rateLimit.hit.failed` and throws a timeout error. The explicit `fail-open` policy allows the request with unknown remaining/reset values and emits `rateLimit.hit.degraded`. Alert on degraded events: they mean the route continued without a confirmed rate-limit decision. ## Readiness The Upstash escape hatch exposes a read-only Redis PING: ```typescript const health = await ctx.ports.upstash.checkHealth(); ``` Use it in an app-owned readiness route. `beignet preflight --connect` discovers the same `checkHealth()` method and fails when Upstash is unavailable. The check uses the provider's configured timeout. The REST-based provider starts no worker or persistent connection, so it is safe in serverless and long-lived HTTP runtimes. ## Devtools Rate limit checks appear in the Rate limits view of [devtools](/devtools) when the devtools provider is installed before the Upstash rate limit provider. ## Direct use Use the port directly for non-HTTP workflows or app-specific limits: ```typescript import { AppError } from "@beignet/core/errors"; const result = await ctx.ports.rateLimit.hit({ key: `password-reset:${email}`, limit: 3, windowSec: 900, }); if (!result.allowed) { throw new AppError(errors.PasswordResetRateLimited); } ``` ## Testing Tests can use the first-party in-memory adapter: ```typescript import { createMemoryRateLimiter } from "@beignet/core/ports"; const rateLimit = createMemoryRateLimiter(); ``` It uses fixed windows and returns the same `allowed`, `remaining`, `resetAt`, and `retryAfterSeconds` shape as production providers. Its counters are per-process — see [Process boundaries of memory providers](/providers#process-boundaries-of-memory-providers). --- # Workflows Source: https://www.beignetjs.com/workflows Beignet splits application work into small primitives: events, jobs, schedules, tasks, notifications, best-effort work, idempotency keys, and outbox records. This page is the map for that toolbox: which primitive answers which question, how side effects stay transactional, and how the pieces compose into multi-step workflows with durable state. ## Workflow primitives Pick the primitive that matches the sentence your code is trying to say: | Primitive | Use when the code says | Owns | Does not own | | --- | --- | --- | --- | | Command use case | "Do this business operation now." | Input/output validation, transaction boundaries, business decisions, audit, domain events | HTTP parsing, durable delivery, vendor-specific side effects | | Event | "This fact happened." | Stable fact name, payload schema, fan-out to listeners | Work ordering, retry policy, user communication intent | | Job | "Do this work later or outside the request." | One handler, payload schema, retry policy, background execution | Database commit atomicity unless dispatched through outbox | | Best-effort work | "Try this after the operation; losing it is acceptable." | Failure isolation for non-durable follow-up work | Delivery, retry, replay, or transaction atomicity | | Schedule | "Start this workflow at this time." | Cron expression, time metadata, trigger payload | Durable retry/dead-letter semantics in core | | Notification | "Tell a person or team about this." | Communication intent, channel rendering, channel selection | Durable execution by itself | | Idempotency key | "This logical command may arrive again." | Duplicate detection, payload fingerprinting, safe replay/conflict handling | Background delivery or side-effect scheduling | | Outbox record | "This event or job must commit with the database write." | Transactional side-effect intent, retry, backoff, dead-letter state | Idempotency inside the eventual listener or job | [Tasks](/tasks) are operational entrypoints you run on demand, such as backfills and maintenance work, through `beignet task run`. Common combinations: - Reliable side effect after commit: the use case records an event through a transaction-scoped outbox recorder, the outbox drains after commit, and a listener dispatches a job or sends a notification. - Scheduled durable work: the schedule handler computes the run payload and dispatches a job instead of doing long-running work inside the cron trigger. - Retry-safe external call: the job handler wraps the provider call in `runIdempotently(...)` when the provider or worker may retry the same logical work. - Ephemeral invalidation hint: the authoritative write commits first, then `BestEffortWorkPort` schedules a broadcast whose loss is repaired by client reconciliation. See [Ports and adapters](/ports#defer-best-effort-work). - Payment fulfillment: the webhook route verifies the provider event, the billing use case keys idempotency on the event ID, and committed entitlement changes emit events or jobs through the outbox. See [Payments and billing](/payments). When in doubt, keep the command use case as the business boundary. Add events for facts other parts of the app may care about, jobs for explicit background work, and outbox only when losing the post-commit side effect is unacceptable. Use best-effort work only when losing it is an explicit part of the design. ## Side effects after commit Every durable primitive shares one rule: do not perform side effects inside an open database transaction. Use cases record intent inside the transaction — `events.record(tx.events, ...)` for facts, a transaction-scoped dispatcher for jobs. If the transaction rolls back, the recorded intent is discarded, so listeners, jobs, and mail never observe data that did not commit. If it commits, the Unit of Work validates and publishes the buffered events — or, in production, the [outbox](/outbox) stores them as database rows in the same transaction and a worker drains them after commit with retries. An ordinary buffered event flush is not atomic with the database commit. If publishing fails after commit, the Unit of Work rejects even though the business writes are already durable; callers must not interpret that rejection as proof that the transaction rolled back or blindly retry a non-idempotent command. Use the outbox when losing the event is unacceptable or when a caller retry must not repeat committed work. For deliberately ephemeral follow-up work, schedule `BestEffortWorkPort` only after the authoritative mutation succeeds. Its adapter isolates scheduling and callback failures from the command result. It does not add transaction atomicity, retries, or replay. Background delivery is at least once, not exactly once. Put [idempotency](/idempotency) inside listeners and job handlers that own work that must not happen twice. See [Database and transactions](/database#transactions) for the Unit of Work wiring that backs `tx.events`. ## App-bound builders Every workflow capability follows the same definition pattern: create the app-bound builder once in `lib/.ts` with the matching factory so each definition's `ctx` is typed as your `AppContext`: ```typescript // lib/jobs.ts import { createJobs } from "@beignet/core/jobs"; import type { AppContext } from "@/app-context"; export const { defineJob } = createJobs(); ``` `createListeners`, `createNotifications`, `createSchedules`, `createTasks`, and `createUploads` follow the same shape in `lib/listeners.ts`, `lib/notifications.ts`, `lib/schedules.ts`, `lib/tasks.ts`, and `lib/uploads.ts`. New apps do not ship these files; the `beignet make` generators create each one on first use. The rule for when a factory exists: `createX()` exists exactly where a definition binds your app context — jobs, listeners, notifications, schedules, tasks, and uploads all declare handlers that receive `ctx`. Events, webhooks, flags, and search indexes are context-free by design: an event or webhook declares a payload shape, a flag declares a value, and a search index declares attributes — none of them run app code, so `defineEvent`, `defineWebhook`, `defineFlag`, and `defineSearchIndex` are imported directly from their core subpaths with no `lib/` builder. ## Service contexts Background work has no request to build a context from. Registry modules such as `server/schedules.ts` and `server/outbox.ts` call `server.createServiceContext(...)`, which builds an `AppContext` through the `service` factory declared in the server's context blueprint and attaches `ctx.gate` the same way it does for requests. `beignet schedule run`, `beignet task run`, `beignet outbox drain`, and the cron route helpers all run through it. Plain scripts such as seeds and one-off maintenance work must use `server.runServiceContext(input, fn)` instead, which scopes the ambient correlation frame to the callback and stays safe under top-level await. See [Routes and server](/server) for the blueprint and the two service entrypoints. ## Workflows as state machines Multi-step processes — onboarding, approvals, appointment lifecycles, intake review — should stay app-owned. Keep the workflow state in your database and repositories, and keep each transition in a command use case. The state machine is not a new framework layer; it is the feature's domain model plus transition use cases in the ordinary feature folder shape. Model the allowed states and transitions in domain code, free of infra, route handlers, React, and provider packages: a status union such as `"draft" | "submitted" | "in_review" | "approved"`, a version field for optimistic concurrency, and pure transition functions that return either the next state or a typed failure reason. A transition use case loads the current state, checks policy, applies the domain transition, maps failures to the app error catalog, writes the new state, audits the decision, and records events inside the same Unit of Work: ```typescript import { z } from "zod"; import { requireTenantScope } from "@beignet/core/tenancy"; import { appError } from "@/features/shared/errors"; import { useCase } from "@/lib/use-case"; import { IntakeApproved } from "../domain/events"; import { approveIntakeTransition } from "../domain/intake"; export const approveIntake = useCase .command("intake.approve") .input( z.object({ intakeId: z.string().uuid(), expectedVersion: z.number().int().positive(), }), ) .emits([IntakeApproved]) .run(async ({ ctx, input, events }) => { const scope = requireTenantScope(ctx); return ctx.ports.uow.transaction(async (tx) => { const intake = await tx.intakes.findById(input.intakeId, scope); if (!intake) { throw appError("IntakeNotFound"); } await ctx.gate.authorize("intake.approve", intake); const approved = approveIntakeTransition(intake, { expectedVersion: input.expectedVersion, now: new Date(), }); if (!approved.ok) { throw appError( approved.reason === "version_conflict" ? "IntakeVersionConflict" : "IntakeInvalidStatus", { details: approved }, ); } await tx.intakes.update(approved.value); await tx.audit.record({ action: "intake.approve", resource: { type: "intake", id: intake.id }, }); await events.record(tx.events, IntakeApproved, { intakeId: approved.value.id, approvedAt: approved.value.reviewedAt, }); return approved.value; }); }); ``` The use case stays callable from HTTP routes, jobs, schedules, scripts, and tests, so the workflow rule lives in one place. A listener on `IntakeApproved` then dispatches follow-up jobs or notifications after commit. ## Time-based recovery Use [schedules](/schedules) when a workflow needs time-based nudges or recovery: expire abandoned onboarding sessions, remind users before renewals, or escalate approvals that have not moved. Keep long-running work out of the schedule handler — dispatch jobs or write outbox records so retry behavior stays inspectable. ## Idempotency at entry points Use [idempotency](/idempotency) for commands that browsers, webhooks, mobile clients, or external systems may retry: declare `meta.idempotency` on HTTP contracts, and wrap non-HTTP entry points in `runIdempotently(...)`. Do not use it as a replacement for optimistic concurrency — keep `expectedVersion` or an equivalent repository guard on state transitions so two actors cannot silently overwrite each other. ## Testing workflow paths Test each transition at the use-case boundary, then add focused tests for the durable chain: the use case records the expected event inside the transaction, the outbox drains it after commit, the listener enqueues the expected job, and exhausted retries dead-letter with useful instrumentation. The `@beignet/core/testing` helpers such as `createUseCaseTester(...)` and `assertOutboxDeadLettered(...)` keep these tests readable without widening production ports. ## Choosing the smallest tool Do not turn every multi-step operation into a formal state machine. Start with a use case and a status field. Add events when other parts of the app need to react, jobs when work should leave the request, outbox when the post-commit work must not be lost, and schedules when time should start or repair workflow work. --- # Events Source: https://www.beignetjs.com/events Events are facts that happened in your domain. Use an event when the code says "this happened" and multiple parts of the app may care: a post was published, a user registered, an invoice was paid, or a comment was added. Beignet events are typed definitions. Event buses and listeners decide how the fact is delivered. ```bash bun add @beignet/core ``` ## Define an event ```typescript import { defineEvent } from "@beignet/core/events"; import { z } from "zod"; export const PostPublished = defineEvent("post.published", { payload: z.object({ postId: z.string().uuid(), slug: z.string(), publishedAt: z.string().datetime(), }), }); ``` The event name is the stable identity. The payload schema validates data before publication and before listener execution. ## Emit events from use cases Use cases declare which events they may emit with `.emits(...)`. The handler receives an `events` helper scoped to that declaration: ```typescript const publishPost = useCase .command("posts.publish") .input(PublishPostInput) .output(PostOutput) .emits([PostPublished]) .run(async ({ ctx, input, events }) => { return ctx.ports.uow.transaction(async (tx) => { const published = await tx.posts.publish(input.slug); await events.record(tx.events, PostPublished, { postId: published.id, slug: published.slug, publishedAt: published.publishedAt, }); return published; }); }); ``` `events.record(...)` catches undeclared events at compile time and throws `UseCaseEventDeclarationError` if an undeclared event is emitted dynamically. ## Record events inside transactions Recording through `tx.events` keeps events transactional: if the transaction rolls back, recorded events are discarded; if it commits, the Unit of Work validates and publishes them. The original payload crosses the event-bus boundary, then the registered listener validates and parses it immediately before execution. That keeps producer validation while ensuring schema transforms run exactly once for the listener. See [side effects after commit](/workflows#side-effects-after-commit) for the rule, [Database and transactions](/database#transactions) for the Unit of Work wiring, and [Outbox](/outbox) when event delivery itself must be durable — the outbox keeps the same `events.record(tx.events, ...)` API but records events as database rows and drains them after commit with retries. For simple non-transactional workflows, call `events.publish(ctx.ports.eventBus, PostPublished, payload)`. It validates the payload before publishing the original value through the event bus; the listener owns the execution-time parse. ## Define listeners Listeners react to events. Create the app-bound `defineListener` builder once in `lib/listeners.ts` with `createListeners()` (see [app-bound builders](/workflows#app-bound-builders)), then define listeners in feature files: ```typescript import { defineListener } from "@/lib/listeners"; import { PostPublished } from "@/features/posts/domain/events"; export const enqueuePublishedEmail = defineListener( "posts.enqueue-published-email", { event: PostPublished, async handle({ payload, ctx }) { await ctx.ports.jobs.dispatch(SendPostPublishedEmailJob, payload); }, }, ); ``` Listeners should live with domain or application code, then be collected in `server/listeners.ts` and registered from server provider wiring. ## Register listeners ```typescript // server/listeners.ts import { postListeners } from "@/features/posts/listeners"; export const listeners = [...postListeners] as const; ``` ```typescript // server/providers.ts import { registerListeners } from "@beignet/core/events"; import { listeners } from "@/server/listeners"; const registration = registerListeners(eventBus, listeners, { ctx, onError(error, listener) { ctx.ports.logger.error("Listener failed", { error, listener: listener.name, }); }, }); await registration.ready; ``` `ready` resolves only after every initial transport subscription is active. `registerListeners(...)` applies one 10-second registration deadline to the complete registry by default; pass `readyTimeoutMs` to choose another positive timeout. If setup fails or times out, Beignet starts cleanup for every subscription within the remaining deadline. Cleanup that does not settle in time is included in the rejected aggregate as a `ListenerRegistrationCleanupTimeoutError`, so startup remains bounded without claiming rollback completed. Call `await registration.unsubscribe()` during normal teardown and apply a host-level shutdown deadline when required. Generated listener wiring performs this work in a provider `start()` hook, so server creation does not resolve until the registry is ready. Readiness covers initial registration only. It is not an ongoing transport health check, and it does not make an ephemeral event bus durable or replay messages missed during a later disconnect. `beignet make listener` updates `server/listeners.ts` and provider wiring. `beignet doctor` flags listener and event registration drift; see [CLI](/cli) for the generator and doctor details. ### Failure and ordering semantics Listener delivery is not an independently durable fan-out. On an awaited, sequential event bus, listeners run in registration order. Without `registerListeners(..., { onError })`, a listener failure propagates to the bus and may prevent later listeners from running. If the transport retries the event, listeners that already succeeded may run again. Providing `onError` reports and consumes that listener failure, allowing a sequential bus to continue, but Beignet does not then retry the failed listener independently. Choose this policy deliberately. When every side effect needs its own retry and dead-letter lifecycle, have listeners dispatch separate idempotent jobs or record separate outbox messages instead of relying on event fan-out for delivery isolation. ## Event bus adapters The starter ships no event bus. `beignet make event` and `beignet make resource --events` add the `eventBus: EventBusPort` port and register the memory provider when the app does not have one yet, and skip the wiring when the ports file already mentions `eventBus`. The in-memory bus suits local development, tests, and single-process apps: ```typescript // server/providers.ts import { createMemoryEventBusProvider } from "@beignet/provider-event-bus-memory"; export const providers = [createMemoryEventBusProvider()] as const; ``` Tests and app-owned wiring can also create the bus directly with `createMemoryEventBus()` from the same package. For multi-process best-effort delivery, use the Redis Pub/Sub provider: ```bash bun add @beignet/provider-event-bus-redis ioredis ``` ```typescript // server/providers.ts import { createRedisEventBusProvider } from "@beignet/provider-event-bus-redis"; export const providers = [createRedisEventBusProvider()] as const; ``` Set `REDIS_EVENT_BUS_URL=redis://localhost:6379`, or let the CLI apply the full preset: ```bash beignet provider add event-bus-redis ``` Redis Pub/Sub gives cross-instance delivery while subscribers are online. It does not persist messages, replay missed events, acknowledge handlers, retry failed handlers, or dead-letter failures. Production apps that need durable event delivery should use [Outbox](/outbox), jobs, or an app-owned durable transport behind the same event bus port; feature code keeps publishing through `ctx.ports.eventBus` either way. The provider opens separate publisher and subscriber connections in every process where it is installed. ioredis reconnects and resubscribes after a transient disconnect, but events published during that gap are lost. Host subscriptions in long-lived web or worker processes rather than ephemeral request runtimes. The env-backed provider accepts a standalone or managed single-endpoint Redis URL; Sentinel and Cluster users should inject app-owned ioredis clients through `createRedisEventBus(...)` and validate failover against their exact topology. The direct adapter exposes `stop()` for shutdown: it detaches Beignet's message listener, cancels subscription retries, and awaits channel cleanup without closing the app-owned publisher or subscriber. Failed cleanup rejects and is reported through instrumentation; `stop()` retries channels whose earlier unsubscribe failed before reporting any remaining cleanup failure. Cleanup has a 10-second deadline by default, configurable with `cleanupTimeoutMs` or `REDIS_EVENT_BUS_CLEANUP_TIMEOUT_MS`, so a stuck Redis command cannot hold server rollback open forever. Await `stop()` before closing or reusing those clients, and force-disconnect caller-owned clients if cleanup rejects. When `ports.tracing` is installed, the Redis envelope carries Beignet's versioned trace carrier and `registerListeners(...)` continues the producer trace in the subscriber process. Legacy envelopes and malformed trace metadata still deliver the event payload. Redis subscribe and unsubscribe command failures are reported through the provider's `onSubscriberError` callback and recorded as `eventBus.subscription.failed` instrumentation, so a failed listener registration is visible in enabled devtools or app telemetry. A failed subscription retries with capped backoff while at least one local handler remains registered; its `ready` promise stays pending until Redis acknowledges the subscription or the caller cancels it. Removing the final handler cancels the retry. Publish failures reject the caller and record `eventBus.publish.failed`; the provider never reports a failed Redis command as successful delivery. Connection errors from env-backed publisher and subscriber clients are recorded as `eventBus.connection.failed` when the watcher is enabled. Without enabled instrumentation, ioredis's fallback logging remains active. The canonical generated listener provider also reports handler failures once from `registerListeners(...).onError`. Direct listener registration remains app-owned; do not report again inside a listener handler when the registration boundary already owns the incident. See [Runtime recipes](/runtime-recipes#best-effort-events) for the process topology and readiness implications of best-effort cross-process events. ## Where events fit - [Workflow primitives](/workflows#workflow-primitives) compares events with jobs, schedules, notifications, idempotency keys, and outbox records. - [Jobs](/jobs) covers typed job definitions, dispatchers, and provider workers. - [Mail](/mail) shows a common event-to-job-to-mail workflow. --- # Jobs Source: https://www.beignetjs.com/jobs Jobs represent explicit work to do. Use a job when the code says "do this work" and one handler owns the work: send an email, process an import, sync a record, generate a report, or call a slow third-party API. Beignet jobs are typed definitions. Dispatchers decide whether they run inline, in tests, or through a durable provider such as BullMQ or Inngest. ```bash bun add @beignet/core ``` ## Define a job Create the app-bound `defineJob` builder once in `lib/jobs.ts` with `createJobs()` (see [app-bound builders](/workflows#app-bound-builders)), then define jobs in feature files: ```typescript import { retry } from "@beignet/core/jobs"; import { z } from "zod"; import { defineJob } from "@/lib/jobs"; export const SendWelcomeEmailJob = defineJob("mail.welcome", { payload: z.object({ email: z.string().email(), }), retry: retry.exponential({ attempts: 3, }), async handle({ payload, ctx }) { await ctx.ports.mailer.send({ to: payload.email, subject: "Welcome", text: "Thanks for joining.", }); }, }); ``` The payload schema is validated before the job is dispatched and before durable worker execution calls `handle(...)`. Durable transports preserve the original JSON-safe payload after producer validation, so the worker's execution-time parse applies schema transforms exactly once before the handler. ## Dispatch jobs Use cases dispatch jobs through `ctx.ports.jobs`: ```typescript await ctx.ports.jobs.dispatch(SendWelcomeEmailJob, { email: user.email, }); ``` That port can be an inline dispatcher in local development and tests, or a durable provider in production. ## Inline dispatcher Use the inline dispatcher when the work should run immediately in the same process: ```typescript import { createInlineJobDispatcher } from "@beignet/core/jobs"; const jobs = createInlineJobDispatcher({ ctx, onError(error, job) { ctx.ports.logger.error("Job failed", { error, jobName: job.name, }); }, }); ``` Inline dispatch honors the job's declared retry policy in-process: a failed handler retries with the policy's delays before the dispatch rejects. Jobs without a retry policy run once, and payload validation failures never retry. Pass `sleep` to replace the real backoff delays in tests, or `retry: false` when another layer owns execution retries for every dispatch through the dispatcher. Retry policies run in exactly one layer. When an [outbox drain](/outbox) delivers a job through the inline dispatcher, the drain runs the handler once per pass and reschedules failures itself using the job's policy — the inline retry loop does not stack on top. No configuration is needed; the drain detects the inline dispatcher automatically. ## Job timeouts Use `timeout` when a job attempt should fail after a bounded execution window: ```typescript import { retry } from "@beignet/core/jobs"; import { z } from "zod"; import { defineJob } from "@/lib/jobs"; export const GenerateReportJob = defineJob("reports.generate", { payload: z.object({ reportId: z.string(), }), timeout: "30s", retry: retry.exponential({ attempts: 3 }), async handle({ payload, ctx, signal }) { await ctx.ports.reports.generate(payload.reportId, { signal }); }, }); ``` Timeouts apply per attempt. When the window expires, Beignet throws `JobTimeoutError`; retry policies then classify that timeout like any other handler failure. Inline dispatchers and outbox-backed inline drains enforce the timeout directly. `createBullMQJobWorker(...)` enforces it around the registered handler. `createInngestJobFunction(...)` maps whole-second timeouts to Inngest's `timeouts.finish` setting and fails fast for millisecond-precision timeouts Inngest cannot honor. The `signal` argument is cooperative cancellation. Beignet aborts it when the timeout fires, but JavaScript cannot forcibly stop work that ignores the signal, so pass it into cancellable provider calls where possible. If a handler ignores the signal, its original promise may continue after `JobTimeoutError` is reported and a retry-capable runner may start another attempt before the first one settles. Make timeout-prone handlers idempotent, propagate the signal through every cancellable operation, and treat timeouts as terminal when overlapping attempts would be unsafe. ## Job hooks Use `hooks` for app-owned behavior that should wrap every handler attempt: logging, tracing, tenant setup, per-job leases, or rate-limit checks. Hooks receive the same parsed payload, context, job definition, and timeout signal as the handler. Runner hooks wrap job-local hooks. ```typescript import type { JobDef, JobHook, StandardSchema } from "@beignet/core/jobs"; import { retry } from "@beignet/core/jobs"; import { z } from "zod"; import type { AppContext } from "@/app-context"; import { defineJob } from "@/lib/jobs"; export const logJobAttempts: JobHook< JobDef, AppContext > = async ({ job, ctx, attempt, maxAttempts }, next) => { ctx.ports.logger.info("Job attempt started", { jobName: job.name, attempt: attempt ?? null, maxAttempts: maxAttempts ?? null, }); await next(); ctx.ports.logger.info("Job attempt completed", { jobName: job.name, attempt: attempt ?? null, maxAttempts: maxAttempts ?? null, }); }; export const GenerateReportJob = defineJob("reports.generate", { payload: z.object({ reportId: z.string(), }), timeout: "30s", retry: retry.exponential({ attempts: 3 }), hooks: [logJobAttempts], async handle({ payload, ctx, signal }) { await ctx.ports.reports.generate(payload.reportId, { signal }); }, }); ``` Global hooks can be installed on execution runners: ```typescript createInlineJobDispatcher({ ctx, hooks: [logJobAttempts], }); ``` The same `hooks` option is available on `createBullMQJobWorker(...)` and `createInngestJobFunction(...)`. Hooks run inside the job timeout, and errors thrown by hooks are classified by the same retry policy as handler errors. A hook may skip `next()` to short-circuit the handler; that counts as a successful attempt. When a runner reports attempt metadata, Beignet forwards it to hooks with the same one-based `attempt` convention used by retry policies. Direct `job.handle(...)` calls bypass hooks, so tests that need hook behavior should dispatch the job or call `runJobHandler(...)`. ### Execution lease hooks Use `createJobExecutionLeaseHook(...)` when one handler attempt should run at a time for a logical job key. This is execution-time coordination: it does not replace `unique`, which suppresses duplicate dispatches before work is queued. ```typescript import { createJobExecutionLeaseHook, type JobDef, retry, } from "@beignet/core/jobs"; import { z } from "zod"; import type { AppContext } from "@/app-context"; import { defineJob, logJobAttempts } from "@/lib/jobs"; const reportPayloadSchema = z.object({ reportId: z.string(), workspaceId: z.string(), }); const reportExecutionLease = createJobExecutionLeaseHook< JobDef<"reports.generate", typeof reportPayloadSchema, AppContext>, AppContext >({ locks: ({ ctx }) => ctx.ports.locks, key: ({ payload }) => payload.workspaceId, ttl: "5m", onUnavailable: "skip", }); export const GenerateReportJob = defineJob("reports.generate", { payload: reportPayloadSchema, timeout: "30s", retry: retry.exponential({ attempts: 3 }), hooks: [logJobAttempts, reportExecutionLease], async handle({ payload, ctx, signal }) { await ctx.ports.reports.generate(payload.reportId, { signal }); }, }); ``` The helper performs one bounded `LocksPort.acquire(...)` call per attempt. It does not start a renewal loop or background worker, so it is safe for serverless entrypoints when `locks` is backed by shared storage such as the Redis locks provider. Release is best effort; `ttl` is the real safety boundary if the runtime is frozen or terminated before `finally` runs. `onUnavailable` defaults to `"skip"`, which treats an overlapping attempt as successful without running the handler. Use `"throw"` to raise `JobExecutionLeaseUnavailableError` and let the retry policy decide whether to try again, or pass a function to log and optionally throw your own error. ## Unique jobs Use `unique` when duplicate dispatches of the same logical job should collapse for a bounded time window. The uniqueness guard is dispatch-time coordination: it prevents another enqueue while the lease is active, but it does not replace handler idempotency for provider retries or worker crashes. ```typescript import { createInlineJobDispatcher, createUniqueJobDispatcher, retry, } from "@beignet/core/jobs"; import { z } from "zod"; import type { AppContext } from "@/app-context"; import { defineJob } from "@/lib/jobs"; const syncAccountPayloadSchema = z.object({ accountId: z.string().min(1), }); export const SyncAccountJob = defineJob("billing.sync-account", { payload: syncAccountPayloadSchema, unique: ({ payload }) => ({ key: payload.accountId, ttl: "10m", }), timeout: "30s", retry: retry.exponential({ attempts: 3 }), async handle({ payload, ctx, signal }) { await ctx.ports.billing.syncAccount(payload.accountId, { signal }); }, }); export function createJobsPort(args: { ports: Pick; createBackgroundContext: () => AppContext; }) { const baseJobs = createInlineJobDispatcher({ ctx: args.createBackgroundContext, }); return createUniqueJobDispatcher({ jobs: baseJobs, locks: args.ports.locks, }); } ``` The concrete lock key is namespaced as `jobs:unique::`. A successful dispatch keeps the lease until `ttl` expires; a failed dispatch releases it so the caller can retry. Wrap direct BullMQ or Inngest dispatchers the same way when an app wants provider-backed dispatch plus Beignet-owned uniqueness. Wrap root dispatchers for unique durable jobs. If you wrap a transaction-scoped outbox dispatcher, the lease is acquired before the transaction commits, so a rollback can still suppress duplicates until the TTL expires. ## Durable dispatch with Inngest Install the Inngest jobs provider when production jobs should be queued outside the request process: ```bash bun add @beignet/provider-jobs-inngest @beignet/core inngest@^4.12.1 ``` Use Inngest v4 with Beignet's Node.js 22.12-or-newer runtime baseline. ```typescript import { createInngestJobsProvider } from "@beignet/provider-jobs-inngest"; import { inngest } from "@/infra/inngest"; export const providers = [createInngestJobsProvider({ client: inngest })]; ``` The injected client keeps event dispatch and function registration on one identity. The SDK reads `INNGEST_*` cloud credentials from the environment. The provider adapts Inngest's durable execution platform into Beignet's `JobDispatcherPort`. It installs `ctx.ports.jobs` and exposes `ctx.ports.inngest.client` as an escape hatch for Inngest-specific features. It does not replace Beignet domain events; use listeners to turn domain facts into Inngest-backed jobs. Keep the function registry separate from the HTTP adapter. `beignet make job` updates `inngestJobs` when the provider is installed: ```typescript // server/inngest.ts import { createServiceActor } from "@beignet/core/ports"; import { createInngestJobFunctions } from "@beignet/provider-jobs-inngest"; import { userJobs } from "@/features/users/jobs"; import { inngest } from "@/infra/inngest"; import { getServer } from "@/server"; export const inngestJobs = [...userJobs] as const; export const inngestFunctions = createInngestJobFunctions({ client: inngest, jobs: inngestJobs, ctx: async () => { const server = await getServer(); return server.createServiceContext({ actor: createServiceActor("beignet-inngest"), }); }, instrumentation: async () => (await getServer()).ports, errorReporter: async () => (await getServer()).ports.errorReporter, }); ``` ```typescript // app/api/inngest/route.ts import { serve } from "inngest/next"; import { inngest } from "@/infra/inngest"; import { inngestFunctions } from "@/server/inngest"; export const { GET, POST, PUT } = serve({ client: inngest, functions: inngestFunctions, }); ``` The lazy server resolvers run inside each function invocation, avoiding eager provider startup when Next.js imports route modules during a production build. Use `INNGEST_DEV=1` only with the local dev server. Production requires `INNGEST_EVENT_KEY` for dispatch and `INNGEST_SIGNING_KEY` for endpoint verification; `beignet preflight` checks both. Non-Next presets expose `createAppInngestFunctions(...)` from `server/inngest.ts`, requiring the host to bind its app-owned service context before mounting Inngest's runtime adapter. See [Runtime recipes](/runtime-recipes#workers) for how Beignet separates provider adapters from serverless-safe worker entrypoints. Direct provider jobs run through provider-owned entrypoints such as Inngest functions; outbox-backed jobs run through `beignet outbox drain`. When a job defines a retry policy, the Inngest helper maps the total attempt count to Inngest's function retry setting, and `createInngestJobFunction(...)` fails fast if a job policy includes custom backoff, jitter, or `retryIf` behavior that Inngest cannot honor. Whole-second job timeouts map to Inngest `timeouts.finish`; sub-second timeouts fail fast because Inngest function timeouts cannot honor them exactly. Jobs without an explicit attempt count use `retries: 0` instead of Inngest's provider default. Incoming events that fail the job payload schema raise Inngest's `NonRetriableError`, so Inngest does not retry permanently malformed work. Beignet records that terminal execution as `deadLettered` when instrumentation is configured. Other failures continue through Inngest's normal retry handling and are not reclassified by Beignet. ## Durable workers with BullMQ Install the BullMQ provider when production jobs should run through a Redis-backed queue that your app owns: ```bash bun add @beignet/provider-jobs-bullmq @beignet/core bullmq ``` ```typescript import { createBullMQJobsProvider } from "@beignet/provider-jobs-bullmq"; export const providers = [ createBullMQJobsProvider({ startupTimeoutMs: 5_000, }), ]; ``` The provider installs `ctx.ports.jobs` and exposes `ctx.ports.bullMQJobs.queue` as an escape hatch for BullMQ-specific operations. Run workers from an explicit worker process, CLI entrypoint, or provider-owned route: ```typescript // server/workers/jobs.ts import { createBullMQJobWorker } from "@beignet/provider-jobs-bullmq"; import { SendWelcomeEmailJob } from "@/features/users/jobs"; import { getServer } from "@/server"; const server = await getServer(); export const jobsWorker = createBullMQJobWorker({ queueName: process.env.BULLMQ_QUEUE_NAME ?? "beignet-jobs", redisUrl: process.env.BULLMQ_REDIS_URL ?? "redis://localhost:6379/0", prefix: process.env.BULLMQ_PREFIX ?? "beignet", jobs: [SendWelcomeEmailJob], ctx: () => server.createServiceContext(), instrumentation: server.ports, workerOptions: { concurrency: 5, }, errorReporter: ({ ctx }) => ctx.ports.errorReporter, infrastructureErrorReporter: server.ports.errorReporter, }); const health = await jobsWorker.checkHealth({ timeoutMs: 5_000 }); if (!health.ok) { throw new Error(health.error.message); } ``` BullMQ workers are at-least-once. Put idempotency inside handlers when duplicate execution would create a side effect. Use `jobsWorker.close()` from process shutdown handling so BullMQ can stop claiming new jobs and wait for active jobs to finish. Put a hard shutdown deadline in the app-owned process entrypoint; pass `{ force: true }` only when unfinished jobs may safely return through BullMQ's stalled-job recovery. The worker helper defaults to the same `"beignet"` Redis key prefix as the provider; pass `prefix` when `BULLMQ_PREFIX` changes. Beignet-created producer connections disable the Redis offline queue and bound request retries so an HTTP dispatch fails instead of waiting indefinitely for Redis. Beignet-created workers use persistent consumer connection settings. Explicit connection objects remain app-owned. Completed jobs are retained for 24 hours up to 1000 rows, and failed jobs for 7 days up to 5000 rows. Configure `retention` on the provider when queue volume or incident policy needs different limits; pass `false` only when `defaultJobOptions` or per-job BullMQ options own finalized-job cleanup. Register the app-owned OpenTelemetry SDK in this standalone process before initializing the server. Passing `server.ports` starts the job span before the lazy service context is created. See [Observability](/observability) for the shared bootstrap pattern. BullMQ and Inngest dispatchers capture the active Beignet trace through their instrumentation target and place a versioned carrier in provider transport data. Their worker/function helpers accept both traced envelopes and legacy raw payloads, then continue the producer trace before resolving lazy app context. The carrier preserves the original producer payload after validation; the worker or function applies the handler-facing schema transform once. Malformed carrier metadata is ignored rather than failing the job. The BullMQ provider maps Beignet fixed and exponential retry attempts to BullMQ attempts/backoff. `retryIf` is honored by `createBullMQJobWorker(...)` with BullMQ unrecoverable failures. Retry fields BullMQ cannot honor exactly, such as `maxDelay`, custom exponential `factor`, and boolean `jitter`, fail fast. `createBullMQJobWorker(...)` also enforces Beignet job timeouts around registered handlers and reports timeout failures through the same retry/dead-letter classification path. Use the provider escape hatch in health or readiness routes when the deployment needs to prove the direct jobs queue is reachable: ```typescript const health = await ctx.ports.bullMQJobs.checkHealth(); if (!health.ok) { return Response.json({ ok: false, jobs: health }, { status: 503 }); } ``` For direct BullMQ jobs, Beignet instrumentation records terminal worker failures as `deadLettered`; BullMQ owns the concrete failed-job set. Use the outbox instead when the application database must contain durable retry and dead-letter rows for the side effect. An `errorReporter` configured on the worker captures terminal failures once. Retryable attempts remain `retryScheduled` instrumentation and logs; they do not create warning incidents. Pass `infrastructureErrorReporter` as the context-free fallback when the per-job `errorReporter` is a resolver. It captures payload validation, context-construction, and unknown-job failures that happen before app context exists, plus worker-level Redis errors, stalled jobs, and shutdown failures. Beignet selects the primary reporter when it can resolve one and does not intentionally fan a failure out to both reporters. Production Redis must use a non-evicting memory policy and appropriate persistence. Job payloads are stored in Redis, so prefer identifiers over secrets or unnecessary personal data. For Inngest functions, pass `errorReporter` to `createInngestJobFunction(...)`. Beignet installs a native `onFailure` handler that reports only after Inngest exhausts provider retries, with stable function/run/job identifiers and no event payload. ## Retry policy Use the `retry` helpers to make durable failure behavior explicit: ```typescript import { retry } from "@beignet/core/jobs"; class TemporaryProviderError extends Error {} retry.none(); retry.fixed({ attempts: 3, delay: "30s", }); retry.exponential({ attempts: 5, initialDelay: "10s", maxDelay: "10m", jitter: true, retryIf: ({ error }) => error instanceof TemporaryProviderError, }); ``` `attempts` is the maximum total attempts, including the first attempt. Use `retryIf` for app-owned transient/permanent error classification. ## Retry vocabulary Beignet uses the same retry language for jobs, outbox-backed delivery, and scheduled work: | Term | Meaning | | --- | --- | | `attempt` | One-based failed execution attempt currently being classified or recorded. | | `attempts` | Maximum total attempts, including the first try. | | retry | Run the same job again because the failed attempt is retryable. | | backoff | Delay before the next retry. Fixed and exponential helpers compute this for Beignet-owned workers. | | timeout | Maximum execution window for one handler attempt. | | hook | App-owned behavior that wraps one handler attempt. | | execution lease | TTL-backed lock acquired around one handler attempt to avoid overlapping execution for a logical job key. | | terminal failure | A non-retryable failure or an exhausted retry policy. | | dead letter | Durable terminal delivery state used by outbox-backed jobs. Direct job providers may expose their own failed-job set; Beignet instrumentation uses `deadLettered` for terminal provider-worker failures. | ## Jobs and transactions Avoid dispatching durable side effects before the database work commits. When a workflow uses Unit of Work, record a domain event during the transaction and let a [listener](/events#define-listeners) dispatch the job after commit — see [side effects after commit](/workflows#side-effects-after-commit) for the rule. Use [Outbox](/outbox) when the job enqueue must commit with the database write. The outbox can sit behind a transaction-scoped `tx.jobs` dispatcher, then a worker drains the durable row into your production job provider. `beignet make job` registers new feature job registries in an existing `server/outbox.ts`, and `beignet doctor` warns when a feature job is missing from the outbox registry (`--fix` registers it). Registry membership does not install a dispatcher. The generator also ensures `jobs: JobDispatcherPort` is declared and bound, using an app-owned inline provider only when the app has no existing dispatcher. Doctor warns when an outbox job registry has no bound or deferred `jobs` port, and drains reject that configuration before claiming messages. If you later add the `jobs-inngest` or `jobs-bullmq` provider preset, the CLI replaces only its marked generated inline fallback. It rejects an unmarked app-owned inline dispatcher as a provider conflict so custom execution wiring is never removed implicitly. ## Retry-safe jobs Job providers may retry handlers after process failures, timeouts, or transient errors. Put idempotency inside the job handler when the handler owns work that must not happen twice: ```typescript import { createIdempotencyFingerprint, runIdempotently, } from "@beignet/core/idempotency"; export const GenerateReportJob = defineJob("reports.generate", { payload: z.object({ reportId: z.string(), requestedBy: z.string(), }), retry: retry.exponential({ attempts: 3 }), async handle({ payload, ctx }) { await runIdempotently(ctx.ports.idempotency, { namespace: "reports.generate", key: payload.reportId, scope: { actorId: payload.requestedBy }, fingerprint: await createIdempotencyFingerprint(payload), ttlSec: 60 * 60 * 24, run: () => ctx.ports.reports.generate(payload.reportId), }); }, }); ``` Use [Idempotency](/idempotency) for the full command, webhook, and job pattern. ## Testing In use-case tests, pass a job dispatcher that records dispatches: ```typescript const dispatchedJobs: Array<{ name: string; payload: unknown }> = []; const jobs = { dispatch: async (job, payload) => { dispatchedJobs.push({ name: job.name, payload }); }, }; ``` In job tests, call the job handler directly with an in-memory context: ```typescript await SendWelcomeEmailJob.handle({ job: SendWelcomeEmailJob, payload: { email: "user@example.com" }, ctx, }); ``` ## Where jobs fit [Workflow primitives](/workflows#workflow-primitives) gives the full decision guide for commands, events, jobs, schedules, notifications, idempotency keys, and outbox records, and the [workflows overview](/workflows) shows the transition pattern that decides when jobs should be dispatched. --- # Schedules Source: https://www.beignetjs.com/schedules Schedules represent time-triggered application work. Use a schedule when the code says "run this at this time": send daily digests, sync billing state, clean expired records, refresh search indexes, or generate periodic reports. Beignet schedules are typed definitions. They describe the cron expression, optional timezone, payload schema, and handler. The runtime that triggers them can be a cron route, Inngest, Vercel Cron, a worker process, or an app-owned adapter. ```bash bun add @beignet/core ``` ## Define a schedule Create the app-bound `defineSchedule` builder once in `lib/schedules.ts` with `createSchedules()` (see [app-bound builders](/workflows#app-bound-builders)), then define schedules in feature files: ```typescript import { z } from "zod"; import { defineSchedule } from "@/lib/schedules"; export const SendDailyDigestSchedule = defineSchedule( "digests.send-daily", { cron: "0 9 * * *", timezone: "America/Chicago", payload: z.object({ date: z.string(), }), createPayload({ run }) { const date = run.scheduledAt ?? run.triggeredAt; return { date: date.toISOString().slice(0, 10), }; }, async handle({ payload, ctx }) { await ctx.ports.jobs.dispatch(SendDigestEmailJob, { date: payload.date, }); }, }, ); ``` The payload schema is validated before the handler runs. `createPayload(...)` lets a provider or cron route trigger the schedule with timing metadata while the schedule owns the app-specific payload. Register app schedules in `server/schedules.ts` when they should be available to operational runners. `beignet make schedule` creates this registry when it does not exist yet and appends new feature schedule registries to it: ```typescript import { createServiceActor } from "@beignet/core/ports"; import type { AppContext } from "@/app-context"; import { digestSchedules } from "@/features/digests/schedules"; import { getServer } from "@/server"; export const schedules = [...digestSchedules] as const; export async function createScheduleContext(): Promise { const server = await getServer(); return server.createServiceContext({ actor: createServiceActor("beignet-schedule"), }); } export async function stopScheduleContext(): Promise { const server = await getServer(); await server.stop(); } ``` `server.createServiceContext(...)` builds a [service context](/workflows#service-contexts) for background work. `beignet doctor` reports feature schedules that never made it into this registry, and `beignet doctor --fix` registers them. ## Run inline Use the inline runner for tests, local scripts, and app-owned trigger adapters: ```typescript import { createInlineScheduleRunner } from "@beignet/core/schedules"; const runner = createInlineScheduleRunner({ ctx: createBackgroundContext, onError({ error, schedule }) { logger.error("Schedule failed", { error, scheduleName: schedule.name, }); }, }); await runner.run(SendDailyDigestSchedule, { scheduledAt: new Date("2026-01-01T09:00:00.000Z"), attempt: 1, source: "vercel-cron", }); ``` Pass `payload` explicitly when a test or script needs full control instead of deriving it through `createPayload(...)`. Run registered schedules from the Beignet CLI for local, CI, or worker-hosted entrypoints: ```bash beignet schedule run digests.send-daily --scheduled-at 2026-01-01T09:00:00.000Z ``` The CLI loads `server/schedules.ts`, creates the app context, runs one schedule, records schedule events into the resolved instrumentation port when one exists, and then calls `stopScheduleContext(...)`. Omit `--payload` to use `createPayload(...)`; pass `--payload '{"date":"2026-01-01"}'` when the caller supplies the schedule payload. ## Expose a cron route In Next.js apps, prefer `createScheduleRoute(...)` from `@beignet/next` so the public trigger route stays small. The helper authenticates the schedule provider with a timing-safe bearer comparison, creates an app context, runs the schedule inline, and records `schedule` events through the instrumentation port resolved from `ctx.ports`: ```typescript // app/api/cron/digests/daily-digest/route.ts import { createScheduleRoute } from "@beignet/next"; import { env } from "@/lib/env"; import { getServer } from "@/server"; import { schedules } from "@/server/schedules"; export const runtime = "nodejs"; export const { GET, POST } = createScheduleRoute({ server: getServer, schedules, schedule: "digests.send-daily", secret: env.CRON_SECRET, source: "vercel-cron", }); ``` The schedule name is resolved when the route module loads, so a typo or an unregistered schedule fails at build or boot time instead of at the first cron invocation. Export both `GET` and `POST` when your scheduler may call either method. The schedule remains the reusable unit — Vercel Cron, Inngest, a worker, or a local script can all trigger the same definition — and schedule failures return a 500 response so providers can retry failed invocations. Cron routes fail closed when `CRON_SECRET` is missing. Set it in your deployment environment and send `Authorization: Bearer ` from your cron provider. Beignet checks the secret before resolving the server or creating app context. `beignet make schedule --route` scaffolds this route and adds a generated `CRON_SECRET` to `lib/env.ts` and `.env.example` when the app does not define one yet. See [Runtime recipes](/runtime-recipes#cron-and-schedules) for when to use cron routes, scheduled functions, worker-hosted tasks, or `beignet schedule run`. ## Schedules and jobs Schedules decide when work should begin. Jobs own durable work. See [Workflow primitives](/workflows#workflow-primitives) when deciding whether the scheduled handler should call a command use case, dispatch a job, send a notification, or write an outbox message. For production workflows, prefer schedule handlers that dispatch jobs. That keeps scheduled triggers small, makes retries a job-provider concern, and lets the same job run from HTTP, events, scripts, or manual admin actions. ## Failure semantics Schedules do not define retry policies. They are trigger definitions. Cron providers, worker hosts, and queue systems decide whether to retry a failed schedule invocation, with provider-owned backoff. Beignet preserves that behavior by rethrowing handler failures after `onError` runs. Dead-lettering is not a schedule concept in core: for critical work, keep the schedule handler small and dispatch a job or outbox message so retry policy, backoff, and dead-letter behavior move into Beignet's durable primitives, described in the [retry vocabulary](/jobs#retry-vocabulary). Cron syntax and timezone support are also provider-owned: Beignet stores the declared values but does not attempt to validate every scheduler dialect. Verify them against the deployment provider. Core does not prevent overlapping schedule invocations; platform retries or a slow prior run may overlap. Keep the trigger idempotent, dispatch a uniquely keyed job, or acquire an app-owned lock when one-at-a-time execution is required. ## Observability Pass a provider instrumentation target as `instrumentation` and the inline runner records first-class `schedule` events for each run: `started` before the handler, `completed` after it, and `failed` with the error when payload creation, validation, or the handler throws. The target accepts the same shapes as other Beignet subsystems, including the whole ports object: ```typescript const runner = createInlineScheduleRunner({ ctx, instrumentation: ctx.ports, instrumentationContext: { requestId: ctx.requestId, traceId: ctx.traceId, }, }); ``` `instrumentationContext` attaches request correlation fields so the devtools request view can expand into the schedule runs triggered by that invocation. `createScheduleRoute(...)` and `beignet schedule run` wire this up automatically from `ctx.ports`, and providers can emit the same `schedule` devtools events through provider instrumentation. Instrumentation watcher checks, redaction, and sink-failure isolation use the shared provider helper. Both owned runners also report terminal schedule failures through an optional `ctx.ports.errorReporter`; reporter failures do not replace the 500 response or CLI failure. For custom logging or metrics, the runner also exposes `onStart`, `onSuccess`, and `onError` lifecycle hooks. Lifecycle hook failures are isolated from schedule execution and reported to `onHookError` when provided — they never prevent the handler from running or turn a successful run into a failure. Instrumentation sink failures are silently isolated by the shared provider helper. Schedule handler failures still reject `runner.run(...)` after `onError` runs, which keeps `onError` useful for logging while preserving normal retry behavior for cron routes and workers. ## Testing Schedule tests can run handlers directly through the inline runner: ```typescript const runner = createInlineScheduleRunner({ ctx, now: () => new Date("2026-01-01T09:00:00.000Z"), }); await runner.run(SendDailyDigestSchedule, { scheduledAt: "2026-01-01T09:00:00.000Z", }); ``` Use in-memory or fake ports in `ctx` and assert on the resulting repository, job, mail, log, or devtools effects. --- # Idempotency Source: https://www.beignetjs.com/idempotency Idempotency makes retryable work safe. Use it when the same logical command may arrive more than once: a user double-submits a form, a mobile client retries a request, a webhook provider redelivers an event, or a job provider retries background work. Beignet enforces idempotency at the HTTP boundary the same way it enforces rate limits: contracts declare the requirement, `createIdempotencyHooks(...)` enforces it, and `IdempotencyPort` decides where reservations live. `runIdempotently(...)` remains the workflow-level primitive for non-HTTP work. ```bash bun add @beignet/core ``` ## Contract metadata Declare the requirement on the contract: ```typescript export const createAppointment = appointments .post("/") .headers( z.object({ "idempotency-key": z.string().min(1), }), ) .body(CreateAppointmentRequest) .meta({ idempotency: { required: true, header: "idempotency-key", scope: "actor-tenant", ttlSec: 60 * 60 * 24, }, }) .errors({ IdempotencyConflict: errors.IdempotencyConflict, IdempotencyInProgress: errors.IdempotencyInProgress, }) .responses({ 201: AppointmentResponse }); ``` The optional `headers` schema documents the key for OpenAPI and typed clients and rejects requests without it during request validation. The `.errors(...)` declarations reuse Beignet's `httpErrors.IdempotencyConflict` and `httpErrors.IdempotencyInProgress` catalog entries so clients see the declared `409` responses. ## Typed clients send keys automatically Beignet clients read the same contract metadata. When a contract declares `idempotency`, every call attaches a generated UUID to the metadata header (`meta.header`, default `idempotency-key`), so components never build keys by hand: ```typescript const createAppointmentEndpoint = apiClient.endpoint(createAppointment); // This call gets a generated idempotency-key header. await createAppointmentEndpoint.call({ body }); ``` The generated key is injected before request header validation runs, and the header becomes optional in the call types. To take control of the key, pass `idempotencyKey` as a call option for retry-with-same-key flows, or pass the header explicitly in `headers` — an explicit header always wins over generation. Each direct `call(...)` invocation gets a new generated key. If application code catches a timeout or network error and calls the endpoint again, that is a new invocation with a new key. Generate one key outside the retry loop and pass it as `idempotencyKey` on every attempt when those calls represent one logical command. Separate calls — including double-clicks — intentionally remain separate unless the app supplies the same key. `required: true` on the contract remains the server-side backstop: clients that are not Beignet clients — curl, mobile apps, third-party integrations — still get a framework-owned `400` when the key is missing. React Query mutations keep the generated key stable across TanStack retry attempts. See [React Query](/react-query) for the details. ## Hook wiring Install the built-in hook where the server is composed: ```typescript import { createIdempotencyHooks } from "@beignet/core/server"; import { createNextServer, createNextServerLoader } from "@beignet/next"; export const getServer = createNextServerLoader(() => createNextServer({ ports: initialPorts, hooks: [createIdempotencyHooks()], // ... }), ); ``` The hook reads `contract.metadata.idempotency` and enforces it with `ctx.ports.idempotency`. After request parsing and route hook identity resolution it reserves `{ namespace: "http.", key, scope, fingerprint }`, where the fingerprint hashes the parsed `{ path, query, body }`. A completed matching reservation short-circuits the handler and replays the stored response with an `idempotency-replayed: true` header. On success it stores final route-owned 2xx responses after the response-validation phase for replay; on framework-owned responses, errors, non-2xx responses, and native `Response` streams it releases the reservation so a retry re-executes. Routes without idempotency metadata pass through untouched. ## Scopes `meta.scope` controls who may replay a stored result: | Scope | Default scope value | | --- | --- | | omitted | `{ actorId: ctx.actor?.id }`, plus `tenantId` when `ctx.tenant?.id` is present | | `global` | `"global"` | | `actor` | `{ actorId: ctx.actor?.id }` | | `tenant` | `{ tenantId: ctx.tenant?.id }` | | `actor-tenant` | `{ actorId: ctx.actor?.id, tenantId: ctx.tenant?.id }` | Omitting `meta.scope` binds the key to the actor, includes the current tenant when one is present, and fails closed when `ctx.actor.id` is missing. Choose `global` explicitly only for a public operation whose callers should share one key namespace. Otherwise, scope keys to the boundary that owns the operation so one actor or tenant can never replay another's result. ## Error semantics | Reservation | Response | | --- | --- | | `reserved` | Handler runs; final route-owned 2xx result is stored for replay | | `replay` | Stored response + `idempotency-replayed: true` | | `inProgress` | `409` with code `IDEMPOTENCY_IN_PROGRESS` | | `conflict` | `409` with code `IDEMPOTENCY_CONFLICT` | The server maps the `IdempotencyConflictError` and `IdempotencyInProgressError` primitives to framework-owned `409` envelopes — including from use cases that call `runIdempotently(...)` directly, so apps do not need to re-map the primitive errors to their own catalog entries. ## Customization Override the namespace, scope, or fingerprint input when defaults do not fit: ```typescript createIdempotencyHooks({ namespace: ({ contract }) => `api.${contract.name}`, scope: ({ ctx, meta }) => ({ tenantId: ctx.tenant?.id, plan: ctx.tenant?.metadata?.plan, }), fingerprintInput: ({ body }) => body, }); ``` Use `fingerprintInput` when request metadata such as tracing headers or pagination noise should not define the logical command. ## Workflow-level idempotency Use `runIdempotently(...)` inside a use case, job, listener, webhook handler, or schedule when the retried work does not arrive over HTTP, or when the workflow itself owns retry safety: ```typescript import { createIdempotencyFingerprint, runIdempotently, } from "@beignet/core/idempotency"; export const importAppointments = useCase .command("appointments.import") .input(ImportAppointmentsInput) .output(ImportResult) .run(async ({ ctx, input }) => { const fingerprint = await createIdempotencyFingerprint(input, { omit: ["importId"], }); return runIdempotently(ctx.ports.idempotency, { namespace: "appointments.import", key: input.importId, scope: { tenantId: ctx.tenant?.id, actorId: ctx.actor?.id, }, fingerprint, ttlSec: 60 * 60 * 24, run: () => ctx.ports.uow.transaction((tx) => tx.appointments.importBatch(input.rows), ), }); }); ``` The helper reserves the key before running the workflow, completes it with the returned result, and releases the reservation if the workflow throws. If the workflow succeeds but `complete(...)` fails, the error is rethrown and the reservation remains in progress instead of being released; an immediate retry therefore cannot repeat already-finished side effects. The HTTP hook uses an `http.`-prefixed namespace, so HTTP reservations never collide with use-case namespaces. The three identities work together: the `namespace` separates unrelated operations that may receive the same key, the `scope` prevents one actor or tenant from replaying another's result, and the `fingerprint` detects when the same key is reused with different payload data. `createIdempotencyFingerprint(input, { omit: [...] })` creates a stable SHA-256 digest from a canonical representation, omitting the key itself and other request-only metadata, and does not store the original payload. Reserved tags keep non-JSON values such as a BigInt and a same-looking string, or a `Date` and its ISO string, from sharing a fingerprint; ordinary JSON payloads retain their stable representation. `runIdempotently(...)` resolves reservations the same way the hook does: `replay` returns the stored result by default, `inProgress` throws `IdempotencyInProgressError`, and `conflict` throws `IdempotencyConflictError`. Pass `replay: "error"` when an operation should reject duplicates instead of returning the stored result. ## Port wiring Add `idempotency: IdempotencyPort` from `@beignet/core/idempotency` to your `AppPorts`, and use the memory adapter only for tests, local examples, and single-process development: ```typescript // infra/port-wiring.ts import { createMemoryIdempotencyStore } from "@beignet/core/idempotency"; export const initialPorts = definePorts({ idempotency: createMemoryIdempotencyStore(), // other ports... }); ``` Reservation tokens use Web Crypto `randomUUID()` or `getRandomValues()` when available and securely fall back to `node:crypto` on supported Node runtimes. The memory adapter also accepts a `createReservationToken` option for deterministic tests; production adapters should use runtime cryptography rather than predictable ownership tokens. Creation fails clearly if neither secure source is available. Production apps should implement `IdempotencyPort` with a durable store such as SQL or Redis. A durable adapter stores one row per reserved key, and the storage operation behind `reserve(...)` must be atomic — provider packages ship this, so apps rarely write it. The standard Drizzle/libSQL path is `createDrizzleSqliteIdempotencyPort(db)` from `@beignet/provider-db-drizzle/sqlite`, with `createDrizzleSqliteIdempotencySetupStatements()` executed in your app-owned migration flow. Unfinished reservations expire after 300 seconds by default so a crashed worker cannot poison a key forever. Set `reservationTtlSec` when an operation has a different upper bound. `ttlSec` separately controls the completed replay window; omit it only when indefinite replay is intentional. Each successful reservation returns an opaque token that `complete(...)` and `fail(...)` must carry. Providers match that token atomically, so a stale executor cannot complete or release a successor's reservation after expiry. An implementation must reject either mutation when the token, fingerprint, or reservation state no longer matches. It must not silently drop the mutation: `runIdempotently(...)` relies on that rejection to avoid reporting a result as durably replayable when it was not stored. Beignet's memory and Drizzle adapters enforce this contract. The memory adapter throws `IdempotencyMutationError`; each Drizzle dialect exposes its corresponding `Drizzle*IdempotencyMutationError`. If protected work throws and releasing its reservation also fails, `runIdempotently(...)` throws an `AggregateError` that retains both failures and uses the operation error as its `cause`. At the HTTP boundary, Beignet preserves the prepared application error response and best-effort reports the settlement failure through `ctx.ports.errorReporter` when configured. ## Unit-of-work-aware adapters For high-integrity workflows, prefer a SQL adapter that participates in the same Unit of Work as the business write, so the reservation, domain write, audit entry, and completed idempotency result share one transaction and the database commit becomes the single durability boundary — if the workflow throws or the process crashes before commit, the reservation rolls back with everything else. The use case shape changes from "idempotency wraps a transaction" to "the transaction exposes an idempotency port": ```typescript await ctx.ports.uow.transaction((tx) => runIdempotently(tx.idempotency, { namespace: "appointments.import", key: input.importId, scope: { tenantId: ctx.tenant?.id, actorId: ctx.actor?.id, }, fingerprint, ttlSec: 60 * 60 * 24, run: async () => { const result = await tx.appointments.importBatch(input.rows); await tx.audit.record(/* ... */); await events.record(tx.events, appointmentsImported, { importId: input.importId, }); return result; }, }), ); ``` Infra creates the adapter from the transaction client next to the repositories; see [Database and transactions](/database#transactions) for the `createTransactionPorts` wiring. Idempotency prevents duplicate command execution; it does not replace durable message delivery. Use an [outbox](/outbox) when post-commit event or job delivery must be durable, and see [Workflow primitives](/workflows#workflow-primitives) when deciding whether a workflow needs idempotency, an outbox record, a job, a schedule, or a notification. ## Jobs and webhooks Jobs and webhooks should use keys from the system that retries them: a webhook handler keys on the provider event id (for example `stripeEvent.id` under a `webhooks.stripe.*` namespace), and a Beignet job keys on an app-owned job id or logical command id. Keep the idempotency check inside the job handler when the job itself owns the retried work — [Jobs](/jobs#retry-safe-jobs) shows the full handler pattern. [Payments and billing](/payments) applies this to verified Stripe webhooks. ## Testing Use the memory store in tests: ```typescript const idempotency = createMemoryIdempotencyStore(); const first = await runIdempotently(idempotency, { namespace: "posts.create", key: "key_1", fingerprint: "fingerprint_1", run: async () => ({ id: "post_1" }), }); const second = await runIdempotently(idempotency, { namespace: "posts.create", key: "key_1", fingerprint: "fingerprint_1", run: async () => ({ id: "post_2" }), }); expect(second).toEqual(first); ``` HTTP-level behavior is testable through `(await getServer()).api(...)`: send the same request twice with one key and assert the second response carries `idempotency-replayed: true`, then change the body and assert the `409` `IDEMPOTENCY_CONFLICT` envelope. This makes duplicate-submit behavior testable without depending on a database or queue provider. --- # Locks and leases Source: https://www.beignetjs.com/locks Use `LocksPort` when only one process, server, worker, schedule, or task should own a short piece of work at a time. A lock coordinates ownership. A lease coordinates ownership with an expiration. Beignet models the runtime object as a lease so crashed workers, interrupted deploys, and lost processes do not hold ownership forever. ## Acquire a lease ```typescript const result = await ctx.ports.locks.acquire("schedule:daily-report", { ttlMs: 60_000, waitMs: 0, metadata: { schedule: "daily-report", }, }); if (!result.acquired) return; try { await runDailyReport(ctx); } finally { await result.lease.release(); } ``` Use `withLease(...)` when the work fits a callback: ```typescript await ctx.ports.locks.withLease( "outbox:drain", { ttlMs: 30_000, waitMs: 5_000 }, async ({ lease }) => { await drainOutbox(ctx, { fencingToken: lease.fencingToken, }); }, ); ``` `ttlMs` should be long enough for the protected critical section and short enough that a crashed process gives up ownership promptly. Renew the lease when the work is intentionally longer than the original TTL: ```typescript const renewed = await lease.renew({ ttlMs: 60_000 }); if (!renewed) { throw new Error("Lost lease ownership before the job finished."); } ``` When a later serverless invocation resumes work, restore the handle with the persisted owner token and the TTL that a no-argument `renew()` should use: ```typescript const lease = ctx.ports.locks.restore(key, ownerToken, { ttlMs: 60_000, expiresAt: persistedExpiresAt, fencingToken: persistedFencingToken, }); ``` Only pass `expiresAt` and `fencingToken` when they were persisted from the original lease. Beignet leaves omitted metadata unknown rather than fabricating values. A stale handle can neither renew nor release a newer owner's lease. ## When to use locks Use locks for coordination: - prevent overlapping schedule runs - ensure only one worker owns a singleton maintenance job - coordinate outbox drains or queue partitions when the underlying store does not already claim rows safely - prevent cache stampedes while one process recomputes an expensive value - guard short provider operations that should not run concurrently Do not use locks as the only correctness mechanism for durable business invariants. For example, "create one invoice per order" should still use a database unique constraint or idempotency key. A lease can reduce duplicate work; the database remains the source of truth. ## Setup with Redis Install the Redis locks provider: ```bash bun add @beignet/provider-locks-redis ioredis ``` Register it in `server/providers.ts`: ```typescript import { createRedisLocksProvider } from "@beignet/provider-locks-redis"; export const providers = [ createRedisLocksProvider({ prefix: "my-app:locks", }), ]; ``` Set `REDIS_LOCKS_URL` in production when the provider should create its own client. If you already manage a Redis client, pass it with `createRedisLocksProvider({ client })`. Optional env vars include `REDIS_LOCKS_DB`, `REDIS_LOCKS_PREFIX`, `REDIS_LOCKS_CONNECT_TIMEOUT_MS`, `REDIS_LOCKS_MAX_RETRIES_PER_REQUEST`, and `REDIS_LOCKS_CONNECT_MAX_ATTEMPTS`. Environment-backed numeric values use non-negative integer strings. Pass numbers to the matching `db`, `connectTimeoutMs`, and `maxRetriesPerRequest` factory options. Both forms require safe integers. Connection timeouts cannot exceed 2,147,483,647 milliseconds, the JavaScript runtime timer ceiling. The provider contributes `ctx.ports.locks` and `ctx.ports.redisLocks` as an escape hatch with the raw Redis client and configured prefix. Lease owner tokens use Web Crypto `randomUUID()` or `getRandomValues()` when available and securely fall back to `node:crypto` on supported Node runtimes. The direct Redis adapter accepts `createOwnerToken` for deterministic tests; production wiring should use the secure runtime default. ## Correctness and Redis topology The Redis provider targets a single Redis primary. Its acquisition script atomically creates the lease and increments the per-key fencing counter on that primary. When fencing tokens protect correctness, use a dedicated Redis deployment with `maxmemory-policy noeviction`. The counter has no TTL, so an `allkeys-*` eviction policy can delete it and let a later acquisition restart at `1`; `noeviction` makes memory pressure fail the lock operation instead of reusing a token. Verify this setting in deployment configuration. The current two-key Lua acquisition does not support Redis Cluster, and asynchronous primary failover can lose recent lease or counter writes. Treat failover and network partitions as application correctness concerns rather than guarantees supplied by the provider. A fencing token only protects a durable side effect when that resource stores the last accepted token and atomically rejects tokens that are not strictly greater. Otherwise, locks reduce duplicate work but cannot prove that a stale owner will never finish after its lease expires. ## Testing `createTestPorts(...)` includes an in-memory locks port by default: ```typescript const { ports, locks, clock } = createTestPorts(); const result = await ports.locks.acquire("job:sync", { ttlMs: 1_000 }); expect(result.acquired).toBe(true); clock.advance(1_000); if (result.acquired) { await expect(result.lease.renew()).resolves.toBe(false); } expect(locks.leases.has("job:sync")).toBe(false); ``` You can also import the memory adapter directly: ```typescript import { createMemoryLocks } from "@beignet/core/locks"; const locks = createMemoryLocks(); ``` Beignet's provider suite also runs live single-primary Redis contention tests in CI. They exercise independent clients racing one key, monotonic fencing, expired-owner rejection, bounded waiting, and timeout behavior. They do not simulate Redis Cluster, primary failover, or network partitions. ## Related pages - [Schedules](/schedules) for time-triggered workflows. - [Jobs](/jobs) for background work. - [Outbox](/outbox) for durable event and job delivery. - [Idempotency](/idempotency) for retry-safe command handling. --- # Outbox Source: https://www.beignetjs.com/outbox The outbox pattern records side-effect intent in the same database transaction as the business write. A separate worker drains those records after commit and delivers events or jobs with retries. Use an outbox when an event or job must not be lost after the database commit: notifications, integrations, billing syncs, audit streams, search indexing, or other workflow-critical side effects. ```bash bun add @beignet/core ``` ## Why it exists After-commit publishing avoids one class of bug: listeners, jobs, and mail do not see data that later rolls back. It still leaves a different failure window: 1. The database transaction commits. 2. The process starts publishing the event or dispatching the job. 3. The process crashes or the provider call fails. 4. The business record is durable, but the side effect is lost. The outbox closes that gap by writing the event or job to an `outbox_messages` table inside the transaction. Delivery becomes a retryable background workflow. Outbox delivery is **at least once**, not exactly once. If the worker delivers a message and crashes before marking it delivered, the message may be delivered again. Use [Idempotency](/idempotency) inside listeners or job handlers when duplicate delivery would be harmful. Use [Workflow primitives](/workflows#workflow-primitives) to decide whether the side effect should be modeled as an event, job, notification, idempotent command, schedule, or outbox record, and see [side effects after commit](/workflows#side-effects-after-commit) for the rule the outbox makes durable. Payment workflows often pair verified webhooks with outbox-backed entitlement, notification, or integration side effects; see [Payments and billing](/payments). ## Core API Use `@beignet/core/outbox` for typed messages, registries, memory test storage, and the drain worker: ```typescript import { createMemoryOutbox, defineOutboxRegistry, drainOutbox, } from "@beignet/core/outbox"; ``` The app-facing delivery port is intentionally small: ```typescript import type { OutboxAdminPort, OutboxPort } from "@beignet/core/outbox"; export type AppPorts = { outbox: OutboxPort; outboxAdmin: OutboxAdminPort; }; ``` Production adapters implement: | Operation | Purpose | | --- | --- | | `enqueue(...)` | Store a pending event or job | | `claimBatch(...)` | Atomically claim available messages with a lease | | `markDelivered(...)` | Ack a claimed message with its claim token | | `markFailed(...)` | Retry or dead-letter a claimed message | Ack and fail operations require the current `claimToken`. This prevents an old worker from acking a message after its lease expired and another worker claimed it. Keep `OutboxAdminPort` on operational contexts rather than transaction-scoped use-case ports. It supports: | Operation | Purpose | | --- | --- | | `listMessages(...)` | Inspect pending, claimed, delivered, or dead-lettered rows | | `countMessages(...)` | Count rows before cleanup or alerting | | `getMessage(...)` | Inspect one payload, attempts, and last error | | `requeueMessage(...)` | Return a dead-lettered row to `pending` | | `purgeDeadLettered(...)` | Delete terminal dead-letter rows after review | | `pruneDelivered(...)` | Delete delivered rows older than a retention cutoff | ## Transaction-scoped recording Keep the existing Beignet event API in use cases. The outbox should sit behind `tx.events`, not introduce a parallel use-case workflow: ```typescript const publishPostUseCase = useCase .command("posts.publish") .input(PublishPostInput) .output(PostOutput) .emits([PostPublished]) .run(async ({ ctx, input, events }) => ctx.ports.uow.transaction(async (tx) => { const post = await tx.posts.publish(input.slug); await events.record(tx.events, PostPublished, { postId: post.id, slug: post.slug, publishedAt: post.publishedAt, }); return post; }), ); ``` Wire `tx.events` with `createOutboxEventRecorder(...)` in production: ```typescript import { createOutboxEventRecorder, createOutboxJobDispatcher, } from "@beignet/core/outbox"; import { createDrizzleSqliteAuditLogPort, createDrizzleSqliteOutboxPort, createDrizzleSqliteUnitOfWork, } from "@beignet/provider-db-drizzle/sqlite"; uow: createDrizzleSqliteUnitOfWork({ db: ports.db.drizzle, createTransactionPorts: (tx) => { const outbox = createDrizzleSqliteOutboxPort(tx); return { posts: createPostRepository(tx), audit: createDrizzleSqliteAuditLogPort(tx), events: createOutboxEventRecorder(outbox, { tracing: ports.tracing, }), jobs: createOutboxJobDispatcher(outbox, { tracing: ports.tracing, }), outbox, }; }, }); ``` This keeps `.emits(...)` and `events.record(...)` enforcement intact while moving durability into infrastructure. The returned recorder exposes only `record(...)`: it writes immediately through the transaction-scoped outbox and does not pretend to be an inspectable buffer. Use `createDomainEventRecorder()` when tests or non-durable Unit of Work adapters need explicit `entries()`, `clear()`, and `flush()` behavior. Passing the installed tracing port captures the current request or workflow span in the outbox row. Existing rows without trace metadata remain valid. ## Transactional job enqueueing Use `createOutboxJobDispatcher(...)` when a use case should enqueue a job inside the same transaction as the business write. Wire it as the transaction-scoped `jobs` port the same way `createOutboxEventRecorder(...)` backs `tx.events` above, then use the normal job dispatcher shape: ```typescript await ctx.ports.uow.transaction(async (tx) => { const appointment = await tx.appointments.create(input); await tx.jobs.dispatch(SendAppointmentReminderJob, { appointmentId: appointment.id, }); return appointment; }); ``` Use this for direct transactional job enqueueing. For event-driven workflows, prefer recording an event and letting a listener enqueue the job during outbox drain. Drizzle-backed outboxes store the carrier in nullable `trace_context_json`. Existing applications must add that column in their next migration before deploying the updated adapter: ```sql -- SQLite and Postgres ALTER TABLE outbox_messages ADD COLUMN trace_context_json text; -- MySQL ALTER TABLE outbox_messages ADD COLUMN trace_context_json longtext; ``` ## Registry and draining The worker needs an explicit registry of typed events and jobs it may deliver: ```bash beignet make outbox ``` `beignet make event` and `beignet make job` also create the outbox registry and bounded drain route on first use. The generated `server/outbox.ts` has this shape: ```typescript import { defineOutboxRegistry } from "@beignet/core/outbox"; import { createServiceActor } from "@beignet/core/ports"; import type { AppContext } from "@/app-context"; import { postEvents } from "@/features/posts/domain/events"; import { postJobs } from "@/features/posts/jobs"; import { getServer } from "@/server"; export const outboxRegistry = defineOutboxRegistry({ events: postEvents, jobs: postJobs, }); export async function createOutboxDrainContext(): Promise { const server = await getServer(); return server.createServiceContext({ actor: createServiceActor("beignet-outbox"), }); } export async function stopOutboxDrainContext(): Promise { const server = await getServer(); await server.stop(); } ``` `server.createServiceContext(...)` builds a [service context](/workflows#service-contexts) for the drain worker. After `server/outbox.ts` exists, `beignet make event` and `beignet make job` append new feature registries to it, and `beignet doctor` warns about feature events and jobs the registry cannot deliver; `beignet doctor --fix` registers them. Registration and delivery wiring are separate. Events in the registry require `ctx.ports.eventBus`; jobs require `ctx.ports.jobs`. `beignet make job` declares and defers `jobs` and installs an app-owned inline dispatcher when no dispatcher is already wired, while preserving an existing direct binding or provider such as Inngest. A later Inngest or BullMQ preset replaces only that marked generated fallback and rejects unmarked custom inline wiring. `beignet doctor` reports `BEIGNET_OUTBOX_JOB_DISPATCHER_MISSING` when the registry contains jobs but the configured ports and port-wiring files do not declare and bind or defer that port. Keep the `jobs` key explicit in `definePorts(...)`; when custom spreads or helper calls prevent doctor from verifying required port wiring, it reports the uncertainty, and `beignet make job` stops instead of replacing a custom dispatcher it cannot verify. Drain messages from a cron route, worker process, queue consumer, or scheduled task. In Next.js apps, prefer `createOutboxDrainRoute(...)` so the outbox runs as a bounded serverless invocation instead of a provider startup loop: ```typescript // app/api/cron/outbox/drain/route.ts import { createOutboxDrainRoute } from "@beignet/next"; import { env } from "@/lib/env"; import { getServer } from "@/server"; import { outboxRegistry } from "@/server/outbox"; export const runtime = "nodejs"; export const { GET, POST } = createOutboxDrainRoute({ server: getServer, registry: outboxRegistry, secret: env.CRON_SECRET, batchSize: 100, }); ``` The route verifies `CRON_SECRET` before resolving the server or assembling app context, then runs authenticated requests through the normal raw-route pipeline. ### Push-assisted polling in Next.js On Next.js 15.1 or newer, use `after()` to request one bounded drain after a successful Unit of Work transaction. This removes the one-minute polling floor for newly committed messages without running an idle poller. Keep the Next-specific wrapper in `server/providers.ts`, after the database provider that installs `uow`: ```typescript import { createObservedUnitOfWork } from "@beignet/core/ports"; import { createProvider } from "@beignet/core/providers"; import { createNextOutboxDrainTrigger } from "@beignet/next"; import { after } from "next/server"; import type { AppContext } from "@/app-context"; import type { AppPorts } from "@/ports"; import type { AppServiceContextInput } from "./context"; const outboxDrainProvider = createProvider< Pick, AppContext, AppServiceContextInput >()({ name: "outbox-drain-trigger", setup({ ports, createServiceContext }): { ports: Pick } { const trigger: () => void = createNextOutboxDrainTrigger({ defer: after, createContext: () => createServiceContext(undefined), registry: async () => (await import("./outbox")).outboxRegistry, batchSize: 100, }); return { ports: { uow: createObservedUnitOfWork({ unitOfWork: ports.uow, afterCommit: trigger, }), }, }; }, }); ``` Register `outboxDrainProvider` after the database provider. This keeps Next-specific server composition out of `infra/db/provider.ts`; the lazy registry import avoids the cycle between `server/providers.ts` and `server/outbox.ts`. Pass the service-context input your app requires; the generated starter accepts `undefined`, while tenant-aware apps may provide an explicit service actor or tenant. This model is **push-assisted polling**, not durable execution. `after()` may be missed during a crash or deployment, performs only one batch, and does not wait for a message's future `availableAt`. Keep `createOutboxDrainRoute(...)` and schedule it as a recovery sweep. About every 15 minutes is a useful default when immediate first delivery comes from `after()`; shorten it when the app's retry-latency requirement demands it. Repeated triggers coalesce while a deferred drain is scheduled or running. This prevents transactions started by an outbox handler from recursively scheduling more drain callbacks; the recovery sweep handles messages left beyond the bounded batch. Delayed messages and scheduled retries still depend on the recovery cron or a durable queue scheduler. Use a durable jobs provider when retries require a guaranteed low-latency wake-up. Apps on older Next.js versions remain valid with cron-only draining. For non-Next runtimes, call `drainOutbox(...)` from the host's bounded background entrypoint. Do not start `setInterval` polling from provider lifecycle hooks in serverless apps. For a local, CI, or worker-hosted drain, use the same `server/outbox.ts` module with the CLI: ```bash beignet outbox drain --batch-size 100 ``` The CLI loads `outboxRegistry`, creates the app context through `createOutboxDrainContext(...)`, drains one batch, records instrumentation, then calls `stopOutboxDrainContext(...)` when present. See [Runtime recipes](/runtime-recipes#outbox-drains) for the difference between cron routes, worker-hosted drains, and command-based drains. `drainOutbox(...)` first validates that every transport required by the registry is available, then claims messages, validates payloads, publishes events through `eventBus`, dispatches jobs through `jobs`, and marks each message delivered. Missing transport wiring therefore fails before a claim and does not consume delivery attempts. Failed deliveries are retried with backoff until `maxAttempts`, then dead-lettered. Outbox rows and delivery adapters preserve the original JSON-safe payload after producer validation. The receiving listener or job worker performs the handler-facing parse, so transforming schemas are not applied repeatedly as a message crosses durable boundaries. Delivery and settlement failures are isolated per claimed message. If `markFailed(...)` itself loses the claim or encounters a transient store error, the drain records an `outbox.settlement.failed` instrumentation event and continues processing the rest of the claimed batch; it does not report the message as retried or dead-lettered when the final state is unknown. When you pass `ctx.ports.devtools` or another instrumentation port to `drainOutbox(...)`, Beignet records first-class `outbox` events for delivered, retried, and dead-lettered messages, including attempt counts, retry timing, and a redacted error summary. `createOutboxDrainRoute(...)` passes the drain request's `requestId` and `traceId` into those rows so the devtools request view can expand into the messages delivered by that cron invocation. When the drain context includes `ports.errorReporter`, the Next route and CLI runner report dead-lettered messages and settlement failures after the final state is known. Drain-level claim/infrastructure failures are also reported. Scheduled retries remain instrumentation events and do not create incidents. Direct `drainOutbox(...)` callers can use `onDeadLetter` and `onSettlementError` to apply the same ownership policy. Outbox delivery uses the same [retry vocabulary](/jobs#retry-vocabulary) as jobs. A retried message is marked pending again with a future `availableAt` computed from the backoff, `maxAttempts` caps total delivery attempts, and a dead-lettered message is in the terminal outbox state and is no longer retried automatically. For job messages, `enqueueJob(...)` and `createOutboxJobDispatcher(...)` use the job definition's [retry policy](/jobs#retry-policy) by default, and the drain owns execution retries: when delivery goes through the inline dispatcher, the drain detects it and runs the handler exactly once per pass, so the job's policy is applied by outbox rescheduling rather than stacked in-process retries. A configured inline-dispatcher `onError` observer still runs, but it cannot swallow that single-attempt failure: the drain marks the message failed and retries or dead-letters it. Durable providers are unaffected — for them dispatch is an enqueue and the queue owns execution. Customize the retry delay for outbox delivery when the worker needs an override: ```typescript await drainOutbox({ outbox, registry, eventBus, jobs, instrumentation: ports, retryDelayMs: ({ message }) => Math.min(60_000, 1000 * message.attempts), }); ``` ## Dead-letter recovery and cleanup `server/outbox.ts` can use the same service context for drains and admin commands when that context exposes `ports.outboxAdmin`. The Drizzle providers `createDrizzlePostgresOutboxAdminPort(...)`, and `createDrizzleMysqlOutboxAdminPort(...)` for this root maintenance port. Inspect dead-lettered rows: ```bash beignet outbox list --status deadLettered beignet outbox show ``` Requeue a reviewed dead-lettered message: ```bash beignet outbox requeue --reset-attempts ``` Clean up terminal rows only after review: ```bash beignet outbox purge --before 2026-01-01T00:00:00.000Z --dry-run beignet outbox purge --before 2026-01-01T00:00:00.000Z ``` Prune delivered rows by retention cutoff: ```bash beignet outbox prune --before 2026-01-01T00:00:00.000Z --dry-run beignet outbox prune --before 2026-01-01T00:00:00.000Z ``` `purge` targets only `deadLettered` rows using `updatedAt` as the cutoff. `prune` targets only `delivered` rows using `deliveredAt` as the cutoff. Both commands support `--limit` for bounded maintenance passes, delete the oldest eligible rows first, and support `--json` for runbooks or dashboards. ## Drizzle SQLite `@beignet/provider-db-drizzle` includes a durable outbox adapter on its `/sqlite` subpath: ```typescript bun add @beignet/provider-db-drizzle ``` ```typescript import { createDrizzleSqliteOutboxAdminPort, createDrizzleSqliteOutboxPort, createDrizzleSqliteOutboxSetupStatements, } from "@beignet/provider-db-drizzle/sqlite"; const outbox = createDrizzleSqliteOutboxPort(db); const outboxAdmin = createDrizzleSqliteOutboxAdminPort(db); ``` Beignet does not hide migrations. Add the setup statements to your app-owned migration/bootstrap flow: ```typescript for (const statement of createDrizzleSqliteOutboxSetupStatements()) { await client.execute(statement); } ``` The default table is `outbox_messages`; pass `{ tableName: "app_outbox_messages" }` to both the setup statements and the port to override it. ## Testing Use the memory adapter in use-case tests: ```typescript import { createMemoryOutbox, createOutboxEventRecorder, } from "@beignet/core/outbox"; const outbox = createMemoryOutbox(); const uow = createNoopUnitOfWork(() => ({ posts, events: createOutboxEventRecorder(outbox), outbox, })); ``` Then assert pending messages or drain them: ```typescript expect(outbox.messages).toMatchObject([ { kind: "event", name: "post.published", status: "pending", }, ]); ``` Use direct in-memory event recorders and inline jobs when durability is not the behavior under test. ## When not to use it Do not force every side effect through the outbox. Use direct after-commit event publishing or inline jobs for low-stakes local workflows, tests, and single-process development. Use the outbox when losing the side effect would create user-visible, financial, compliance, or workflow correctness issues. --- # Notifications Source: https://www.beignetjs.com/notifications Notifications represent user-facing communication intent. Use them when a use case needs to tell a person or team that something happened, but should not care whether delivery uses email today, SMS later, push later, or an in-app inbox. Jobs, events, and outbox still own reliable background execution. Notifications sit above them and give communication a stable application API. ## Define a notification Keep feature-owned notifications under the feature that owns the business event: ```txt features/ appointments/ notifications/ index.ts ``` Create the app-bound `defineNotification` builder once in `lib/notifications.ts` with `createNotifications()` (see [app-bound builders](/workflows#app-bound-builders)), then define notifications in feature files: ```ts import { defineMailNotificationChannel } from "@beignet/core/notifications"; import { z } from "zod"; import { defineNotification } from "@/lib/notifications"; export const AppointmentReminderNotification = defineNotification("appointments.reminder", { payload: z.object({ appointmentId: z.string().uuid(), patientEmail: z.string().email().optional(), startsAt: z.string().datetime(), }), channels: { email: defineMailNotificationChannel(({ payload }) => { if (!payload.patientEmail) return undefined; return { to: payload.patientEmail, subject: "Upcoming appointment", text: `Your appointment starts at ${payload.startsAt}.`, }; }), }, }); ``` `defineMailNotificationChannel(...)` uses `ctx.ports.mailer`, so the app can swap Resend, SMTP, memory mail, or another mail adapter without changing the notification definition. ## Send from a use case Use cases should request the communication intent, not a vendor-specific delivery mechanism: ```ts await ctx.ports.notifications.send(AppointmentReminderNotification, { appointmentId: appointment.id, patientEmail: appointment.patientEmail, startsAt: appointment.startsAt.toISOString(), }); ``` This keeps application code focused on "notify the patient" instead of "enqueue this specific email job." ## Wire the port Use `createInlineNotificationsProvider(...)` as the dev-default provider for the `notifications` port. It installs an inline dispatcher whose channel handlers receive an app service context built lazily through the server context blueprint on each send: ```ts // server/providers.ts import { createInlineNotificationsProvider } from "@beignet/core/notifications"; export const providers = [createInlineNotificationsProvider()] as const; ``` `beignet make notification` does this wiring for you, adding the `notifications` and `mailer` ports with dev-default providers and skipping keys the app already wires; see [CLI](/cli) for the generator details. Replace the memory mailer with a real mail provider when the app should deliver email; the notification definitions do not change. When the app wires ports by hand in an app-local provider, use `createInlineNotificationDispatcher(...)` directly with a `ctx` factory and an optional `instrumentation` port. The dispatcher validates payloads before invoking channel handlers and emits devtools events when instrumentation is available. ## Channel outcomes and failure isolation Every selected channel runs in declaration order. A failed channel produces a `failed` result without preventing later channels from running: ```ts const result = await ctx.ports.notifications.send( AppointmentReminderNotification, payload, ); for (const channel of result.results) { if (channel.status === "failed") { ctx.ports.logger.error("Notification channel failed", { channel: channel.channel, reason: channel.reason, }); } } ``` Channel outcomes are `queued`, `sent`, `skipped`, or `failed`. Inline delivery reports complete results by default. When a caller must reject after any channel failure, configure its dispatcher with `failureMode: "throw"`. `NotificationDeliveryError` is thrown only after every selected channel has run and carries the complete result. Beignet records `notification.channel.queued`, `.sent`, `.skipped`, and `.failed` instrumentation events in addition to aggregate enqueue/send events. ## Queued delivery Use the first-party delivery job when channels should run through the app's existing jobs or outbox infrastructure. Keep the registry and job in `server/notifications.ts`: ```ts import { defineNotificationDeliveryJob, defineNotificationRegistry, } from "@beignet/core/notifications"; import type { AppContext } from "@/app-context"; import { AppointmentReminderNotification } from "@/features/appointments/notifications"; export const notificationRegistry = defineNotificationRegistry([ AppointmentReminderNotification, ]); export const DeliverNotificationJob = defineNotificationDeliveryJob({ registry: notificationRegistry, }); export const notificationJobs = [DeliverNotificationJob] as const; ``` Add `notificationJobs` to every job or outbox registry that can receive queued notifications. The delivery job defaults to three attempts with exponential backoff. Each channel is a separate job, so retrying email cannot duplicate an in-app notification that already succeeded. Install the queued provider after the base providers inside the server loader. Composing it there avoids a type cycle in apps whose `AppContext` infers ports from the base provider tuple: ```ts import { createQueuedNotificationsProvider } from "@beignet/core/notifications"; export const getServer = createNextServerLoader(async () => { const { providers } = await import("./providers"); const { DeliverNotificationJob } = await import("./notifications"); return createNextServer({ // ... providers: [ ...providers, createQueuedNotificationsProvider({ deliveryJob: DeliverNotificationJob, }), ], }); }); ``` The provider uses the installed `jobs` port. That may be an inline dispatcher, BullMQ, Inngest, or an app-owned outbox-backed dispatcher. Queueing is atomic with application writes only when code inside the transaction constructs a queued notification dispatcher against that transaction's `createOutboxJobDispatcher(...)`. The globally installed provider cannot make request-side notification sends part of an application transaction by itself. ## Preferences and opt-outs Preferences stay app-owned because recipient identity and preference storage vary by product. Implement `NotificationPreferencesPort` and pass it to the inline dispatcher or delivery job: ```ts // infra/notification-preferences.ts import type { NotificationPreferencesPort } from "@beignet/core/notifications"; import type { AppContext } from "@/app-context"; export const notificationPreferences: NotificationPreferencesPort = { async evaluate({ notification, channel, payload, ctx }) { const enabled = await ctx.ports.notificationSettings.isEnabled({ notificationName: notification.name, channel, payload, }); return enabled ? { deliver: true } : { deliver: false, reason: "Disabled by the recipient" }; }, }; ``` Pass the preference adapter to the delivery job in `server/notifications.ts`: ```ts export const DeliverNotificationJob = defineNotificationDeliveryJob({ registry: notificationRegistry, preferences: notificationPreferences, }); ``` Preferences are evaluated immediately before delivery, including on job retries, so queued notifications honor current settings. A denied decision is `skipped`. A preferences lookup failure is `failed` and the channel handler is not invoked; durable jobs can then retry without sending through an uncertain opt-out state. ## Other channels Email is the first built-in channel helper because Beignet already has a `MailerPort`. SMS, push, and in-app notifications can use app-owned channel handlers: ```ts export const AppointmentReminderNotification = defineNotification("appointments.reminder", { payload: z.object({ phoneNumber: z.string().optional(), }), channels: { sms: async ({ payload, ctx, channel }) => { if (!payload.phoneNumber) { return { channel, status: "skipped", reason: "No phone number" }; } const result = await ctx.ports.sms.send({ to: payload.phoneNumber, body: "Your appointment is coming up.", }); return { channel, status: "sent", id: result.id, provider: result.provider, }; }, }, }); ``` This keeps the framework primitive stable while leaving vendor-specific preferences, templates, and provider choices in app code. ## In-app inbox notifications An in-app inbox is Beignet's paved first-party non-email pattern: notifications land in a per-user inbox table and render inside the product with an unread count. The storage model stays app-owned because recipients, tenancy, read models, and retention vary by product. Scaffold the whole slice for a Drizzle-backed app: ```bash beignet make inbox beignet db generate beignet db migrate beignet db status ``` The generator creates a `features/inbox` feature — an `inbox_notifications` table and repository behind `ctx.ports.inbox`, cursor-paginated list, unread-count, mark-read, and mark-all-read contracts and use cases — plus `defineInboxNotificationChannel`, a channel factory that writes inbox rows from any notification. Deliver an existing notification into the inbox by adding the channel: ```ts import { defineInboxNotificationChannel } from "@/features/inbox/channel"; export const PostPublishedNotification = defineNotification( "posts.published", { payload: z.object({ userId: z.string(), postTitle: z.string(), }), channels: { inApp: defineInboxNotificationChannel(({ payload }) => ({ userId: payload.userId, type: "post.published", title: `"${payload.postTitle}" was published`, })), }, }, ); ``` The channel returns `skipped` when the mapping resolves no recipient, so a notification can safely mix email and in-app delivery. The inbox is personal — rows are scoped to the recipient's user id — and the notification `type` is an open string, so any feature can deliver its own kinds without editing the inbox schema. Apps with the frontend shell also get an `/inbox` page and an unread badge component. ## Test notification intent Use `createMemoryNotificationPort(...)` when a test only needs to assert that a use case requested a notification: ```ts import { createMemoryNotificationPort } from "@beignet/core/notifications"; const notifications = createMemoryNotificationPort({ id: () => "notification_1", }); await useCase.run({ ctx: { ...ctx, ports: { ...ctx.ports, notifications, }, }, input, }); expect(notifications.deliveries).toEqual([ expect.objectContaining({ notificationName: "appointments.reminder", }), ]); ``` Use the inline dispatcher when a test should also verify channel rendering or mailer behavior. ## Relationship to jobs, events, and outbox Notifications represent communication intent and channel delivery; they do not replace durable workflow primitives. [Workflow primitives](/workflows#workflow-primitives) gives the full decision guide for when to use a notification versus a job, event, schedule, command, idempotency key, or outbox record. A common production flow is: ```txt Use case -> record domain event in transaction -> outbox drains event after commit -> listener queues a notification -> one independently retryable job delivers each selected channel ``` --- # Tasks Source: https://www.beignetjs.com/tasks Tasks are operational entrypoints: backfills, repairs, imports, exports, and release maintenance that an operator runs on purpose. A task pairs a stable name with a validated input schema and a handler that receives the same app-owned context as the rest of the application, so operational work goes through ports, audit, and logging instead of one-off scripts. Use a job when the app decides to run work in the background, a schedule when time triggers the work, and a task when a person, CI job, or release pipeline decides when it runs. See the [background work overview](/workflows) for choosing between primitives. ## Define a task Create the app-bound builder once in `lib/tasks.ts`: ```typescript // lib/tasks.ts import { createTasks } from "@beignet/core/tasks"; import type { AppContext } from "@/app-context"; export const { defineTask } = createTasks(); ``` Then define feature-owned tasks under `features//tasks/`: ```typescript // features/issues/tasks/backfill-search.ts import { z } from "zod"; import { defineTask } from "@/lib/tasks"; export const BackfillSearchInputSchema = z.object({ dryRun: z.boolean().default(true), }); export const backfillSearchTask = defineTask("issues.backfill-search", { input: BackfillSearchInputSchema, description: "Backfill the issues search index.", async handle({ input, ctx }) { ctx.ports.logger.info("Task handled", { taskName: "issues.backfill-search", dryRun: input.dryRun, }); return { dryRun: input.dryRun }; }, }); ``` Task handlers should call use cases and ports rather than reaching into infra directly. Inputs are parsed with the task's schema before the handler runs, so a typo'd flag fails with a `TaskValidationError` instead of mutating data. The generator creates the task file, the `lib/tasks.ts` builder when it is missing, and the registry entry in one step: ```bash beignet make task issues.backfill-search ``` ## Register tasks `server/tasks.ts` owns central task registration and the operational context used by the CLI: ```typescript // server/tasks.ts import { createServiceActor } from "@beignet/core/ports"; import { defineTasks, type TaskRunContextArgs } from "@beignet/core/tasks"; import type { AppContext } from "@/app-context"; import { issueTasks } from "@/features/issues/tasks"; import { getServer } from "./index"; export const tasks = defineTasks([...issueTasks] as const); export async function createTaskContext( args: TaskRunContextArgs, ): Promise { const server = await getServer(); return server.createServiceContext({ actor: createServiceActor("beignet-cli"), tenantId: args.tenant ? await resolveTenantId(server, args.tenant) : undefined, }); } async function resolveTenantId( server: Awaited>, tenant: string, ): Promise { const workspace = (await server.ports.workspaces.findBySlug(tenant)) ?? (await server.ports.workspaces.findById(tenant)); if (!workspace) { throw new Error(`Unknown tenant "${tenant}". Pass a workspace slug or id.`); } return workspace.id; } export async function stopTaskContext(): Promise { const server = await getServer(); await server.stop(); } ``` The `tasks` export is the registry the CLI loads. `createTaskContext` receives `TaskRunContextArgs` — the task definition, task name, schema-parsed input, and the optional `tenant` value from `--tenant` — and builds the app context for a run: a service actor plus the app's ports, providers, audit, and devtools wiring. The app decides how to resolve the tenant string to a tenant id, for example by looking up a slug. `stopTaskContext` receives the same arguments and shuts providers down after the run finishes. `beignet make task` keeps this registry updated for you. ## Run a task ```bash beignet task run issues.backfill-search --tenant acme --input '{"dryRun":true}' ``` The CLI loads `server/tasks.ts` (override with `--module`), parses `--input` as JSON against the task's schema, creates the context, runs the handler, and prints the task name, duration, and output. `--tenant` scopes the run to a tenant without task input schemas having to declare a tenant field; it flows to `createTaskContext` separately from `--input`. Pass `--json` for machine-readable output in CI or release jobs. When `createTaskContext(...)` returns `ports.errorReporter`, the CLI reports a terminal task failure and performs a bounded flush before `stopTaskContext(...)`. Direct `runTask(...)` calls remain caller-owned and can use `tryReportException(...)` at their outer execution boundary. ## Testing Run the task definition directly with `runTask` and a test context: ```typescript import { runTask } from "@beignet/core/tasks"; import { createTestContext } from "@beignet/core/testing"; import type { AppContext } from "@/app-context"; import { backfillSearchTask } from "@/features/issues/tasks"; const makeContext = createTestContext(); const fixture = makeContext(); const output = await runTask(backfillSearchTask, { input: { dryRun: true }, ctx: fixture.ctx, }); expect(output.dryRun).toBe(true); ``` Memory ports make assertions cheap: capture logs and audit entries in memory, then assert the task recorded what it did. Keep these tests in `features//tests/`. ## Production Tasks run from bounded entrypoints — a local shell, CI job, release job, or admin worker — so they need no exposed HTTP route. See [Runtime recipes](/runtime-recipes#one-off-tasks) for task runtime entrypoints and operational auth. ## Related pages - [Background work overview](/workflows) for choosing between primitives. - [Jobs](/jobs) for background work the app dispatches itself. - [Schedules](/schedules) for time-triggered work. - [Audit and activity logging](/audit) for recording what operational work did. - [CLI](/cli) for `beignet make task` and `beignet task run` options. --- # OpenTelemetry Source: https://www.beignetjs.com/observability Beignet separates local inspection from production telemetry: - `@beignet/devtools` stores a detailed local timeline for development. - `@beignet/provider-tracing-opentelemetry` creates production spans and low-cardinality metrics. - Your app owns the OpenTelemetry SDK, exporter, sampler, resource metadata, and shutdown or serverless flush behavior. The OpenTelemetry provider is optional. Without it, Beignet keeps request IDs and W3C trace correlation for logs and devtools without adding an OpenTelemetry runtime dependency. ## Install ```bash bun add @beignet/provider-tracing-opentelemetry @opentelemetry/api ``` Install and register the SDK appropriate for the deployment. For a Next.js app on Vercel: ```bash bun add @vercel/otel ``` ```typescript title="lib/telemetry.ts" import { registerOTel } from "@vercel/otel"; const state = globalThis as typeof globalThis & { __appTelemetryRegistered?: boolean; }; export function registerTelemetry() { if (state.__appTelemetryRegistered) return; registerOTel({ serviceName: "my-app" }); state.__appTelemetryRegistered = true; } ``` ```typescript title="instrumentation.ts" import { registerTelemetry } from "@/lib/telemetry"; export function register() { registerTelemetry(); } ``` The root `instrumentation.ts` bootstraps the Next.js process. Standalone job workers, task and schedule commands, outbox drains, and scripts must call the same idempotent `registerTelemetry()` function before they initialize their Beignet server. Installing the Beignet provider without registering an SDK is safe, but OpenTelemetry's global tracer and meter remain no-ops. Beignet does not start an SDK, choose an exporter, or create a process shutdown hook. ## Register the provider Place the OpenTelemetry provider after devtools and before providers whose external work should contribute operation metrics and span events: ```typescript title="server/providers.ts" import { createDevtoolsProvider } from "@beignet/devtools"; import { createOpenTelemetryTracingProvider } from "@beignet/provider-tracing-opentelemetry"; import { createResendMailProvider } from "@beignet/provider-mail-resend"; import { registerTelemetry } from "@/lib/telemetry"; registerTelemetry(); export const providers = [ createDevtoolsProvider(), createOpenTelemetryTracingProvider(), createResendMailProvider(), ] as const; ``` The provider contributes `ports.tracing` and replaces `ports.instrumentation` with a composed sink. OpenTelemetry receives later provider events while the earlier devtools sink continues to receive its enabled watchers. Devtools is optional. In production, the provider can be installed without a devtools provider. ## Active spans When `ports.tracing` is installed, Beignet wraps these execution boundaries: | Boundary | Span name | Kind | | --- | --- | --- | | HTTP request | `beignet.request ` | server or internal | | Use case | `beignet.use_case ` | internal | | Listener | `beignet.listener ` | consumer | | Job handler | `beignet.job ` | consumer | | Outbox delivery | `beignet.outbox deliver ` | consumer | | Schedule handler | `beignet.schedule ` | consumer | | Task handler | `beignet.task ` | internal | The current span remains active across asynchronous handler work. Provider SDKs that use the OpenTelemetry API can therefore create children of the correct request, use case, listener, or workflow span without importing a Beignet-specific global. Incoming `traceparent` and `tracestate` headers continue an HTTP trace. If a host adapter already established an active OpenTelemetry request span, Beignet's request span becomes its child instead of creating a competing root. ## Metrics The provider records: - `beignet.request.duration` - `beignet.use_case.duration` - `beignet.listener.duration` - `beignet.job.duration` - `beignet.outbox.delivery.duration` - `beignet.schedule.duration` - `beignet.task.duration` - `beignet.operation.errors` - `beignet.provider.operation.count` Durations use milliseconds. Attributes are limited to stable operation names, types, outcomes, attempts, and provider names. Beignet does not attach request bodies, payloads, tenant IDs, user IDs, message IDs, error messages, or stacks to these metrics. Custom `TraceOperation` values may attach rich span-only `attributes`. Add only bounded dimensions to `metricAttributes`; never copy request, actor, tenant, or payload values into metric labels. An SDK that only configures tracing may leave the global OpenTelemetry meter as a no-op. Register a meter provider to export metrics, inject a configured `meter`, or set `meter: false` when the app intentionally exports traces only. ## Errors and Sentry Failed spans set error status and `error.type`. Exception messages and stacks remain out of spans by default. Enable `recordExceptions` only after reviewing the exporter and backend redaction policy: ```typescript createOpenTelemetryTracingProvider({ recordExceptions: true, }); ``` Keep exception capture in `ErrorReporterPort`. When Sentry and another OpenTelemetry SDK are both installed, prevent Sentry from registering a second pipeline: ```typescript createSentryErrorReportingProvider({ init: { skipOpenTelemetrySetup: true }, }); ``` Beignet does not export OpenTelemetry logs. Continue using `LoggerPort` for structured runtime logs and `AuditLogPort` for durable business activity. ## Durable propagation Beignet carries a versioned, vendor-neutral `TraceCarrier` through its durable and distributed boundaries: - outbox rows persist the carrier in nullable `trace_context_json` storage; - outbox drains continue the producer trace in a delivery span and forward the delivery context to the downstream event bus or job dispatcher; - Redis event envelopes continue listener spans in subscriber processes; - BullMQ job data and Inngest event data carry the context into job spans. Existing outbox rows, Redis messages, and provider jobs without a carrier keep working and start a new trace. Unknown carrier versions, malformed `traceparent`, and invalid `tracestate` values are ignored rather than failing message delivery. Payloads are never added to trace metadata. Direct adapters capture the active context when their `instrumentation` or `tracing` option exposes `ports.tracing`. The optional third argument on event publish and job dispatch also lets delivery layers forward a carrier without a global runtime: ```typescript await ctx.ports.jobs.dispatch(job, payload, { trace }); await ctx.ports.eventBus.publish(event, payload, { trace }); ``` Beignet does not propagate OpenTelemetry baggage. Stable job, event, message, and request IDs remain useful application-level correlation fields. ## Serverless behavior The Beignet provider performs no network setup and starts no workers, timers, or background loops. It creates spans and records metrics through the SDK that the app already registered. The host integration or app-owned SDK remains responsible for batching, exporting, and flushing telemetry within the platform's lifecycle. Register the SDK once in every process type; Next.js does not invoke its root `instrumentation.ts` for standalone workers or CLI commands. See [Request lifecycle](/request-lifecycle) for HTTP correlation, [Devtools](/devtools) for the local event timeline, [Logging](/logging) for diagnostic logs, and [Error reporting](/error-reporting) for exception capture. --- # Logging Source: https://www.beignetjs.com/logging Logging belongs at the application and infrastructure boundary. Use structured logs for request flow, use case milestones, provider diagnostics, and failures that need production visibility. Beignet keeps logging behind a port so use cases can emit useful context without depending on a specific logger. Use [Audit and activity logging](/audit) for durable business activity records that need actor, tenant, request, and resource history. Use `LoggerPort` for diagnostic runtime logs. Use [Error reporting and alerting](/error-reporting) when exceptions need to be sent to an external system or turned into operator alerts. Use [Privacy lifecycle](/privacy-lifecycle) to decide retention, redaction, and what should never leave app-owned storage. ## Setup Use the Pino provider for production logging: ```bash bun add @beignet/core @beignet/provider-logger-pino pino ``` ```typescript import { createNextServer, createNextServerLoader } from "@beignet/next"; import { createPinoLoggerProvider } from "@beignet/provider-logger-pino"; import { initialPorts } from "@/infra/port-wiring"; export const getServer = createNextServerLoader(() => createNextServer({ ports: initialPorts, providers: [createPinoLoggerProvider()], context: ({ ports, req }) => ({ requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(), ports, }), }), ); ``` `createPinoLoggerProvider(options)` configures level, format, service, and timestamp in code; options override env-derived values. The provider reads `LOG_LEVEL`, `LOG_FORMAT`, `LOG_SERVICE`, and `LOG_TIMESTAMP` from environment variables. Use `LOG_FORMAT=json` in production. Use `LOG_FORMAT=pretty` locally when `pino-pretty` is installed. The provider flushes its Pino logger when `server.stop()` runs so buffered destinations can drain before shutdown. When Beignet creates a worker-backed pretty transport, it closes that transport during the same lifecycle step. Structured metadata and child bindings pass through Beignet's recursive credential redaction before Pino writes them. This applies to the lifecycle provider and the direct adapter. When the app owns Pino transports or destinations, use the direct adapter: ```typescript import { createPinoLogger } from "@beignet/provider-logger-pino"; const logger = createPinoLogger({ logger: appPinoLogger }); ``` This returns `LoggerPort` without creating or closing the Pino instance. ## Port shape `LoggerPort` is exported by `@beignet/core/ports`: ```typescript export interface LoggerPort { trace(message: string, meta?: Record): void; debug(message: string, meta?: Record): void; info(message: string, meta?: Record): void; warn(message: string, meta?: Record): void; error(message: string, meta?: Record): void; fatal(message: string, meta?: Record): void; child(bindings: Record): LoggerPort; } ``` Use child loggers for request or workflow fields that should appear on every line: ```typescript export async function publishPost(ctx: AppContext, input: PublishPostInput) { const log = ctx.ports.logger.child({ requestId: ctx.requestId, postId: input.postId, }); log.info("Publishing post"); const post = await ctx.ports.posts.publish(input.postId); log.info("Post published", { slug: post.slug }); return post; } ``` ## Request logging Use `createLoggingHooks` when you want HTTP lifecycle logs. The hook is framework-owned behavior, so it belongs in `server/index.ts` beside auth, devtools, CORS, and rate limiting. ```typescript import { createLoggingHooks } from "@beignet/core/server"; const requestLoggingHooks = createLoggingHooks({ requestIdHeader: "x-request-id", onRequestEnd: ({ ctx, req, requestInfo, res, durationMs, contract, error, }) => { if (!ctx) { return; } const log = ctx.ports.logger.child({ requestId: ctx.requestId, contract: contract?.name, }); const meta = { method: req.method, path: requestInfo.url.pathname, status: res.status, durationMs: Math.round(durationMs), }; if (error) { log.error("Request failed", { ...meta, error }); return; } log.info("Request completed", meta); }, }); ``` Make sure the context blueprint and auth hooks add the request fields you want in logs, such as `requestId`, `actor.id`, `tenant.id`, or `role`. When `createServer(...)` has a `trustedProxy` policy, logging observers receive the same resolved `requestInfo` as the context factory, CSRF, and rate limiting. Beignet does not log `requestInfo.clientIp` automatically. Add it only when the application needs IP-based operational records and has an appropriate retention policy. ## What to log Good production logs are structured and sparse: | Location | Log | | --- | --- | | Hooks | request start/end, auth failures, rate limit decisions | | Use cases | business milestones and expected domain failures | | Jobs | dispatch, start, success, retry, failure | | Providers | connection setup, teardown, external service errors | Avoid logging request bodies, passwords, tokens, cookies, full authorization headers, or unbounded objects. Prefer stable IDs and counts. The Pino adapter redacts sensitive structured keys and high-confidence credential strings by default. It does not rewrite the log message itself, so do not interpolate secrets into message strings. Use the shared redaction helpers for structured metadata that may include headers or provider payloads: ```typescript import { redactHeaders, redactValue } from "@beignet/core/ports"; log.info("Request received", { headers: redactHeaders(req.headers), }); log.info("Provider payload", redactValue(payload)); ``` ## Testing Tests can use a no-op or captured logger: ```typescript import type { LoggerPort } from "@beignet/core/ports"; export function createTestLogger(): LoggerPort { const logger: LoggerPort = { trace: () => {}, debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, fatal: () => {}, child: () => logger, }; return logger; } ``` Use a captured logger when the behavior under test is that a specific diagnostic was emitted. Otherwise, a no-op logger keeps tests quiet. --- # Error reporting Source: https://www.beignetjs.com/error-reporting Beignet separates three observability concerns: - **Logging** records structured runtime facts. - **Error reporting** captures exceptions and failure context for triage. - **Alerting** turns symptoms into operator action. Use `ErrorReporterPort` when application code needs to report an unexpected failure without importing a vendor SDK. ## Capture errors Declare `errorReporter` as an app port and report unexpected failures from use cases, jobs, schedules, outbox drains, and tasks: ```typescript await ctx.ports.errorReporter.captureException(error, { level: "error", requestId: ctx.requestId, traceId: ctx.traceId, tags: { feature: "billing", }, contexts: { tenant: { id: ctx.tenant.id }, }, }); ``` Capture messages for important non-exception signals: ```typescript await ctx.ports.errorReporter.captureMessage("Payment provider degraded", { level: "warning", tags: { provider: "stripe" }, }); ``` Expected business failures such as validation failures, not-found results, denied policies, and known catalog errors usually belong in logs or audit records, not high-priority exception alerts. ## Setup with Sentry Install the Sentry provider: ```bash bun add @beignet/provider-error-reporting-sentry @sentry/node ``` Register it in `server/providers.ts`. Provider-contributed ports replace earlier bound ports during startup, so a generated app can keep its local `createNoopErrorReporter()` fallback and let this provider replace it when Sentry is installed. ```typescript import { createSentryErrorReportingProvider } from "@beignet/provider-error-reporting-sentry"; export const providers = [ createSentryErrorReportingProvider({ dsn: process.env.SENTRY_DSN, init: { environment: process.env.NODE_ENV, }, }), ]; ``` The provider contributes `ctx.ports.errorReporter` and `ctx.ports.sentry` as an escape hatch for advanced Sentry operations. With no `dsn` or `SENTRY_DSN`, the provider still contributes the Beignet port but does not initialize Sentry. When the app registers a separate OpenTelemetry SDK, configure `init: { skipOpenTelemetrySetup: true }` so Sentry remains the error reporter without installing a second tracing pipeline. See [OpenTelemetry](/observability) for the combined provider order. ## HTTP request errors Install `createErrorReportingHooks(...)` in `server/index.ts` to capture unexpected route, hook, response-validation, and server failures. The hook observes caught errors without changing response mapping. Use `mapUnhandledError` only to decide the response body. ```typescript import { createErrorReportingHooks, createIdempotencyHooks, } from "@beignet/core/server"; import { createNextServer, createNextServerLoader } from "@beignet/next"; import type { AppContext } from "@/app-context"; import { initialPorts } from "@/infra/port-wiring"; import { appContext } from "./context"; import { routes } from "./routes"; export const getServer = createNextServerLoader(async () => { const { providers } = await import("./providers"); return createNextServer({ ports: initialPorts, providers, hooks: [ createErrorReportingHooks(), createIdempotencyHooks(), ], context: appContext, routes, mapUnhandledError: ({ err, ctx }) => { ctx?.ports.logger.error("Unhandled API error", { error: err, requestId: ctx?.requestId, }); return { status: 500, body: { code: "INTERNAL_SERVER_ERROR", message: "Internal server error", requestId: ctx?.requestId, }, }; }, }); }); ``` By default the hook captures unexpected failures and skips expected application catalog errors, auth denials, tenant requirements, idempotency conflicts, entitlement denials, and request validation failures. Pass `shouldReport` or `reportOptions` when an app needs different alerting rules or extra tags. Reporting preparation, capture, and failure observation are independently bounded to one second so a reporting backend cannot hold an HTTP response open. Set `timeoutMs` to tune the bound. Use `timeoutMs: false` only when intentionally allowing reporting to block request completion. Callback, resolver, reporter, and timeout failures are isolated and may be observed through `onReporterError`. ## Runtime ownership Report a logical failure once at the boundary that knows it is terminal: | Boundary | Reporting owner | Default behavior | | --- | --- | --- | | HTTP | `createErrorReportingHooks(...)` | Reports unexpected caught failures; skips catalog, auth, policy, tenancy, idempotency, entitlement, and validation outcomes. | | BullMQ jobs | `createBullMQJobWorker(...)` | Reports terminal, invalid, unknown-job, and worker infrastructure failures. Retry attempts remain instrumentation. | | Inngest jobs | `createInngestJobFunction(...)` | Reports through native `onFailure` after Inngest exhausts retries. | | Next schedule routes | `createScheduleRoute(...)` | Reports a failed invocation that the route converts to a 500 response. | | Next/CLI outbox drains | Drain adapter | Reports dead letters, settlement failures, and drain infrastructure failures; scheduled retries are not incidents. | | CLI tasks and schedules | CLI runner | Reports terminal failures and performs a bounded flush before context shutdown. | | Registered listeners | App-owned registration provider | The canonical generated provider reports from `registerListeners(...).onError`. | | Direct core primitive calls | Calling app | Use `tryReportException(...)` at the outer boundary that decides the failure is terminal. | Do not also report inside a handler when its worker, route, or runner owns the terminal boundary; doing both creates duplicate incidents. Prefer durable failure state for work that must be retried or reconciled. Error reporting tells an operator something went wrong; it does not make the work durable. ## What context to send Attach stable identifiers, not raw payloads: | Field | Use | | --- | --- | | `requestId` | Correlate logs, devtools, traces, and support tickets | | `traceId` | Connect nested use case, provider, outbox, and job events | | `actorId` | Identify the user, service, or anonymous actor | | `tenantId` | Scope the failure to a tenant or workspace | | `contractName` | Identify the HTTP boundary | | `useCaseName` | Identify the application workflow | | `jobName` | Identify background work | | `outboxMessageId` | Reconcile durable delivery failures | | `resourceType` and `resourceId` | Find the affected record | Do not send request bodies, raw provider responses, access tokens, cookies, passwords, PHI, payment details, private messages, or full authorization headers unless your reporting vendor and retention policy are approved for that data. Beignet applies default sensitive-key redaction to structured Sentry metadata, but it cannot safely rewrite exception messages or infer domain-sensitive fields. `AppError.details` is not automatically redacted and is never copied by the default HTTP hook; treat manual capture of an `AppError` as an explicit data Use [Privacy lifecycle](/privacy-lifecycle) to define which fields may leave app-owned storage and which fields must only appear as stable identifiers. ## Testing `createTestPorts(...)` includes an in-memory `errorReporter` port by default. Use it directly when testing failure paths: ```typescript const { ports, errorReporter } = createTestPorts(); await useCase.run({ ctx: { ports }, input }); expect(errorReporter.reports).toEqual([ expect.objectContaining({ type: "exception", }), ]); ``` You can also import the memory reporter directly: ```typescript import { createMemoryErrorReporter } from "@beignet/core/error-reporting"; const errorReporter = createMemoryErrorReporter(); ``` ## Devtools versus production reporting Devtools are local diagnostics. When devtools are installed before an error reporting provider, captured exceptions and messages appear under the Errors view with request and trace correlation. Use devtools locally, structured logs in production, and a production error reporter for exceptions that operators need to triage. ## Alerting Do not alert on every captured exception. Alert on symptoms that require human action: - elevated 5xx rate - repeated auth or payment provider failures - queue or outbox dead-letter growth - schedule missed-run or failure rate Start with slow, high-signal alerts and add more only when they lead to useful operator action. Every alert should have an owner, a severity, a runbook link, and enough context to find the affected tenant or resource. ## Related pages - [Logging](/logging) for structured application logs. - [Errors](/errors) for app error catalogs and client error handling. - [Outbox](/outbox) and [Jobs](/jobs) for durable failure semantics. - [Going to production](/deployment) for redaction and sensitive data boundaries. --- # Audit and activity logging Source: https://www.beignetjs.com/audit Audit logging records business activity that must be explainable later: who did what, to which resource, in which tenant, and under which request. It is different from diagnostic logging. `LoggerPort` helps operators debug runtime behavior; `AuditLogPort` gives the application a durable activity trail. Use [Privacy lifecycle](/privacy-lifecycle) to decide audit retention, deletion, anonymization, and which metadata must stay out of durable activity records. ```bash bun add @beignet/core # Optional for the local devtools timeline: bun add @beignet/devtools ``` Audit records read the actor, tenant, and request ID from app context. See [Routes and server](/server) for the context blueprint and [Authentication](/authentication) for resolving the request actor and session. ## Audit port Add an audit port to application ports: ```typescript import type { AuditLogPort } from "@beignet/core/ports"; export type AppTransactionPorts = { audit: AuditLogPort; posts: PostRepository; }; export type AppPorts = { audit: AuditLogPort; posts: PostRepository; uow: UnitOfWorkPort; }; ``` Audit entries use stable action names and resource descriptors: ```typescript await ctx.ports.audit.record({ action: "posts.publish", resource: { type: "post", id: post.id, name: post.slug }, metadata: { publishedAt: post.publishedAt }, }); ``` Call sites only describe the business activity. Actor, tenant, request ID, and trace ID come from the ambient request context when the audit port is wrapped with `createAmbientAuditLog(...)` (next section). Fields provided explicitly on an entry always win over ambient values. ## Ambient enrichment Wrap the durable audit port with `createAmbientAuditLog(...)` from `@beignet/core/server` and the actor, tenant, request ID, and trace ID fill in automatically at record time: ```typescript import { createAmbientAuditLog } from "@beignet/core/server"; import { createInstrumentedAuditLog } from "@beignet/core/ports"; import { createDrizzleSqliteAuditLogPort, createDrizzleSqliteAuditLogSetupStatements, } from "@beignet/provider-db-drizzle/sqlite"; for (const statement of createDrizzleSqliteAuditLogSetupStatements()) { await client.execute(statement); } const audit = createAmbientAuditLog( createInstrumentedAuditLog({ audit: createDrizzleSqliteAuditLogPort(db), instrumentation: ports, }), ); ``` The default Drizzle audit table is `audit_log`. If your app uses a different name, pass the same `tableName` to setup and port creation: ```typescript const auditTableName = "audit_events"; for (const statement of createDrizzleSqliteAuditLogSetupStatements({ tableName: auditTableName, })) { await client.execute(statement); } const audit = createDrizzleSqliteAuditLogPort(db, { tableName: auditTableName, }); ``` When `doctor` cannot infer that table name from a literal provider option, declare it in `beignet.config.ts`: ```typescript import { defineConfig } from "@beignet/cli/config"; export default defineConfig({ database: { tables: { audit: "audit_events", }, schemaSources: ["@acme/db/schema"], }, }); ``` `database.schemaSources` is only needed when the table definition lives outside the app's standard database files, such as a shared workspace package. The server keeps the ambient context current for every execution path: requests enter it before hooks run and refresh after hooks finalize identity, and service contexts created with `server.createServiceContext(...)` enter it with the service actor, tenant, and fresh correlation IDs. Because enrichment happens when `record(...)` runs, the wrapper also works for ports a unit of work rebuilds per transaction — wrap both construction points. On runtimes without `AsyncLocalStorage`, entries pass through unchanged and a missing actor defaults to an anonymous actor before persistence. ## Transaction boundary For writes, record audit entries inside the same Unit of Work transaction as the state change: ```typescript const published = await ctx.ports.uow.transaction(async (tx) => { const post = await tx.posts.publish(input.slug); await tx.audit.record({ action: "posts.publish", resource: { type: "post", id: post.id, name: post.slug }, }); return post; }); ``` This keeps audit records aligned with committed data. If the transaction rolls back, the audit record rolls back too. For failed attempts that must be audited, record a separate `outcome: "failure"` entry in an error path that is designed for that requirement. ## Background contexts Jobs, listeners, schedules, cron routes, and scripts should receive the same context shape as HTTP handlers. Declare a `service` factory in the server's `context` blueprint, then build background contexts with `server.createServiceContext(...)`: ```typescript import { createSystemActor } from "@beignet/core/ports"; const ctx = await server.createServiceContext({ actor: createSystemActor("example-background"), }); ``` Creating a service context also enters the ambient request context, so ambient-wrapped audit ports enrich background records the same way they enrich request records. In plain scripts such as seeds, use `server.runServiceContext(input, fn)` instead: it scopes the same ambient frame to the callback, so audit enrichment works there too without the `AsyncLocalStorage.enterWith` frame that crashes Bun 1.3.x under top-level await. See [Routes and server](/server) for the two service entrypoints. ## HTTP hooks Use `beforeHandle` for HTTP boundary decisions that must be durably audited before the response is sent, such as denied access to sensitive routes. Keep successful business-write audit records in the use case transaction. ```typescript const accessAuditHooks = { name: "access-audit", beforeHandle: async ({ ctx, contract }) => { if (contract.metadata?.auth !== "required" || ctx.actor.type === "user") { return; } await ctx.ports.audit.record({ action: `http.${contract.name}.rejected`, outcome: "failure", resource: { type: "route", name: contract.name }, metadata: { status: 401 }, }); return { ctx, response: { status: 401, body: { code: "UNAUTHORIZED", message: "Unauthorized" }, }, }; }, }; ``` `afterSend` is an observation hook. It is useful for best-effort logging, metrics, and diagnostic mirrors, but Beignet ignores `afterSend` failures so they cannot change a response that has already been produced. The server's trusted-proxy policy makes resolved request metadata available to the context factory and server hooks, but `createAmbientAuditLog(...)` does not copy client IP, host, or protocol into audit entries. Add selected values to an entry's `metadata` only when they are required for the audited workflow. Client IP addresses are personal data in many jurisdictions, so define a retention and redaction policy before persisting them. ## Jobs, listeners, and schedules Background work should audit the durable business activity it owns. A listener audits that it enqueued follow-up work: ```typescript export const enqueuePostPublishedEmail = defineListener( "posts.enqueue-published-email", { event: PostPublished, async handle({ payload, ctx }) { await ctx.ports.jobs.dispatch(SendPostPublishedEmailJob, payload); await ctx.ports.audit.record({ action: "listeners.posts.enqueue-published-email", resource: { type: "post", id: payload.postId, name: payload.slug }, metadata: { eventName: PostPublished.name }, }); }, }, ); ``` A job audits the external side effect after it succeeds. For non-idempotent side effects such as sending mail, catch and log audit-write failures instead of letting them fail the job: if the mail provider already accepted the message, an audit failure should not make a retry send it again. For stronger guarantees, put delivery state, audit state, and retries behind an idempotent outbox or provider-specific delivery record. Schedules should audit the work they performed — the records processed, the date covered, the trigger time — not just that the cron endpoint was called. ## Recommended fields Use these fields consistently: | Field | Purpose | | --- | --- | | `action` | Stable verb such as `issues.update` or `posts.publish` | | `actor` | User, service, system, or anonymous actor that initiated the action | | `tenant` | Tenant, organization, workspace, or account boundary | | `resource` | Domain object affected by the action | | `requestId` | Request correlation ID for logs, devtools, and support | | `outcome` | `success` or `failure` | | `metadata` | Small domain details safe to persist | Do not store secrets, tokens, raw PHI payloads, passwords, or full request bodies in audit metadata. Prefer stable identifiers and small, intentional summaries. ## Redaction `createMemoryAuditLog()` redacts metadata by default. Durable app adapters should use `redactAuditLogEntry()` or `createRedactedAuditLog()` before writing metadata to storage: ```typescript import { createRedactedAuditLog, redactAuditLogEntry } from "@beignet/core/ports"; const safeAudit = createRedactedAuditLog(durableAudit); const safeEntry = redactAuditLogEntry(entry); ``` Beignet's shared redactor catches secret-shaped keys such as `authorization`, `cookie`, `set-cookie`, `x-api-key`, `token`, `password`, `secret`, and `credentials`. It does not know which app-specific fields contain PHI or PII, so keep audit metadata intentionally small. ## Instrumentation mirror Sanitized audit activity appears in the Audit view of [devtools](/devtools) when the durable port is wrapped with `createInstrumentedAuditLog(...)`: ```typescript import { createInstrumentedAuditLog } from "@beignet/core/ports"; const audit = createInstrumentedAuditLog({ audit: durableAudit, instrumentation: ports, }); ``` The wrapper writes through the durable audit port first, then emits a custom event owned by the `audit` watcher. Pass the ports object as `instrumentation` so the sink is resolved lazily and observes provider startup order; with no sink installed, only the durable write happens. Do not emit instrumentation audit events from inside an active database transaction unless your adapter defers the event until after commit. Otherwise the local timeline can show an audit record for work that later rolls back. ## Testing Use the memory adapter for use-case tests. To assert enriched entries, mirror production wiring: wrap the memory port with `createAmbientAuditLog(...)` and enter an ambient request context for the test identity: ```typescript import { createMemoryAuditLog, createUserActor } from "@beignet/core/ports"; import { clearActiveRequestContext, createAmbientAuditLog, enterActiveRequestContext, } from "@beignet/core/server"; const audit = createMemoryAuditLog(); const actor = createUserActor("user_1"); const ctx = { actor, requestId: "test-request", ports: { audit: createAmbientAuditLog(audit), }, }; enterActiveRequestContext({ requestId: "test-request", actor }); await useCase.run({ ctx, input }); clearActiveRequestContext(); expect(audit.entries).toMatchObject([ { action: "posts.publish", actor: { type: "user", id: "user_1" }, requestId: "test-request", }, ]); ``` Route tests that go through the server do not need the manual `enterActiveRequestContext(...)` call — the server enters and refreshes the ambient context per request. Repository or adapter tests should verify the durable table shape separately. --- # Devtools Source: https://www.beignetjs.com/devtools `@beignet/devtools` gives local apps a live timeline for Beignet activity: requests, errors, use cases, domain events, jobs, outbox delivery, schedules, payments, feature flags, entitlements, policies, and provider activity. ```bash bun add @beignet/devtools ``` ## Setup ### 1. Register the provider ```typescript import { createDevtoolsProvider } from "@beignet/devtools"; import { createNextServer, createNextServerLoader } from "@beignet/next"; export const getServer = createNextServerLoader(() => createNextServer({ ports, providers: [createDevtoolsProvider(), ...otherProviders], context: async ({ ports, requestId, trace }) => ({ requestId, ...trace, ports, }), }), ); ``` The provider is the only wiring devtools needs. The server itself owns request instrumentation: `createServer(...)` resolves a request ID and W3C trace context for every request, writes the `x-request-id` and `traceparent` response headers, and records request and error events into the resolved provider instrumentation port. Requests under `/api/devtools` are ignored by default so dashboard traffic does not pollute the timeline. See [request lifecycle](/request-lifecycle) for the `instrumentation` option that configures headers, ignored paths, redaction, and capture decisions. Devtools does not require the OpenTelemetry SDK, but events are shaped for OTel-compatible correlation with `traceId`, `spanId`, `parentSpanId`, and `traceparent` from `@beignet/core/tracing`. Install the [OpenTelemetry provider](/observability) when the app should export active spans and production metrics while retaining this local timeline. Spread the `trace` context argument into your app context so deeper instrumentation stays on the same trace. ### 2. Add the dashboard route ```typescript // app/api/devtools/[[...path]]/route.ts import { createDevtoolsRoute } from "@beignet/devtools"; import { getServer } from "@/server"; export const { GET, POST } = createDevtoolsRoute( async () => (await getServer()).ports.devtools, { basePath: "/api/devtools", authorize: async (req) => Boolean(await (await getServer()).ports.auth.getSession(req)), }, ); ``` Sign in, then visit `/api/devtools` in development. ![The devtools dashboard showing an expanded request with overview facts, a lifecycle waterfall of correlated spans, and correlated activity grouped by category](/devtools-dashboard.webp) The dashboard streams live events over Server-Sent Events, groups views by section in the sidebar, and supports search (`/`), method/status/watcher filters, Pause/Resume, and Clear. Request rows expand into an end-to-end lifecycle view for events sharing the same `traceId` or `requestId`: a waterfall of correlated spans, overview facts, correlated activity grouped by category, and the raw JSON. Request spans break down into per-stage sub-bars — `onRequest` hooks, parsing, context creation, `beforeHandle` hooks, the handler, and send — so a request that "feels slow" shows *where* the time went; a wide `context` bar on every request usually means sequential remote lookups in the context factory (see [Context latency budgets](/request-lifecycle#context-latency-budgets)). Start there when debugging a route. The errors view groups failures by owner — route, framework, provider, job, schedule, outbox, client-side, devtools, or unknown — and each subsystem view shows domain-specific metrics such as cache hits/misses, outbox attempts, or rate limit decisions. The payments view focuses on checkout sessions, portal sessions, refunds, verified webhooks, provider IDs, and failures. The entitlements view focuses on paid product access decisions, subjects, denial reasons, and check sources. The policies view focuses on observed authorization decisions, abilities, denial reasons, and batch sources. The stream sends a fresh snapshot on connection, uses heartbeat comments to reduce idle disconnects through intermediaries, and closes after four minutes so `EventSource` reconnects and receives a fresh bounded snapshot. When the host reports a disconnect through request abort or response cancellation, the stream releases its server subscription; timed closure does the same. Unread encoded stream data is capped at 1 MiB; exceeding the limit closes the connection so `EventSource` can reconnect instead of retaining an unbounded server queue. Initial snapshots retain the newest events that fit within the same cap as a contiguous newest-first window and stop when the next entry cannot fit. The dashboard displays `omittedEvents` and `omittedWatchers` in a partial-snapshot warning when entries are left out, so an oversized stored event cannot trap the dashboard in a reconnect loop or repeatedly force the server to inspect the remaining buffer. ### 3. Optional local persistence The default buffer is in memory. Enable local persistence when you want the timeline to survive dev server restarts: ```typescript import { createDevtoolsProvider, createFileDevtoolsStore, } from "@beignet/devtools"; createDevtoolsProvider({ store: createFileDevtoolsStore({ filePath: ".beignet/devtools/core/events.jsonl", }), }); ``` You can also enable the built-in file store with environment variables: ```bash DEVTOOLS_PERSIST=true DEVTOOLS_PERSIST_PATH=.beignet/devtools/core/events.jsonl ``` The file store writes JSONL and compacts to the most recent configured events. `POST /api/devtools/clear` clears the in-memory buffer and the configured store. ### 4. Configure watchers Watchers own capture for each subsystem. Configure them through `createDevtoolsProvider(...)`: ```typescript createDevtoolsProvider({ watchers: { requests: true, errors: true, useCases: true, eventBus: false, jobs: false, outbox: true, schedules: true, providers: true, db: true, cache: true, payments: true, entitlements: true, custom: true, }, }); ``` Disabled watchers do not store matching events. The built-in watchers are `requests`, `errors`, `useCases`, `eventBus`, `jobs`, `outbox`, `schedules`, `providers`, `db`, `cache`, `storage`, `uploads`, `mail`, `payments`, `flags`, `entitlements`, `notifications`, `auth`, `audit`, `policies`, `rateLimit`, and `custom`. Custom integrations can register watcher metadata too. Custom watcher views appear in the dashboard sidebar when they own `custom` events: ```typescript createDevtoolsProvider({ watchers: { search: { label: "Search", description: "Search query and indexing diagnostics.", eventTypes: ["custom"], }, }, }); ``` Then record events with `watcher: "search"` so that custom watcher controls whether they are stored. ### 5. Use cases are instrumented automatically `createUseCase(...)` instruments every run by default. No devtools-specific wiring is needed: ```typescript import { createUseCase } from "@beignet/core/application"; export const useCase = createUseCase(); ``` Each run resolves the instrumentation port from `ctx.ports`, reads `ctx.requestId` and trace context fields, and records `usecase` events that share one nested span across `start`, `end`, and `error` phases. Failed runs also record correlated `error` events. Without an installed sink, runs stay silent. Pass `instrumentation: false` to opt out. ## Provider instrumentation Providers record external work through `createProviderInstrumentation()` from `@beignet/core/providers` instead of depending on devtools directly; `@beignet/devtools` implements the instrumentation port that helper resolves. When provider instrumentation records an event during an active request, devtools fills in the active `requestId`, `traceId`, and `traceparent` so provider work stays correlated with the route, hook, and use-case timeline. See [Writing a provider](/writing-a-provider) for the instrumentation conventions, resolution order, and watcher guidance. ## Audit activity Durable audit logs should still be written through your app's `AuditLogPort`. Use `createInstrumentedAuditLog()` from `@beignet/core/ports` when local debugging should also show sanitized audit activity: ```typescript import { createInstrumentedAuditLog } from "@beignet/core/ports"; const audit = createInstrumentedAuditLog({ audit: durableAudit, instrumentation: ports, }); ``` The wrapper records the durable audit entry first, then emits a custom event owned by the `audit` watcher into the resolved instrumentation port. Devtools remains a local diagnostic view; it is not the durable audit store. When an audit port is transaction-scoped, emit the devtools mirror only after the transaction commits. Keeping the transaction-scoped audit port durable-only is preferable to showing a local audit event for work that later rolls back. ## Manual events Use `record()` when application code wants to add a custom event. It fills `id` and `timestamp` for you. ```typescript ctx.ports.devtools.record({ type: "custom", watcher: "search", name: "search.query", label: "Search query", summary: "24 results in 18ms", details: { query, resultCount: 24, durationMs: 18, }, }); ``` Use `log()` only when you already have a complete `DevtoolsEvent`. ## Redaction Devtools uses the shared redaction helpers from `@beignet/core/ports` before events are stored. Sensitive keys such as `authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `x-api-key`, `accessKey`, `jwt`, `session`, `token`, `password`, `secret`, and `credentials` are replaced with `[redacted]`. High-confidence credential shapes embedded in strings—including Bearer and Basic credentials, JWTs, credential-bearing URLs, secret assignments, and private keys—are scrubbed too, including in error messages and stacks. Server request instrumentation records request headers for debugging, but it does not record request or response bodies by default. The request lifecycle view marks stored events when sensitive fields were redacted and warns if secret-shaped metadata keys remain visible. Add an app-owned redactor through the server `instrumentation` option: ```typescript const server = await createNextServer({ // ... instrumentation: { redact: (event) => ({ ...event, details: scrub(event.details), }), }, }); ``` ## Event types | Type | Description | Key fields | |------|-------------|------------| | `request` | HTTP request handling | `method`, `path`, `status`, `durationMs`, `stages`, `responseOwner` | | `error` | Errors | `message`, `stack`, `contractName`, `useCaseName`, `owner` | | `usecase` | Use case execution | `name`, `kind`, `phase`, `durationMs` | | `eventBus` | Domain event publishing | `eventName` | | `job` | Background job lifecycle | `jobName`, `status` | | `schedule` | Schedule execution | `scheduleName`, `status`, `cron`, `timezone` | | `provider` | Provider lifecycle | `providerName`, `action` | | `custom` | App-specific diagnostics | `name`, `label`, `summary`, `details` | All events share `id`, `timestamp`, an optional `requestId`, optional `traceId`, optional `spanId`, optional `parentSpanId`, optional `traceparent`, and an optional `watcher` for custom watcher ownership. ## Endpoints | Endpoint | Description | |----------|-------------| | `GET /api/devtools` | Dashboard UI | | `GET /api/devtools/core/events` | JSON event list | | `GET /api/devtools/stream` | Server-Sent Events stream with a fresh snapshot on each connection | | `POST /api/devtools/clear` | Clear the in-memory buffer and configured store | The events endpoint accepts `type`, `requestId`, `traceId`, and `limit` query parameters. ## Configuration The provider controls whether events are recorded. The HTTP route controls whether those events are exposed. HTTP routes auto-enable only in development. ```bash DEVTOOLS_ENABLED=true DEVTOOLS_ENABLED=false DEVTOOLS_MAX_EVENTS=1000 DEVTOOLS_PERSIST=true DEVTOOLS_PERSIST_PATH=.beignet/devtools/core/events.jsonl ``` The default in-memory buffer keeps the latest 500 events. The events endpoint returns the latest 200 unless a `limit` query parameter is provided. Persistence is opt-in and uses `.beignet/devtools/core/events.jsonl` by default when enabled without a custom path. Route handlers auto-enable only when `NODE_ENV === "development"`, and they remain hidden until you configure an access policy. Generated apps wire `DEVTOOLS_ENABLED` through `lib/env.ts` and authorize signed-in sessions through the auth port. Set `DEVTOOLS_ENABLED=false` to disable the route locally. Unset, test, preview, staging, and production environments also require `DEVTOOLS_ENABLED=true` before authorization can expose the route. The starter's sidebar reads the same value for local navigation. Browser requests that include an `Origin` header must be same-origin, even when `authorize` succeeds. This is a browser safeguard in addition to application authorization. For staging or internal production diagnostics, add application-owned authorization: ```typescript import { createDevtoolsRoute } from "@beignet/devtools"; import { getServer } from "@/server"; export const { GET, POST } = createDevtoolsRoute( async () => (await getServer()).ports.devtools, { basePath: "/api/devtools", enabled: process.env.DEVTOOLS_ENABLED === "true", authorize: async (req: Request) => { const session = await (await getServer()).ports.auth.getSession(req); return session?.user.id === process.env.DEVTOOLS_ADMIN_USER_ID; }, }, ); ``` If `authorize` returns `false`, devtools responds with `404`. If it returns a `Response`, that response is used. If your development server binds exclusively to loopback, you can opt into an unauthenticated local route with `allowUnauthenticatedDevelopmentAccess: true`. The option works only in development and only for loopback URL hostnames. Do not use it with `0.0.0.0`, a LAN or container-accessible interface, or a hosted development environment: the Fetch `Request` URL describes the destination, not the network peer. Use cookie/session authorization for the embedded dashboard. Same-origin dashboard requests include cookies, while browser `EventSource` requests cannot attach an application-defined authorization header. --- # Going to production Source: https://www.beignetjs.com/deployment Beignet gives production apps guardrails, but it does not make deployment or security automatic. Treat this page as the pre-launch checklist: run the preflight checks, validate configuration, verify host settings, wire bounded runtime entrypoints, and confirm the security posture of every exposed surface. ## Preflight checks Run the validation loop in CI before shipping: ```bash bun beignet check ``` It runs `beignet lint`, `beignet doctor --strict`, and the app's `lint`, `typecheck`, and `test` scripts in one pass, and exits non-zero when any step fails. Run `bun run format` locally before opening a change. `beignet lint` enforces dependency direction so sensitive infrastructure code does not leak into domain, use case, route, or component layers. `doctor --strict` is the CI bar: it catches production drift that is easy to miss during manual edits, such as exposed devtools routes, missing cron auth, upload definitions without explicit size limits, provider environment variables that are not configured, and credentialed wildcard CORS. For GitHub-annotation output, run `beignet lint --format github` and `beignet doctor --strict --format github` individually. In the deploy pipeline itself — where production configuration is present — run the runtime gate: ```bash bun beignet preflight ``` It validates the actual environment: required provider env vars, secrets still matching `.env.example` placeholders, the app env schema, doctor's production hardening findings, and logging/error-reporting wiring. Add `--connect` to run the app-owned migration status check before server boot, then run every port's `checkHealth()` so pending migrations or a bad credential fail the deploy instead of the first request. Older apps without `db:status` receive a compatibility note and continue. Use `beignet check --preflight` to append only the disconnected environment gate to the full validation loop, or `beignet check --preflight-connect` to include migration status and dependency health checks. Both connected commands accept `--connect-timeout-ms ` when the default 5000 ms per check is too short. Confirm the CLI can inspect the route map: ```bash bun beignet routes ``` If your app exposes OpenAPI, make sure the OpenAPI route imports the static `contractsFromRoutes(routes)` export from `server/routes.ts`. Run `doctor` after changing contracts or route registration. ## Production hardening checklist When `doctor` reports production-readiness diagnostics, use this checklist before launch: - Secrets and provider credentials are unique per environment, validated at startup, never committed, and never logged. - Auth routes, tenant resolution, authorization policies, and trusted origins derive authority from verified sessions, API keys, or trusted gateway metadata. - Devtools, OpenAPI, cron, webhooks, and operational routes have intentional exposure and app-owned authorization. - CORS origins, proxy IP trust, rate-limit keys, and request body limits match the production host topology. - Uploads and storage define max sizes, authorization or explicit public access, object visibility, safe content headers, and short direct-upload expirations. - Provider-backed dependencies have bounded readiness checks, worker shutdown behavior, webhook secrets, and least-privilege credentials. ## Environment variables and secrets Keep deploy-time configuration in `lib/env.ts` with `@beignet/core/config` and validate it at startup. Avoid reading ad hoc environment variables inside route handlers, use cases, or infra adapters. Declare server-only and client-safe variables separately: ```typescript import { createEnv } from "@beignet/core/config"; import { z } from "zod"; export const env = createEnv({ server: { NODE_ENV: z.enum(["development", "test", "production"]), CRON_SECRET: z.string().min(32), BETTER_AUTH_SECRET: z.string().min(32), STORAGE_S3_BUCKET: z.string().min(1), }, clientPrefix: "NEXT_PUBLIC_", client: { NEXT_PUBLIC_APP_URL: z.string().url(), }, runtimeEnv: process.env, }); ``` Rules: - Never put secrets behind the client prefix. - Validate required production secrets at startup. - Prefer platform secret stores over committed `.env` files, and keep `.env.example` useful but empty of real values. - Use `runtimeEnvStrict` when the host only bundles variables that are explicitly referenced. See [Config](/config) for the strict runtime helpers. Provider credentials should be owned by the deployment environment and read through app config. Scope database and S3-compatible credentials to the app, environment, and narrowest bucket or key prefix possible, and never log, audit, or record credentials in devtools. `doctor --strict` checks common first-party provider environment variables when the corresponding package is installed. ## Host settings Before launch, verify the host configuration: - `NODE_ENV=production` is set for production builds. - TLS is enforced by the platform. - Preview and production environments have separate secrets and databases, and do not share writable credentials unless that is intentional. - The database pool maximum is budgeted across the host's maximum web, worker, preview, task, and migration concurrency. Serverless deployments use a provider-managed pooled endpoint when available. - BullMQ Redis uses `maxmemory-policy=noeviction`, appropriate persistence, memory alerts, and bounded completed/failed job retention. Queue payloads do not contain secrets or unnecessary personal data. - Cron routes receive the expected `Authorization` header. - Build logs do not print secrets. - `requestBody.maxBytes` and upload router `limits` match the largest expected JSON, direct-upload metadata, and server-handled multipart requests. - OpenAPI and devtools routes have the intended exposure. - Source maps, stack traces, and error reporting settings match the team's incident response plan. ## Safe database migrations Generate and commit migration files before deployment. For each environment, run one serialized release job: ```bash bun beignet db migrate bun beignet db status bun beignet preflight --connect ``` Use the deployment platform's environment or concurrency lock so two releases cannot migrate the same database at once. Do not run migrations from every web replica, a readiness route, or application startup. `db status` is observational and cannot close the race between inspection and migration; serialization is the safety mechanism. If the platform cannot serialize release jobs, wrap the app-owned migration script in a database advisory lock appropriate to the selected backend. Rolling deploys should use expand-and-contract migrations: deploy additive, backward-compatible schema changes first, roll out compatible application code, and remove old columns or constraints in a later release after every old instance has stopped. ## Liveness and readiness Keep liveness and readiness separate: - `/api/health` should be cheap and local. It answers whether the process can respond. - `/api/ready` should run bounded dependency checks. It answers whether the app should receive traffic. Generated apps expose both endpoints. The readiness route uses `createHealthRoute(...)` from `@beignet/next` and provider-owned checks such as `ctx.ports.db.checkHealth()`: ```ts // app/api/ready/route.ts import { createHealthRoute } from "@beignet/next"; import { env } from "@/lib/env"; import { getServer } from "@/server"; export const { GET } = createHealthRoute( getServer, { checks: { database: (ports) => ports.db.checkHealth(), }, timeoutMs: 2000, }, env.NODE_ENV, ); ``` Readiness checks should be cheap, non-mutating probes such as `select 1`, Redis `PING`, queue health checks, or a provider health endpoint. Do not run migrations, drains, workers, or polling loops from readiness routes. `doctor` only checks for the presence of app-owned readiness wiring; it does not make network calls. First-party Drizzle, Redis cache, Redis event bus, Redis locks, Upstash rate limiting, BullMQ jobs, Meilisearch, S3-compatible storage, and Vercel Blob storage providers expose explicit `checkHealth()` helpers for this purpose. ## Security headers, CORS, CSRF, and client IPs Install the browser response-header baseline in `server/index.ts`: ```ts import { createSecurityHeadersHooks } from "@beignet/core/server"; const hooks = [ createSecurityHeadersHooks({ contentSecurityPolicy: "default-src 'self'; frame-ancestors 'none'", strictTransportSecurity: env.NODE_ENV === "production" ? { maxAgeSec: 31_536_000, includeSubDomains: true } : false, }), ]; ``` The hook adds common response headers and preserves route-owned headers. Keep CSP and HSTS explicit because asset hosts, embedded frames, redirects, and HTTPS rollout are app-owned deployment decisions. `doctor` warns when the central server does not install `createSecurityHeadersHooks(...)`. For credentialed browser requests: - Do not combine `credentials: true` with `origin: "*"` or `origins: ["*"]`. Use an explicit origin allow-list per environment. Beignet rejects this configuration in `createCorsHooks(...)`, and `doctor` warns about the same pattern before deploy. - Keep cookies `HttpOnly`, `Secure` in production, and `SameSite=Lax` or `SameSite=Strict` unless your auth flow requires cross-site cookies. For cookie-backed browser mutations, install `createCsrfHooks(...)`; keep provider webhooks and auth callbacks on explicit `skip` rules when another verifier owns the route. - CORS hooks short-circuit only browser preflights that include `Origin` and `Access-Control-Request-Method`; explicit `OPTIONS` contracts remain routable. ```ts import { createCsrfHooks } from "@beignet/core/server"; const csrf = createCsrfHooks({ allowMissingOrigin: false, trustedOrigins: ["https://app.example.com"], token: { cookieName: "csrf", headerName: "x-csrf-token", }, skip: ({ contract }) => contract.name.startsWith("webhooks."), }); const server = await createNextServer({ trustedProxy: { clientIp: "x-forwarded-for-last", }, hooks: [csrf, createRateLimitHooks()], // ... }); ``` Any control keyed by client IP is only as trustworthy as the header it reads. Clients can send arbitrary `x-forwarded-for` values; only the entry appended by your platform's trusted reverse proxy is reliable. Configure an explicit server-level `trustedProxy.clientIp` for IP-scoped limits; otherwise Beignet requires a custom `earlyKey` or explicit `ipSource: "none"` opt-out. The same policy lets `createCsrfHooks(...)` compare browser origins against external forwarded host and protocol values, and it supplies resolved `requestInfo` to the app context and logging hooks. See [Rate limiting](/rate-limiting) for the trusted-proxy details and key strategies. ## Providers Production providers should be installed in `server/providers.ts` when your app has that file, or directly in the central server setup. Provider startup and teardown belong to the application lifecycle, not route handlers. Use `beignet provider add ` for supported provider recipes such as OpenFeature flags, mail, Meilisearch search, Sentry error reporting, Upstash rate limiting, Redis cache, Redis event bus, Redis locks, and S3-compatible storage. The command writes dependency, provider, port, env, and setup-note changes that `beignet provider audit` and `beignet doctor --strict` can inspect. Check these before shipping: - Database clients and migrations are ready for the target environment. - Better Auth and the Beignet database provider share one module-scoped client when they use the same database; generated apps keep it in `infra/db/client.ts`. - Cache, mail, job, auth, logging, and rate-limit providers have required env. - Unit of Work and after-commit event behavior are tested with the real adapter. - Dev-only providers and devtools routes are gated appropriately. For durable workflows, verify each provider's failure semantics before relying on it in production: outbox adapters should preserve attempts, retry timing, leases, and dead-letter state, and job providers should document which retry behavior they own. In-memory providers are for tests, local development, or single-process apps; they are not a substitute for queues, workers, or durable outbox drains. Mail providers intentionally fail fast because sends are not idempotent; retry mail through jobs or outbox rows that own idempotency. Redis Pub/Sub event delivery is best-effort and cross-process, but it does not persist, replay, acknowledge, retry, or dead-letter event messages. The env-backed event-bus provider uses two connections per process and targets a standalone or managed single Redis endpoint. Run subscribers in long-lived processes; reconnect restores subscriptions but cannot recover events published while disconnected. Sentinel and Cluster deployments should inject app-owned ioredis clients and prove failover behavior against the selected host. Failed initial subscriptions retry with capped backoff while their listeners remain registered. S3-compatible storage delegates bounded transient retries to the AWS SDK when the provider creates the client; injected clients keep their own retry configuration. ## Runtime recipes Background and operational work should run from explicit bounded entrypoints. The web process should serve HTTP, cron routes should trigger one unit of work, workers should own repeated or queue-backed work, provider-backed function hosts should own provider invocations, and tasks should run from operator or CI commands. Do not start polling loops, queue consumers, or interval drains from provider `setup` or `start` hooks in serverless apps. Provider hooks should install ports, prepare clients, run bounded startup checks, and close resources. The host should decide when to invoke the work. See [Runtime recipes](/runtime-recipes) for the concrete layouts: web-only apps, cron routes, outbox drains, BullMQ workers, Inngest functions, `beignet schedule run`, `beignet outbox drain`, `beignet task run`, and provider readiness checks for Redis, S3-compatible storage, search, queues, and databases. ## Devtools Devtools are for local development by default. HTTP routes remain hidden until you configure authorization, and non-development environments additionally require explicit enablement. If you enable devtools in staging or an internal environment, add an `authorize` callback, keep event retention short, and redact sensitive fields before recording custom events. Beignet's doctor warns when a devtools route has no access policy. See [Devtools](/devtools) for the route options and local-only unauthenticated opt-in. ## Uploads and storage Every upload definition should set explicit file constraints: allowed content types, `maxSizeBytes`, and visibility. Uploads are protected by default, so add `authorize(...)` unless the workflow is intentionally public and declares `access: "public"`. Keep object keys tenant- or owner-scoped, default to private visibility, and keep direct-upload expiration windows short. Beignet's doctor warns when feature-owned upload definitions omit `maxSizeBytes` or authorization. See [Uploads](/uploads) and [Storage](/storage) for definition options, signature verification, checksums, scanner hooks, and object ownership. Public storage routes set `nosniff` and download active content types by default; keep that default unless the app intentionally serves active public assets from the application origin. ## Logging, audit, and redaction Logs, audit entries, and devtools events should help operators debug without leaking secrets or sensitive domain data. Use the shared `redactValue` and `redactHeaders` helpers from `@beignet/core/ports` for structured metadata, and store actor, tenant, request, and resource IDs instead of raw request or response bodies. Read [Privacy lifecycle](/privacy-lifecycle) before launch to define retention, deletion, and "what not to log" rules, and [Audit and activity logging](/audit) for durable activity records. ## Client base URLs Server-side code should usually call internal functions directly. When it must use the HTTP client, pass an absolute `baseUrl` to `createClient(...)`. Browser clients can use same-origin relative requests behind the deployment platform's routing layer. Keep client construction in `client/` so base URL and auth behavior have one home. ## Related pages - [Config](/config) for typed environment validation. - [Runtime recipes](/runtime-recipes) for web, cron, worker, provider-function, task, and readiness layouts. - [Schedules](/schedules), [Outbox](/outbox), [Jobs](/jobs), and [Tasks](/tasks) for the Beignet primitives those runtimes invoke. - [Authentication](/authentication) and [Authorization](/authorization) for request identity and business policy. - [Rate limiting](/rate-limiting) for trusted client IPs and key strategies. - [Privacy lifecycle](/privacy-lifecycle) for retention, export, deletion, anonymization, and sensitive-data boundaries. --- # Runtime recipes Source: https://www.beignetjs.com/runtime-recipes Beignet keeps runtime work behind explicit entrypoints. The web process serves HTTP. Cron routes trigger one bounded unit of work. Workers consume queues or repeat bounded commands. Provider-backed function hosts run provider-owned invocations. Tasks run when an operator, CI job, or release pipeline asks for them. That separation is intentional: providers install ports and manage clients, but they should not start unbounded polling loops, queue consumers, or drain intervals during server boot. Put repeated work in the runtime that owns its scaling, shutdown, health checks, and credentials. ## Runtime matrix Choose the smallest process layout that matches the work your app actually does: | If your app uses | Run this | Beignet surface | | --- | --- | --- | | Contracts, auth, UI, OpenAPI, devtools, uploads, storage routes, and webhooks | One web process | `createApiRoute(getServer)`, `createNextServer(...)`, or `createFetchServer(...)` | | Non-durable follow-up work that may be lost | The originating web process | `ctx.ports.bestEffortWork`, `createNextBestEffortWorkPort({ defer: after })` | | Best-effort cross-process events | Web processes with the Redis event bus provider registered | `ctx.ports.eventBus`, `createRedisEventBusProvider()` | | Durable events or outbox-backed jobs | Push-assisted drain plus a recovery cron, or a drain command | `createNextOutboxDrainTrigger(...)`, `createOutboxDrainRoute(...)`, `beignet outbox drain` | | BullMQ-backed jobs | Web process plus a worker process | `createBullMQJobWorker(...)` | | Inngest-backed jobs | Web process plus the Inngest route or function host | `createInngestJobFunctions(...)`, `serve(...)` | | Schedules | Web process plus a protected cron route, scheduled function, or command scheduler | `createScheduleRoute(...)`, `beignet schedule run ` | | Backfills, repairs, imports, exports, and release maintenance | One-off command | `beignet task run ` | Use a single web process when all work is request/response. Add a cron route when work needs an HTTP-triggered scheduler. Add a worker process when work is long-running, repeated, or queue-backed. Add a provider function host only when the provider owns invocation and retry semantics. ## Database clients by runtime Every process that imports the app's module-scoped `infra/db/client.ts` gets one database client or pool. Warm requests in that process reuse it through the cached server loader; never create the client inside a route or context factory. | Runtime | Database posture | | --- | --- | | Long-lived Node/Bun server | Use one bounded pool and close it during graceful server shutdown. | | Autoscaled container | Multiply the per-process pool maximum by the maximum replica count, including workers. | | Serverless function | Keep the per-instance pool small and prefer the database host's pooled endpoint. Warm instances reuse the module singleton; cold instances create another pool. | | Queue worker | Budget a separate pool for every worker process. Worker concurrency above pool size queues database work rather than creating extra connections. | | One-off task or migration | Use a short-lived client, close it before exit, and include concurrent release jobs in the connection budget. | | Local SQLite file | Run one writable host. Process-local transaction serialization does not coordinate multiple machines. | | Hosted libSQL | Reuse one remote client per process and follow the host's transaction and concurrency limits. | The web process and Better Auth should share the same client when they use the same database. Generated apps do this by injection and keep the shared client app/process-owned so retryable server initialization cannot close the client that auth still references. Their public server `stop()` closes it after a successful boot, which lets workers and one-off commands exit cleanly. See [Database and transactions](/database#connection-ownership-and-pool-sizing) for the budget formula and provider API. ## Web process The web process answers traffic only. It owns API routes, auth callbacks, webhooks, OpenAPI, devtools when enabled, uploads, storage routes, and process-local health endpoints. In Next.js, expose the central API from a catch-all route: ```typescript // app/api/[[...path]]/route.ts import { createApiRoute } from "@beignet/next"; import { getServer } from "@/server"; export const { DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT } = createApiRoute(getServer); ``` For Bun, Deno, Cloudflare Workers, Node fetch servers, and other Web Fetch runtimes, use `@beignet/web` and serve the `server.fetch` handler from the host that owns the process. Do not start queue consumers, outbox polling loops, schedule intervals, or long-running workers from the web process in serverless deployments. A container web process may make outbound provider calls while handling requests, but background repetition should still live in a worker, cron, or command runtime. ## Liveness and readiness Keep liveness and readiness separate: - `/api/health` is cheap and local. It answers whether the process can return a response. - `/api/ready` runs bounded dependency checks. It answers whether the process should receive traffic. Generated apps expose both endpoints. A readiness route can use `createHealthRoute(...)` from `@beignet/next` and provider-owned `checkHealth()` helpers: ```typescript // app/api/ready/route.ts import { createHealthRoute } from "@beignet/next"; import { env } from "@/lib/env"; import { getServer } from "@/server"; export const { GET } = createHealthRoute( getServer, { checks: { database: (ports) => ports.db.checkHealth(), cache: (ports) => ports.redis.checkHealth(), search: (ports) => ports.meilisearch.checkHealth(), storage: (ports) => ports.s3Storage.checkHealth(), }, timeoutMs: 2000, }, env.NODE_ENV, ); ``` Only include checks for providers your app actually installs. Readiness probes should be cheap and non-mutating: `select 1`, Redis `PING`, queue metadata, search health, storage bucket access, or a provider health endpoint. Do not run migrations, outbox drains, schedule handlers, queue consumers, imports, or backfills from readiness routes. First-party providers expose these useful readiness helpers: | Provider area | Port or escape hatch | Probe | | --- | --- | --- | | Drizzle database | `ctx.ports.db` | `checkHealth()` | | Redis cache | `ctx.ports.redis` | `checkHealth()` | | Redis event bus | `ctx.ports.redisEventBus` | `checkHealth()` | | Redis locks | `ctx.ports.redisLocks` | `checkHealth()` | | Upstash rate limiting | `ctx.ports.upstash` | `checkHealth()` | | BullMQ jobs | `ctx.ports.bullMQJobs` | `checkHealth({ timeoutMs })` | | Meilisearch | `ctx.ports.meilisearch` | `checkHealth()` | | S3-compatible storage | `ctx.ports.s3Storage` | `checkHealth()` | | Vercel Blob storage | `ctx.ports.vercelBlob` | `checkHealth()` | For worker readiness, expose the same kind of bounded check from the worker host or run it before accepting work. A BullMQ worker, for example, should prove Redis and the app database are reachable before it starts claiming jobs. ## Cron and schedules Use cron routes when the deployment platform owns the schedule trigger. In Next.js apps, `createScheduleRoute(...)` keeps the route small: it authenticates with `CRON_SECRET`, runs the schedule through the server request pipeline, records instrumentation, and returns a status: ```typescript // app/api/cron/digests/daily-digest/route.ts import { createScheduleRoute } from "@beignet/next"; import { env } from "@/lib/env"; import { getServer } from "@/server"; import { schedules } from "@/server/schedules"; export const runtime = "nodejs"; export const { GET, POST } = createScheduleRoute({ server: getServer, schedules, schedule: "digests.send-daily", secret: env.CRON_SECRET, source: "vercel-cron", }); ``` Use `beignet schedule run` when the host can run a command instead of calling HTTP, such as a release job, container worker, CI job, or scheduler with command support: ```bash beignet schedule run digests.send-daily --scheduled-at 2026-01-01T09:00:00.000Z ``` Schedules are trigger definitions. When missed or failed work needs durable retry and dead-letter behavior, keep the schedule handler small and enqueue a job or write an outbox message. ## Outbox drains Use `createOutboxDrainRoute(...)` for deployments where a platform cron invokes HTTP. The route should drain one bounded batch and exit: ```typescript // app/api/cron/outbox/drain/route.ts import { createOutboxDrainRoute } from "@beignet/next"; import { env } from "@/lib/env"; import { getServer } from "@/server"; import { outboxRegistry } from "@/server/outbox"; export const runtime = "nodejs"; export const { GET, POST } = createOutboxDrainRoute({ server: getServer, registry: outboxRegistry, secret: env.CRON_SECRET, batchSize: 100, }); ``` Use the CLI when the host can run a bounded command: ```bash beignet outbox drain --batch-size 100 ``` Both paths use the same `server/outbox.ts` registry. The HTTP route creates context through the server request pipeline; the CLI path uses `createOutboxDrainContext(...)` from `server/outbox.ts` to build an app service context. A long-running worker may repeat bounded drain passes, but the loop belongs to the worker host, not provider lifecycle hooks. On Next.js 15.1 or newer, `createNextOutboxDrainTrigger(...)` can pass one bounded drain to `after()` after a successful transaction. In `server/providers.ts`, use `createObservedUnitOfWork(...)` to replace the base database Unit of Work and resolve `server/outbox.ts` lazily inside the deferred callback. Keep the cron route as a recovery sweep, typically around every 15 minutes, because `after()` is a latency optimization rather than a durable wake-up. Each push-assisted trigger performs one batch. It does not wait for delayed messages or scheduled retries, and a process interruption can prevent the callback from running. Tight retry latency requires a durable jobs provider; older Next.js apps use the same cron-only route without the trigger. For work whose loss is acceptable, such as an ephemeral cache-invalidation hint, `BestEffortWorkPort` provides a smaller boundary than an outbox trigger. On Next.js 15.1 or newer, install `createNextBestEffortWorkPort({ defer: after, onError })` from `server/providers.ts`. Scheduler and callback failures cannot change the originating response, so the callback must not own required delivery or the authoritative mutation. See [Ports and adapters](/ports#defer-best-effort-work) for wiring and deterministic test support. This adapter is request-bound; workers and commands need an adapter supplied by their own runtime. ## Workers Worker processes own long-running or repeated background work. They can run queue consumers, repeated outbox drains, or command schedulers, and they should still call Beignet primitives internally so app context, ports, logging, audit, and devtools instrumentation remain consistent. For BullMQ, consume an explicit list of Beignet job definitions with `createBullMQJobWorker(...)`: ```typescript // server/workers/jobs.ts import { createBullMQJobWorker } from "@beignet/provider-jobs-bullmq"; import { SendWelcomeEmailJob } from "@/features/users/jobs"; import { getServer } from "@/server"; const server = await getServer(); export const jobsWorker = createBullMQJobWorker({ queueName: process.env.BULLMQ_QUEUE_NAME ?? "beignet-jobs", redisUrl: process.env.BULLMQ_REDIS_URL ?? "redis://localhost:6379/0", prefix: process.env.BULLMQ_PREFIX ?? "beignet", jobs: [SendWelcomeEmailJob], ctx: () => server.createServiceContext(), workerOptions: { concurrency: 5, }, errorReporter: ({ ctx }) => ctx.ports.errorReporter, infrastructureErrorReporter: server.ports.errorReporter, }); const health = await jobsWorker.checkHealth({ timeoutMs: 5_000 }); if (!health.ok) { throw new Error(health.error.message); } ``` Give workers the same provider environment they need to build app ports, plus worker-specific settings such as queue Redis URLs, concurrency, and shutdown timeouts. Handle process shutdown by closing workers so BullMQ stops claiming new work and gives active work a chance to finish. The app entrypoint should also enforce a deadline shorter than the host's termination grace period, then stop the server-owned database, telemetry, and other resources after the worker has drained. URL-created producers fail quickly during Redis outages; URL-created workers use persistent consumer retry behavior. Explicit connection objects remain app-owned. Keep BullMQ Redis on `maxmemory-policy=noeviction`, configure persistence, bound completed and failed retention, and keep sensitive data out of job payloads. Prefer the outbox when work must commit atomically with database writes. Prefer provider-backed jobs when the provider should own queueing, scheduling, invocation, and retry behavior. ## Provider-backed functions Inngest owns queueing, invocation, and retry behavior for direct Inngest-backed jobs. The Beignet provider installs `ctx.ports.jobs` for dispatch and exposes helpers that map Beignet job definitions to Inngest functions: ```typescript // server/inngest.ts import { createServiceActor } from "@beignet/core/ports"; import { createInngestJobFunctions } from "@beignet/provider-jobs-inngest"; import { userJobs } from "@/features/users/jobs"; import { inngest } from "@/infra/inngest"; import { getServer } from "@/server"; export const inngestJobs = [...userJobs] as const; export const inngestFunctions = createInngestJobFunctions({ client: inngest, jobs: inngestJobs, ctx: async () => { const server = await getServer(); return server.createServiceContext({ actor: createServiceActor("beignet-inngest"), }); }, instrumentation: async () => (await getServer()).ports, errorReporter: async () => (await getServer()).ports.errorReporter, }); ``` ```typescript // app/api/inngest/route.ts import { serve } from "inngest/next"; import { inngest } from "@/infra/inngest"; import { inngestFunctions } from "@/server/inngest"; export const { GET, POST, PUT } = serve({ client: inngest, functions: inngestFunctions, }); ``` Treat provider-backed functions as worker entrypoints, not web request handlers. They need the same service-context decisions as other background work: actor, tenant, app ports, logging, audit, and failure reporting. The generated lazy resolvers avoid server boot during route-module import. Use `INNGEST_DEV=1` only for local development; production cloud execution needs both `INNGEST_EVENT_KEY` and `INNGEST_SIGNING_KEY`. For Web Fetch and other non-Next hosts, the provider preset generates `createAppInngestFunctions(...)` instead of assuming `getServer()` exists. Create the app server through the host's normal lifecycle, supply that server's service-context factory and ports, and pass the resulting functions to Inngest's runtime adapter. ## Best-effort events The Redis event bus provider gives cross-process Pub/Sub delivery while subscribers are online. Every process that should receive an event must register its listeners in that process. If a web process publishes an event and no worker is subscribed, Redis does not replay the missed message later. The env-backed provider opens one publisher and one subscriber connection per process and targets a standalone Redis URL or managed service with one Redis-compatible endpoint. ioredis reconnects and restores subscriptions after a transient disconnect, but events sent during the disconnected interval are not replayed. Include both connections in the process connection budget and host subscribers in long-lived web or worker processes, not ephemeral request-only runtimes. If the first subscription command fails after ioredis exhausts its command retries, Beignet retries the desired subscription with capped backoff until it succeeds or its final local handler unsubscribes. The generated listener provider waits for `registerListeners(...).ready` in its `start()` hook before server creation resolves. The registry has one 10-second startup deadline by default, starts every child cleanup when registration fails, and reports cleanup that misses the remaining deadline without hanging listener startup. Set `readyTimeoutMs` explicitly to tune that policy. Direct adapter users should also await each subscription's `ready` promise before advertising process readiness. This proves initial Redis acknowledgement, not continuing delivery after startup or replay across a reconnect gap. Sentinel and Cluster topologies require app-owned ioredis constructor options. Create those clients in app infrastructure and adapt them with `createRedisEventBus(...)`; validate subscription recovery against the exact host because Beignet's live suite currently proves only the single-endpoint topology. HTTP-only Redis APIs cannot host the persistent Pub/Sub subscriber. On shutdown, await the direct adapter's `stop()` before closing or reusing the app-owned clients so its message listener, retry timers, and Redis channel subscriptions are released. Cleanup failures reject after Beignet retries channels left dirty by an earlier unsubscribe. Adapter cleanup has a 10-second deadline by default; set `cleanupTimeoutMs` for injected clients or `REDIS_EVENT_BUS_CLEANUP_TIMEOUT_MS` for the env-backed provider. The provider force-disconnects its owned clients when that deadline expires. Direct-adapter callers retain client ownership and should do the same when `stop()` rejects. Stopping is terminal for that adapter; create a new adapter before registering more listeners. The provider health check pings both connections but does not prove that every expected listener registered its channel. Use Redis Pub/Sub for best-effort notifications, cache invalidation, or in-process fan-out where missed messages are acceptable. Use the outbox or a job provider for workflow-critical side effects that need persistence, retry, acknowledgement, or dead-letter state. ## One-off tasks Use app tasks for backfills, repairs, imports, exports, and release maintenance: ```bash beignet task run posts.backfill-search --input '{"dryRun":true}' ``` Tasks should call use cases and ports rather than reaching into infra directly. Prefer CLI entrypoints over exposed HTTP routes for work that operators or CI trigger by hand. Pass `--tenant` when the app's `createTaskContext(...)` resolves operational tenant scope separately from the task input payload. ## Runtime checklist Before shipping a runtime topology, confirm: - The web process only serves HTTP and health routes. - Every cron route or operational HTTP route is protected with a secret or app-owned authorization. - Every scheduled or drain invocation does one bounded unit of work. - Every worker has readiness checks for the dependencies it must reach before claiming work. - Provider lifecycle hooks install ports, prepare clients, and close resources, but do not start unbounded loops. - Best-effort event buses are not used for workflow-critical delivery. - Deferred work is requested only after the authoritative mutation succeeds, and losing its callback is acceptable. - Durable side effects use outbox rows, jobs, idempotency, or provider-owned retry semantics. ## Related pages - [Going to production](/deployment) for preflight checks, secrets, host settings, and production hardening. - [Schedules](/schedules) for schedule definitions and cron route behavior. - [Outbox](/outbox) for transactional delivery, retries, and dead-letter state. - [Jobs](/jobs) for BullMQ and Inngest provider behavior. - [Tasks](/tasks) for operational task definitions and CLI execution. - [Providers](/providers) for lifecycle and setup order. --- # Privacy lifecycle Source: https://www.beignetjs.com/privacy-lifecycle Privacy is an application lifecycle concern, not a single Beignet primitive. Beignet gives you ports, use cases, hooks, audit logs, storage, uploads, and redaction helpers so each app can make sensitive data ownership explicit. Design the lifecycle before production data arrives: classify the data you collect, decide where it is allowed to live, define retention and deletion behavior, keep exports reproducible, and redact data before it reaches logs, devtools, error reporters, or vendor metadata. ## Data classification Classify app data by risk before choosing what to store or emit: | Class | Examples | Default handling | | --- | --- | --- | | Stable identifiers | `userId`, `tenantId`, `requestId`, `resourceId` | Safe for logs, audit records, and error context when access is controlled | | Business metadata | status, counts, feature names, timestamps | Usually safe when it does not reveal private content | | User content | messages, bios, comments, uploads, form answers | Store in app repositories or storage only; avoid diagnostic sinks | | Secrets | passwords, tokens, cookies, provider credentials, API keys | Never log, audit, report, or expose | | Regulated data | PHI, payment details, government IDs, legal records | Store only behind explicit product, retention, and vendor controls | Prefer stable IDs over raw content in every operational system. Logs, audit records, devtools, and error reporters should help an operator find the app record that owns the data, not duplicate the data. ## Retention Retention should be owned by app policy and implemented through use cases, repositories, storage adapters, and schedules. Define retention windows for each durable surface: | Surface | Typical owner | Retention question | | --- | --- | --- | | Primary tables | Feature repositories | How long does the product need this record? | | Audit logs | Audit adapter and compliance policy | How long must activity be explainable? | | Object storage | Upload definitions and attachment repositories | When should files be deleted, archived, or quarantined? | | Outbox and job state | Background workflow adapters | When can completed or failed work be pruned? | | Devtools persistence | Development tooling config | Is persistence disabled or short-lived outside local development? | | Logs and error reporting | Provider settings | Does the vendor retention match your data classification? | Use [schedules](/schedules) for routine cleanup, and keep cleanup idempotent: deleting the same record twice should be safe, because scheduled work can retry. ## Export Exports should be explicit use cases, not ad hoc database reads. An `exportUserData` use case authorizes the actor, reads through feature repositories, records an audit entry with counts instead of content, and returns only the fields the user should receive. That keeps authorization, tenancy, audit logging, and redaction in one workflow. Do not export provider credentials, internal IDs that are not meaningful to the user, audit records about other actors, or records outside the tenant boundary. ## Deletion and anonymization Use deletion when the product and legal model allow the record to disappear. Use anonymization when the app must preserve aggregate history while removing personal identity — for example, a `deleteUserAccount` use case that anonymizes the user row, tombstones attachments, and records an audit entry inside one Unit of Work transaction. Good deletion workflows: - authorize the actor and tenant at the use-case boundary - delete or anonymize feature-owned records through repositories - remove or tombstone object-storage attachments - revoke active sessions and tokens - enqueue background cleanup when providers need asynchronous deletion - audit that the deletion request was processed without storing the deleted private content Deletion does not automatically remove data already copied into logs, error reporters, vendor dashboards, backups, analytics, or support exports. That is why diagnostic surfaces should avoid raw private content in the first place. ## Redaction Use the `redactValue` and `redactHeaders` helpers from `@beignet/core/ports` for generic sensitive structures such as provider payloads and request headers. Generic redaction is not enough for domain privacy: add app-owned redaction for fields such as patient notes, private messages, payment descriptions, and free-form text. Keep redaction allow-list based when possible. It is safer to choose the fields that may leave the application than to strip a few known-sensitive fields from a large object. ## What not to log Do not send these values to logs, devtools, audit metadata, error reporters, job metadata, provider instrumentation, or alert payloads: - passwords, password reset tokens, magic links, session tokens, API keys, cookies, authorization headers, and provider credentials - raw request bodies, multipart upload bodies, private messages, comments, notes, form answers, and user-authored files - PHI, payment details, government IDs, financial account numbers, and legal documents - full provider responses when the provider may echo sensitive request data - presigned upload URLs, signed download URLs, or URLs containing access tokens - unbounded objects whose shape may grow to include private fields later Log stable references instead: request, tenant, actor, and resource IDs, plus hashes for values an operator may need to correlate. ## Testing privacy behavior Privacy behavior should be testable like any other application workflow. Add focused tests for export use cases only returning authorized tenant data, deletion use cases removing or anonymizing every feature-owned record, audit records using stable IDs instead of deleted content, and scheduled cleanup being idempotent. Memory ports make these tests cheap: capture logs, audit entries, jobs, and mail in memory, then assert on the emitted metadata. ## Related pages - [Going to production](/deployment) for the pre-launch checklist. - [Audit and activity logging](/audit) for durable business activity records. - [Logging](/logging) for structured diagnostic logs. - [Error reporting and alerting](/error-reporting) for production exception capture. - [Storage](/storage) and [Uploads](/uploads) for file ownership and cleanup. - [Schedules](/schedules) for retention and cleanup jobs. --- # CLI Source: https://www.beignetjs.com/cli `@beignet/cli` creates Beignet apps, generates feature slices and workflow artifacts, runs database and operational entrypoints, inspects route wiring, and catches drift after manual edits. Use [Quickstart](/getting-started) to create and run your first app; use this page as the command reference. Beignet requires Node.js 22.12 or newer. Bun 1.3.14 or newer is supported for CLI commands. Scaffold a new app through your package manager's `create` command. Inside a generated app, `@beignet/cli` is already a dev dependency with a `beignet` package script. Or run the scoped package without installing: ```bash bun create beignet my-app bun beignet lint npm run beignet -- lint bunx @beignet/cli doctor --strict npx @beignet/cli doctor --strict ``` Always pass the scoped name (`@beignet/cli`) to `bunx`, `npx`, `pnpm dlx`, or `yarn dlx` — the unscoped npm name `beignet` belongs to an unrelated package. Reference blocks below show the bare `beignet ` syntax; prefix it with your package manager. `beignet --version` prints the installed CLI version. ## Commands | Command | What it does | | --- | --- | | `beignet create [directory]` | Scaffold a new Beignet app. | | `beignet make ` | Generate feature slices and workflow artifacts. | | `beignet db ` | Run the app's database lifecycle scripts. | | `beignet routes` | Inspect contract-to-route wiring. | | `beignet map` | Map app architecture and workflow relationships. | | `beignet explain ` | Explain one mapped concept with source evidence. | | `beignet check` | Run the full validation loop as one command. | | `beignet lint` | Enforce architecture dependency direction. | | `beignet doctor` | Report framework drift, optionally fixing it. | | `beignet provider add ` | Add provider dependencies, wiring, env examples, and setup notes. | | `beignet provider audit` | Inventory installed provider setup without failing CI. | | `beignet task run ` | Run an app-owned operational task. | | `beignet schedule run ` | Run an app-owned schedule once. | | `beignet outbox drain` | Run one bounded outbox drain pass. | | `beignet mcp` | Run an MCP server exposing CLI tools to coding agents. | | `beignet completion ` | Manage bash or zsh completions. | ## create ```bash bun create beignet my-app ``` In an interactive terminal without selection flags, `create` prompts for the project directory, whether the app is API-only, which database to use, and which providers to add. A selection flag (`--api`, `--db`, or `--providers`) skips the prompts, `--yes` forces the defaults, and non-TTY environments never see prompts. There is one full-stack starter; `--api` drops the UI shell and app pages while keeping the same architecture, and `--db` picks the database backend (`sqlite` by default). The CLI writes files only — see [Quickstart](/getting-started) for what the starter contains and the install, environment, migrate, and first-run steps, and [Database and transactions](/database) for what each `--db` backend scaffolds. Better Auth, Drizzle persistence, Pino, and no-op error reporting are part of every starter, so passing them to `--providers` is an error. Providers add external service providers on top: the provider package, peer dependencies, wiring in `server/providers.ts`, `.env.example` entries, and setup notes in `docs/providers.md`. | Provider | Adds | | --- | --- | | `jobs-inngest` | `@beignet/core/jobs`, `@beignet/provider-jobs-inngest`, and `inngest` | | `mail-resend` | `@beignet/provider-mail-resend` and `resend` | | `rate-limit-upstash` | `@beignet/provider-rate-limit-upstash`, `@upstash/ratelimit`, and `@upstash/redis` | `--providers` accepts the full [`provider add`](#provider-add) preset catalog. Presets are applied to the fresh scaffold with the same machinery as `beignet provider add`, so create-time and post-create setup stay identical; examples include `event-bus-redis`, `cache-redis`, `jobs-bullmq`, `storage-s3`, and `search-meilisearch`. Starter-native presets (`jobs-inngest`, `mail-resend`, and `rate-limit-upstash`) are rendered directly, and selections that fill the same app port (for example `mail-resend` plus `mail-smtp`) fail before any files are written. The interactive prompt keeps the short template list and points at the wider catalog. `create --dry-run` applies those same preset transformations in memory, so its human and JSON file lists match a real create without writing the target directory. | Option | Description | | --- | --- | | `--template ` | App template. `next` is the only template today. | | `--api` | Scaffold an API-only app without the UI shell. | | `--db ` | Database backend: `sqlite` (default), `postgres`, or `mysql`. | | `--package-manager ` | `bun`, `npm`, `pnpm`, or `yarn`, used in printed next steps. | | `--providers ` | One value or a comma-separated list of provider presets. | | `--yes` | Skip interactive prompts and use the defaults. | | `--force` | Write into a non-empty directory. | | `--dry-run` | Preview planned writes without creating files. | | `--json` | Print the plan or result as JSON. | ## make Generators run inside an app and target the canonical structure described in [App architecture](/app-architecture). All of them — along with `routes`, `map`, `lint`, and `doctor` — resolve `beignet.config.*` (`.ts`, `.json`, `.mjs`, or `.js`) first. Set `framework: "next"` (the default) for `@beignet/next` apps or `framework: "web"` for servers built on the standard Fetch adapter from `@beignet/web`. In the web profile, `routes` and `doctor` derive the exposed HTTP surface from canonical feature route groups in the central `defineRoutes(...)` registry passed to `createFetchServer(...)`; they do not require `app/api/` or Next.js route handlers. Vite and Bun do not need separate values because they are client tooling and a Fetch host rather than Beignet server adapters. Omitted paths fall back to the generated defaults. The same config can declare app-owned operational table names, such as `database.tables.audit: "audit_events"`, so `doctor` checks Drizzle-backed audit, idempotency, and outbox wiring against the names your app uses. Add `database.schemaSources` when those table definitions live in a shared package or non-standard app path, for example `["@acme/db/schema"]`. Apps that satisfy a provider requirement through an injected platform client can list that specific static-audit exception under `providerAudit.ignoreRequiredEnv`; runtime env validation is unchanged. Generators are idempotent: repeated runs skip identical files and avoid duplicate wiring, and a generated file that diverged stops the command unless you pass `--force`. Framework-neutral generators such as `make feature`, `make contract`, and `make schedule` work in both profiles. Commands that currently generate Next.js handlers — `make payments`, `make upload`, `make outbox`, and `make schedule --route` — reject `framework: "web"` before writing files. `create` also remains a Next.js scaffold today. Every `make` command accepts: | Option | Description | | --- | --- | | `--dry-run` | Preview generated changes without writing files. | | `--json` | Print the planned or written changes as JSON. | | `--force` | Overwrite generated files that diverged. | | `--cwd ` | Generate against an app root in another directory. | ### make feature ```bash beignet make feature projects ``` Generates the contract-first vertical slice for a product capability: `contracts.ts`, `schemas.ts`, `use-cases/`, `ports.ts`, `routes.ts`, a test file, and a Drizzle repository adapter. It registers the route group in `server/routes.ts`, the port in `ports/index.ts`, and the repository in `infra/db/repositories.ts`; OpenAPI routes that use route registration stay in sync automatically. The generated `name` field is a placeholder — reshape the slice around the real workflow. | Option | Description | | --- | --- | | `--with ` | Adds feature-owned artifacts: `policy`, `factory`, `seed`, `task`, `event`, `listener`, `job`, `notification`, `schedule`, `ui`, or `upload`. | | `--recipe full-slice` | Adds the canonical full-slice recipe: policy, factory, seed, task, event, listener, job, notification, schedule, UI client helpers, component, upload, and outbox wiring. | Each addon writes the same output as the matching standalone generator; `ui` writes feature-colocated React Query helpers and a contract-backed React Hook Form component. The generated form uses the contract body schema for field validation, maps mutation failures to the root form error, resets after success, and invalidates the generated list query. It creates the canonical `client/forms.ts` adapter and adds the required form dependencies when they are missing. When `ui` and `upload` are both selected, the upload addon also emits a typed React upload client, uploader component, and component test. Use `--recipe full-slice` when you want a richer reference slice: ```bash beignet make feature projects --recipe full-slice ``` The recipe keeps the base `contracts.ts`, `schemas.ts`, `use-cases/`, `ports.ts`, `routes.ts`, and tests, then adds feature-owned workflow artifacts around them. The generated create use case publishes the generated `ProjectCreated` event, and the event/job generators add `server/outbox.ts`, `infra/db/schema/outbox-messages.ts`, the outbox drain route, and the required port wiring. The listener generator registers the feature listener registry in `server/listeners.ts` and wires the central listener provider in `server/providers.ts`. Replace the starter `name` field, logger messages, listener body, job body, notification payload, and task body with the real workflow. When the workflow needs durable audit records, follow [Audit and activity logging](/audit) and record the business action inside the same Unit of Work transaction as the write. ### make resource ```bash beignet make resource projects beignet make resource projects --authorization --tenant-scoped --events --soft-delete ``` Generates a CRUD-shaped slice when the concept is an entity with repository-backed persistence: list, create, get, update, and delete contracts, use cases, route handlers, repository methods, a policy starter, tests, feature-specific not-found and conflict catalog errors, a Drizzle schema file, and repository registration. Generated list endpoints use cursor pagination with an explicit query transport shared by the client, server, and OpenAPI document. Updates use optimistic concurrency `version` checks that turn stale writes into the generated conflict error. See [Build your first feature](/build-first-resource) for the guided flow. | Option | Description | | --- | --- | | `--authorization` | Authorization metadata, policy wiring, `ctx.gate.authorize(...)` checks, and a policy matrix test. | | `--tenant-scoped` | Tenant-scoped schemas, `TenantScope` repository boundaries, and use-case checks. | | `--events` | Created, updated, and deleted domain events published through `ctx.ports.eventBus`. | | `--soft-delete` | Archive rows with `deletedAt` instead of hard-deleting. | `--events` also wires the event bus when the app has none, exactly like `make event` (see workflow generators below). ### make contract ```bash beignet make contract projects ``` Writes `features/projects/contracts.ts` with a starter contract group, schema, and standard error response. It does not wire routes, use cases, or ports — use `make feature` for the full slice. See [Contracts](/contracts). ### make use-case and make test ```bash beignet make use-case projects.archive-project beignet make test projects.archive-project ``` `make use-case` writes `features/projects/use-cases/archive-project.ts` and updates the use-case index; actions starting with `get`, `list`, `find`, `search`, or `count` generate `.query(...)`, others `.command(...)`. `make test` writes `features/projects/tests/archive-project.test.ts` using the test context helpers. See [Application](/application) and [Testing](/testing). ### make port, make adapter, and make policy ```bash beignet make port email beignet make adapter email beignet make policy posts ``` `make port` writes `ports/email.ts`, adds the port to `AppPorts`, creates a test fake, and wires a throwing infra stub so the app still typechecks. `make adapter` writes `infra/email/email-adapter.ts` and replaces the stub; it stops instead of guessing when the infra wiring was customized. `make policy` writes `features/posts/policy.ts` with a `definePolicy(...)` starter. See [Ports](/ports) and [Authorization](/authorization). ### Workflow generators ```bash beignet make event posts.published beignet make listener posts.enqueue-published-email --event posts.published beignet make job posts.send-published-email beignet make notification posts.published beignet make outbox beignet make schedule posts.daily-summary --cron "0 9 * * *" --timezone America/Chicago --route beignet make task posts.backfill-search beignet make upload posts.attachment beignet make upload posts.attachment --ui ``` Names use `feature.name` format. Each generator writes the colocated feature file (for example `features/posts/jobs/send-published-email.ts`), creates or updates the folder's registry `index.ts` (`postEvents`, `postJobs`, and so on), and creates the matching app-bound `lib/` builder such as `lib/jobs.ts` on first use. New apps do not scaffold workflow folders; generators create them on demand. They also keep central registries and ports wired: - `make schedule` and `make task` create or update the `server/schedules.ts` and `server/tasks.ts` registries used by the runners below. - `make listener` creates or updates the `server/listeners.ts` registry and wires that central registry with `registerListeners(...)`. Its generated provider registers in `start()`, awaits the registry's initial readiness with the default 10-second deadline, and awaits cleanup in `stop()`. - `make outbox` creates `server/outbox.ts` and a bounded `app/api/cron/outbox/drain` route. On Next.js 15.1 or newer it also adds a server-local provider that wraps the database Unit of Work with a push-assisted `after()` drain; earlier versions keep cron-only wiring. The `make event` and `make job` commands create that outbox path on first use and append the feature's registries to `defineOutboxRegistry({...})`. See [Outbox](/outbox). - `make job` ensures `jobs: JobDispatcherPort` is declared and bound. It installs an app-owned inline provider only when no direct or deferred job dispatcher exists, preserving providers such as Inngest. Adding an Inngest or BullMQ preset later replaces only the marked generated fallback and rejects unmarked custom inline wiring as a conflict. - `make event` and `make resource --events` wire `eventBus: EventBusPort` into `AppPorts`, register `createMemoryEventBusProvider()` in `server/providers.ts`, and add the `@beignet/provider-event-bus-memory` dependency when the ports file lacks the key. - `make notification` wires `mailer: MailerPort` with `createMemoryMailerProvider()` and `notifications: NotificationPort` with `createInlineNotificationsProvider()`, each independently and only when missing, so providers such as Resend and app-owned adapters are left untouched. Swap the dev-default providers when you outgrow them. - `make listener` requires `--event` and expects the event file to exist; run `make event` first. - `make schedule --route` writes `app/api/cron///route.ts`, which requires `CRON_SECRET`, and adds a generated `CRON_SECRET` to `lib/env.ts` and `.env.example` when the app does not define one. - `make upload --ui` adds a shared client/server constraint manifest, typed upload client, React uploader, and component test. It is available in full-stack apps; API-only apps keep the backend upload workflow. - `make upload` validates central registration before writing. If a customized upload route no longer contains `uploadRegistry = defineUploads({ ... })`, it fails with the registry entry to add manually instead of leaving a generated upload unregistered. | Command | Options | | --- | --- | | `make listener` | `--event ` (required). | | `make schedule` | `--cron ` (defaults to `0 9 * * *`), `--timezone `, `--route`. | | `make upload` | `--ui` to add the connected React upload workflow. | Concept pages: [Events](/events), [Jobs](/jobs), [Schedules](/schedules), [Notifications](/notifications), and [Uploads](/uploads). Operational tasks are app-owned entrypoints for backfills, maintenance, and one-off repair work; they should call use cases or ports rather than copying business rules into scripts. ### make factory and make seed ```bash beignet make factory posts.post beignet make seed posts.demo-posts ``` `make factory` writes `features/posts/tests/factories/post.ts` plus a factory registry; the starter persists through repository ports. `make seed` writes `features/posts/seeds/demo-posts.ts` plus a seed registry, creates the app-owned `server/seed.ts` entrypoint when it is missing, and adds the `db:seed` script. The entrypoint lives in `server/` because it boots the app through `getServer()`, which `beignet lint` forbids from `infra/`. See [Database](/database). ### make tenancy ```bash beignet make tenancy beignet db generate beignet db migrate beignet db status ``` Generates a workspace tenancy slice for Drizzle-backed apps: a `features/workspaces` feature with contracts, use cases, a membership-aware policy, repositories for workspaces, members, and invites, three Drizzle tables, invite email notifications, tests, and a demo-workspace seed with a sign-in-able demo admin. It replaces the starter `lib/tenant.ts` and `server/context.ts` so every request resolves the active workspace from the user's memberships and the `beignet-workspace` cookie, and adds `membership` to the app context. On apps with the frontend shell it also emits workspace and member settings pages, an invite accept screen, a workspace switcher, and settings navigation entries. `make tenancy` only replaces `lib/tenant.ts` and `server/context.ts` when they still match the starter template (or its own output); customized files abort the generator with manual instructions before anything is written, and `--force` overrides. Routes live under `/api/workspaces`; switching workspaces sets the `beignet-workspace` cookie through a route `handle` escape hatch. See [Authorization](/authorization#workspace-tenancy). ### make payments ```bash beignet make payments beignet db generate beignet db migrate beignet db status ``` Generates a payments-backed billing slice for Drizzle-backed apps: a `features/billing` feature with a free/pro plan model, `FREE_PLAN_LIMITS` quotas, a billing-backed entitlements port, a `billing_accounts` table and repository, checkout/portal/status contracts and use cases, an idempotent `app/api/webhooks/payments/route.ts` webhook route, a demo billing seed, and `BILLING_PRO_PRICE_ID` env validation. Local development uses the memory payments provider; swap `server/providers.ts` to the Stripe provider when credentials are configured. On apps with the frontend shell it also emits a plan settings page at `/settings/plan`. Billing accounts scope to the user until `make tenancy` adds workspaces, then become workspace-scoped automatically. See [Payments](/payments). ### make inbox ```bash beignet make inbox beignet db generate beignet db migrate beignet db status ``` Generates an in-app inbox notifications slice for Drizzle-backed apps: a `features/inbox` feature with cursor-paginated list, unread-count, mark-read, and mark-all-read contracts and use cases, an `inbox_notifications` table and repository, an in-app notification channel (`defineInboxNotificationChannel`), a sample notification, seeds, and tests. The inbox is personal: rows are scoped to the signed-in user and need no tenancy. On apps with the frontend shell it also emits an `/inbox` page, an unread badge component, and a sidebar navigation entry. Add the in-app channel to any feature notification to deliver into the inbox. See [Notifications](/notifications#in-app-inbox-notifications). ## db ```bash beignet db schema sync beignet db generate beignet db migrate beignet db status beignet db seed beignet db reset ``` `db schema sync` idempotently brings the app-owned Drizzle schema re-exports of Beignet provider tables in sync with the installed providers, currently for Beignet's audit, idempotency, and outbox tables. Run it before `db generate` when you add those operational ports. Use `--tables` to sync only the table definitions for the ports in the next migration. The database commands (`generate`, `migrate`, `status`, `seed`, and `reset`) delegate to the app-owned package script of the same name (`db:generate`, `db:migrate`, `db:status`, `db:seed`, `db:reset`) and check prerequisites first — a missing script, missing `drizzle.config.*`, or removed status/seed/reset entrypoint produces an error naming the exact file to restore. The starter ships `db:generate`, `db:migrate`, `db:status`, and `db:reset`; run `db migrate` first since the initial migration is vendored, and add a `db:seed` script with your first feature seeds. For `generate`, `migrate`, `seed`, and `reset`, `--dry-run` validates prerequisites and reports the app-owned script without executing it; it does not simulate the script's SQL or data changes. Status is already read-only and always runs its inspection. See [Database](/database). | Option | Description | | --- | --- | | `--dialect sqlite\|postgres\|mysql` | Select the schema dialect for `db schema sync`; otherwise inferred from `server/providers.ts` or `drizzle.config.*`. | | `--tables audit,idempotency,outbox` | Select which provider tables `db schema sync` writes; defaults to all three. | | `--output ` | Select the synced schema file for `db schema sync`; defaults to `infra/db/schema/beignet.ts`. | | `--dry-run` | Print the command that would run without running it; lifecycle commands do not simulate SQL or data changes. | | `--json` | Print the script, runner, and captured output as JSON; retains the final 64 KiB per stream and reports `outputTruncated` when needed. | ## routes ```bash beignet routes ``` Prints a table of method, path, contract export, and matched Next.js handler file for every contract the CLI can inspect. It supports contract-group definitions and direct `defineContract({ method, path })` exports. Exported declarations and fluent calls are read from TypeScript syntax, so nested objects, comments, and multiline expressions do not truncate contract inspection. Local fluent-builder aliases and named local exports are followed. Factories must be value imports from `@beignet/core/contracts`; named aliases and namespace imports work, while same-named methods on unrelated objects are ignored. | Option | Description | | --- | --- | | `--json` | Machine-readable route list. | | `--cwd ` | Inspect an app root in another directory. Must point at an app, not a monorepo root. | ## map ```bash beignet map beignet map --feature issues beignet map --json --kind route,use-case,event,listener beignet map --changed beignet map --changed --base origin/main --json ``` Builds a deterministic graph from the app's source and the same facts used by `routes`, `lint`, `doctor`, and `provider audit`. It includes features, contracts, route groups, use cases, authorization abilities, events and their listeners, jobs, schedules, tasks, notifications, uploads, agent capabilities, registries, ports, app and package providers, database tables, OpenAPI exposure, tests, cross-feature dependencies, and validation findings. The human view is a compact feature inventory. `--json` returns the versioned `schemaVersion: 1` graph: stable node IDs and source locations, typed edges with source evidence and confidence, registration status, doctor/lint diagnostics, and explicit unresolved references. It is report-only, so findings remain visible without making the command fail. The source graph uses TypeScript's resolver with the app's complete `tsconfig.json`: `baseUrl`, every `paths` alias, multiple alias targets, JSON comments, and extended configs. Failed local module references remain visible as `local_import_unresolved` entries. Exported direct or contract-group declarations whose method, path, or group prefix cannot be derived statically appear as `contract_declaration_unresolved` instead of disappearing. Registration diagnostics include the stable declaration identity in `subject.file` and `subject.exportName`, which is also what determines node registration status. Use `--changed` to map a Git change set to potentially affected Beignet concepts. Without `--base`, Beignet preserves staged index changes, unstaged worktree changes, and untracked files instead of collapsing them into one net diff against `HEAD`. `--base ` also includes committed branch changes from the merge base of that already-local ref through `HEAD`; the command never fetches the ref. The bounded result separates direct source changes, reverse local imports, one-hop semantic consumers, related members of directly changed containers, and feature ownership context. Governing configuration and changed direct local workspace dependencies produce an app-wide impact. Recognized guide files such as `README.md`, `AGENTS.md`, and `CLAUDE.md`, plus Markdown or text files under a root `docs/` or `documentation/` directory, remain non-impacting. Other `.mdx` and `.txt` paths remain application changes. Static-analysis blind spots such as deleted declarations, unresolved imports, and changed files with no mapped concept appear as explicit gaps. Changed-file paths and gap paths are repository-relative; app-map node sources and source evidence remain relative to the selected app root. Changed mapping is report-only. It does not run checks or prove that a change is correct; inspect the affected concepts and then run `beignet check`. `--changed` cannot be combined with `--feature` or `--kind`, and `--base` requires `--changed`. | Option | Description | | --- | --- | | `--feature ` | Keep one feature and its direct relationships. | | `--kind ` | Keep one or a comma-separated list of node kinds. | | `--changed` | Map the current Git change set to bounded, potentially affected concepts. | | `--base ` | In changed mode, compare from the merge base of an already-local Git ref. | | `--json` | Print the complete graph, selected projection, or changed-impact report as machine-readable JSON. | | `--cwd ` | Map an app root in another directory. | ## explain ```bash beignet explain feature issues beignet explain route "POST /api/issues" beignet explain use-case issues.create beignet explain task issues.backfill-search beignet explain port storage beignet explain registry issueTasks beignet explain table issues beignet explain diagnostic BEIGNET_ROUTE_GROUP_UNREGISTERED ``` Resolves any mapped concept or diagnostic against the same versioned graph as `beignet map`. Kinds include features, HTTP declarations, use cases, authorization, workflow artifacts, agent capabilities, registries, ports and providers, tables, OpenAPI documents, entrypoints, and tests. A target may be a stable node ID, runtime or declaration name, source selector such as `file#export`, or an applicable alias such as an HTTP method/path or provider port. The result is deterministic and source-backed: it includes relevant nodes and relationships, source evidence, Beignet conventions, current doctor/lint findings, suggested files with reasons, and runnable inspection and validation commands. Provider explanations also identify the matching entry in the configured provider registry when the static provider audit can prove it. Human output names the resolved app root, and each command in JSON carries its own `cwd`, so `--cwd` explanations remain copy-pasteable. Explain never changes the app and does not generate model-authored advice. Feature explanations bound the relationship list and report the full versus returned counts so large features stay useful in agent context windows. Use a route explanation or `beignet map --feature --json` when you need the omitted detail. | Option | Description | | --- | --- | | `--json` | Print the versioned `schemaVersion: 1` explanation payload. | | `--cwd ` | Explain a concept in another app root. | ## check ```bash beignet check ``` Runs the whole validation loop as one command: `beignet lint`, `beignet doctor --strict`, and the app's own `lint`, `typecheck`, and `test` package scripts through the detected package manager, in that order. Every step runs even when an earlier one fails, so a single run reports everything that needs fixing, and the command exits non-zero when any step fails. Missing package scripts are reported as skipped, never as failures. Failed package-script output is bounded to the retained 64 KiB tail of each stream, so a noisy process cannot grow the check result without limit. The target package must declare `@beignet/core`, `@beignet/next`, or `@beignet/web` as a runtime dependency. The root must also contain `app-context.ts` or `beignet.config.*`; a canonical app with both `features/` and `server/index.ts` is accepted when `app-context.ts` is missing so doctor can report the drift. Other package roots stop before lint, doctor, or app scripts run. | Option | Description | | --- | --- | | `--fix` | Apply doctor's low-risk fixes before checking. | | `--preflight` | Append the disconnected runtime environment preflight after strict doctor. | | `--preflight-connect` | Append connected preflight with migration status and dependency health checks. Implies `--preflight`. | | `--connect-timeout-ms ` | Per migration-status or dependency health check timeout for connected preflight. Defaults to `5000`. | | `--json` | Versioned payload (`schemaVersion: 1`) with the step list, statuses, captured failure output, and applied fixes. | | `--cwd ` | Check an app root in another directory. | ## preflight Runtime production gate, distinct from the static `doctor` checks: it reads the environment the process actually runs with, so run it in the deploy pipeline where production configuration is present. ```bash beignet preflight beignet preflight --connect beignet check --preflight beignet check --preflight-connect ``` The gate verifies every individually required provider env var. For mutually exclusive credentials, one complete `requiredEnvAlternatives` configuration must be present. Variant packages are scoped to the factories registered in the app, so inactive variants do not demand credentials. Preflight also flags values still matching `.env.example` (or common placeholder patterns) on secret-like keys, validates the app env schema by importing `lib/env.ts`, folds in `doctor`'s production hardening diagnostics with promoted severities (doctor warnings fail the gate, hints become warnings), and warns when logging or error reporting is absent or inert. It exits `1` on any error finding. | Flag | Meaning | | --- | --- | | `--connect` | Inspect migration status, then boot the app server and run every port's `checkHealth()`. Needs network access and real credentials. | | `--connect-timeout-ms ` | Per migration-status or dependency health check timeout. Defaults to `5000`; timeout findings report the configured duration. | | `--env-file ` | Merge a dotenv-style file under the environment (existing env keys win) for local rehearsal. | | `--env-module ` | App env module validated by importing it. Defaults to `lib/env.ts`. | | `--server-module ` | Module exporting `getServer`, used by `--connect`. Defaults to `server/index.ts`. | | `--json` | Machine-readable output with `schemaVersion: 1`. | ## lint ```bash beignet lint ``` Enforces the architecture boundaries described in [App architecture](/app-architecture): it recognizes TypeScript imports and re-exports, `require()`, import assignments, and literal dynamic imports. Local value-import checks follow local helper modules, so an otherwise valid use case cannot reach infra through an intermediary. Contracts, schemas, and client roots receive an additional client-safe graph check, including Node built-ins imported with or without the `node:` prefix. Computed module references inside constrained layers fail when lint cannot verify their destination; use a string-literal specifier or move runtime loading behind an allowed boundary. Every module inside a configured Beignet feature root receives a layer; allowed feature-root helpers remain helpers, while near-miss canonical names such as `usecases/` report the expected `use-cases/` path. Direct findings include the offending `file:line:column`; graph-reachability findings include their complete | Option | Description | | --- | --- | | `--json` | Machine-readable diagnostics. | | `--format ` | `human`, `json`, or `github` workflow annotations. Defaults to `human`, or `github` when `GITHUB_ACTIONS` is set. | | `--cwd ` | Lint an app root in another directory. | ## doctor ```bash beignet doctor beignet doctor --strict beignet doctor --fix beignet doctor --fix --dry-run beignet doctor --fix --plan --only routes.register-missing ``` The framework integrity report. Diagnostics cover these areas: - **Routes and contracts** — contracts without handlers, handlers without contracts, query schemas without explicit transports, unregistered route groups, partially wired slices, and CRUD slices missing generated pieces. - **OpenAPI** — drift in direct arrays, exported contract lists, and `contractsFromRoutes(routes)` registries, plus entries for contracts outside the registered route surface. - **Workflow registries** — schedules and tasks missing from `server/schedules.ts` or `server/tasks.ts`, events with listeners and jobs missing from `defineOutboxRegistry({...})` when the app uses outbox delivery, listeners missing from `server/listeners.ts` or otherwise not referenced by `registerListeners(...)`, listener wiring that does not await readiness from `start()` and cleanup from `stop()`, runtime manifests that omit opted-in workflow registries, and serverless footguns such as background timers in provider files, outbox draining from lifecycle hooks, and outbox registries without a drain entrypoint or without a declared and bound or deferred `jobs` port for registered jobs. - **Errors and authorization** — route-owned catalog errors missing from `features/shared/errors.ts`, runtime `appError(...)` calls not declared on contracts, authorization metadata without policy coverage, and audit-required metadata without audit writes or test assertions. - **Database** — missing Drizzle config, schema exports, scripts, or seed/reset entrypoints, Drizzle-backed idempotency/outbox/audit ports without table setup, provider-declared required tables missing from schema, configured schema sources, or migrations, unwired repository adapters, tenant-scoped Drizzle repository ports or adapters missing `TenantScope` method arguments, raw `tenantId` or `workspaceId` repository boundaries, or `tenantScopeId(scope)` predicates, ports without adapters, unguarded resets, and seeds without factories or a `db:seed` script. - **Security and providers** — devtools routes without authorization or an explicit local-only development opt-in, cron routes without `CRON_SECRET`, installed providers without expected env configuration, Better Auth without an auth route or trusted origins, missing security headers, credentialed wildcard CORS, uploads without routes, authorization, or size limits, and notification dispatchers that bypass `ctx.ports.notifications`. - **Payments** — billing slices without a payment webhook route, preferring `createPaymentWebhookRoute(...)` for payment-port billing, checkout contracts missing idempotency metadata or an `idempotency-key` header, and billing webhook use cases that reference `ctx.ports.idempotency` without an `AppPorts` idempotency declaration, billing entitlement modules that are not wired into infra providers, entitlement checks without an `AppPorts` entitlements declaration, plus Stripe configuration still wired to local memory payments. - **Structure and versions** — feature artifacts in non-canonical folders, including normalized near-misses and known aliases such as `usecases/` and `events/`, with the same expected path reported by lint and doctor; strict-mode canonical conformance drift, mixed `@beignet/*` version ranges or installed versions, and CLI/core version skew (an informational hint suggesting the app-local `bun beignet`). When production-readiness diagnostics are present, human `doctor` output also prints a production hardening checklist covering secrets and provider credentials, verified auth and tenant authority, exposed operational routes, security headers, CORS and proxy trust, upload and storage limits, readiness checks, worker shutdown, webhook secrets, and least-privilege provider credentials. Workflow artifacts the starter does not scaffold are not drift on their own; `doctor` reports misplaced, unregistered, or partially wired artifacts, not absent ones. | Option | Description | | --- | --- | | `--strict` | Include CI-oriented warnings (missing generated tests, conformance drift, unused route errors) and fail on warnings. Informational hints never affect the exit code. | | `--fix` | Apply low-risk fixes before reporting: repair generated test support; register route groups, schedules, tasks, outbox entries, listeners, Inngest jobs, and opted-in runtime-manifest entries; sync missing default Beignet provider-table exports; and repair direct OpenAPI arrays whose contracts are already imported. Registry fixes are append-only and bail out on missing or customized anchors, ambiguous imports, and partial individual registration. Database repair requires an existing schema index, default table names, a missing `infra/db/schema/beignet.ts` or one that still matches `beignet db schema sync` output, and a schema index where its generated `export *` can be safely added or retained. It changes source only, so run `beignet db generate`, `beignet db migrate`, and `beignet db status` afterward. Compatible co-located workflow repairs become one `workflows.register-missing` operation. | | `--dry-run` | With `--fix`, return a read-only repair plan containing stable operation IDs, SHA-256 file hashes, exact unified patches, a plan ID, the detected convention, and every current doctor diagnostic. Human and GitHub output render the diagnostics that determine the command's exit status. | | `--plan ` | With `--fix`, apply only if the complete current repair plan still matches this ID; otherwise fail before writing. | | `--only ` | With `--fix --plan`, apply selected comma-separated operation IDs such as `routes.register-missing` or `outbox.register-missing`. | | `--json` | Without `--dry-run`, return the versioned inspection payload (`schemaVersion: 1`) with `targetDir`, `config`, `strict`, `convention`, `contracts`, `routes`, `diagnostics`, and `fixes`. Fix application adds the guarded `planId` and stable `operationIds`; MCP `doctor_fix` returns this same shape. With `--fix --dry-run`, return the versioned repair plan instead. | | `--format ` | `human`, `json`, or `github`. Defaults to `human`, or `github` when `GITHUB_ACTIONS` is set. `--json` conflicts with any other `--format`. | | `--cwd ` | Check an app root in another directory. | Architecture lint resolves the complete `baseUrl` and `paths` configuration through TypeScript, including `src/` layouts, extended configs, multiple aliases, and fallback targets, before classifying dependency direction. Generator registry updates report an error when a customized central array cannot be found; “skipped” means the requested registration already exists. ## provider add ```bash beignet provider add cache-redis beignet provider add storage-s3 --dry-run --json ``` Adds a provider setup preset to an existing app. The command is idempotent: repeated runs skip identical files and avoid duplicate provider entries. It updates package dependencies, `server/providers.ts`, `ports/index.ts`, `infra/port-wiring.ts`, `.env.example`, and `docs/providers.md`. | Preset | Adds | | --- | --- | | `flags-openfeature` | `@beignet/provider-flags-openfeature`, `@openfeature/server-sdk`, `flags: FlagsPort`, and `createOpenFeatureFlagsProvider()`. | | `jobs-bullmq` | `@beignet/provider-jobs-bullmq`, `bullmq`, `jobs: JobDispatcherPort`, Redis configuration, and worker setup notes. | | `jobs-inngest` | `@beignet/provider-jobs-inngest`, `inngest`, `jobs: JobDispatcherPort`, and Inngest configuration. | | `mail-resend` | `@beignet/provider-mail-resend`, `resend`, `mailer: MailerPort`, and `RESEND_API_KEY`/`RESEND_FROM`. | | `mail-smtp` | `@beignet/provider-mail-smtp`, `nodemailer`, `mailer: MailerPort`, and `MAIL_*` settings. | | `payments-stripe` | `@beignet/provider-payments-stripe`, `stripe`, `payments: PaymentsPort`, and Stripe API/webhook configuration. | | `search-meilisearch` | `@beignet/provider-search-meilisearch`, `search: SearchPort`, and `MEILISEARCH_HOST`. | | `error-reporting-sentry` | `@beignet/provider-error-reporting-sentry`, `@sentry/node`, and `createSentryErrorReportingProvider()`. | | `rate-limit-upstash` | `@beignet/provider-rate-limit-upstash`, Upstash peers, `rateLimit: RateLimitPort`, and Upstash REST env. | | `cache-redis` | `@beignet/provider-cache-redis`, `ioredis`, `cache: CachePort`, and `REDIS_URL`. | | `event-bus-redis` | `@beignet/provider-event-bus-redis`, `ioredis`, `eventBus: EventBusPort`, and `REDIS_EVENT_BUS_URL`. | | `locks-redis` | `@beignet/provider-locks-redis`, `ioredis`, `locks: LocksPort`, and `REDIS_LOCKS_URL`. | | `storage-s3` | `@beignet/provider-storage-s3`, AWS S3 SDK peers, `storage: StoragePort`, and `STORAGE_S3_BUCKET`. | | `storage-vercel-blob` | `@beignet/provider-storage-vercel-blob`, `@vercel/blob`, `storage: StoragePort`, and read-write token or OIDC credentials. | `error-reporting-sentry` replaces the starter no-op error reporter at provider startup without adding `errorReporter` to the deferred list. The other presets add their app-facing ports to `AppPorts` and defer them to provider startup. Presets that fill the same app port, such as `mail-resend` and `mail-smtp`, fail with a conflict instead of registering competing providers. Provider escape hatches such as `ctx.ports.redis`, `ctx.ports.redisEventBus`, `ctx.ports.redisLocks`, `ctx.ports.s3Storage`, `ctx.ports.resend`, `ctx.ports.smtp`, `ctx.ports.meilisearch`, and `ctx.ports.openFeature` are inferred from `server/providers.ts`. | Option | Description | | --- | --- | | `--dry-run` | Preview planned writes without changing files. | | `--json` | Versioned payload (`schemaVersion: 1`) with changed and skipped files plus next steps. | | `--cwd ` | Add provider setup to an app root in another directory. | Run your package manager install command after the preset writes dependency changes, then run `beignet provider audit` and `beignet doctor --strict`. ## provider audit ```bash beignet provider audit beignet provider audit --json ``` Reports the provider packages installed by the target app without turning the report into a CI failure. The audit reads package-owned `beignet.provider` metadata without importing provider implementation modules, then shows metadata validity, registration status, required env, required tables, and declared app ports in the human table. JSON output also includes active variants, provider watchers, and source locations for matching provider-registry entries. Missing alternative credential paths stay grouped in human diagnostics. JSON preserves each active variant's accepted configurations and status when multiple variants are registered. Use the human table when checking an app manually. Use `--json` when a script, CI report, or coding agent needs a machine-readable provider inventory. Stable setup problems that should fail CI remain `doctor` diagnostics. | Option | Description | | --- | --- | | `--json` | Versioned payload (`schemaVersion: 1`) with `targetDir`, `providers`, and `summary`. | | `--cwd ` | Audit providers for an app root in another directory. | ## task run ```bash beignet task run posts.backfill-search --tenant acme --input '{"dryRun":true}' ``` Runs an app-owned operational task through the registry in `server/tasks.ts`, which exports `tasks`, `createTaskContext(...)`, and optionally `stopTaskContext(...)`. Keep auth, tenancy, and provider lifecycle decisions in that module so local shells, CI jobs, and deployed runners behave the same. | Option | Description | | --- | --- | | `--input ` | JSON input validated by the task schema. Defaults to `{}`. | | `--tenant ` | Tenant id or slug passed to the app's `createTaskContext` as `TaskRunContextArgs.tenant`, separate from task input. The app resolves it. | | `--module ` | Task registry module. Defaults to `server/tasks.ts` or `paths.tasks`. | | `--cwd ` | Run against an app root in another directory. | | `--json` | Print the task result as JSON. | ## schedule run ```bash beignet schedule run posts.daily-summary --scheduled-at 2026-01-01T09:00:00.000Z ``` Runs a schedule explicitly from a local shell, CI job, or worker through the registry in `server/schedules.ts`, which exports a `schedules` array, `createScheduleContext(...)`, and optionally `stopScheduleContext(...)`. See [Schedules](/schedules). | Option | Description | | --- | --- | | `--payload ` | JSON payload for the schedule schema. Omit it to use the schedule's `createPayload(...)`. | | `--module ` | Schedule registry module. Defaults to `server/schedules.ts` or `paths.schedules`. | | `--run-id ` | Provider or app schedule run ID. | | `--attempt ` | One-based provider attempt number. | | `--scheduled-at ` | Provider scheduled timestamp. | | `--triggered-at ` | Schedule trigger timestamp. | | `--source ` | Provider or app source label. | | `--cwd ` | Run against an app root in another directory. | | `--json` | Print the run result as JSON. | ## outbox drain ```bash beignet outbox drain --batch-size 100 ``` Drains durable events and jobs in one bounded pass through the registry in `server/outbox.ts`, which exports `outboxRegistry`, `createOutboxDrainContext(...)`, and optionally `stopOutboxDrainContext(...)`. There is intentionally no separate jobs-drain command: outbox-backed jobs drain here, and direct provider jobs use provider-owned worker entrypoints such as an Inngest route. See [Outbox](/outbox). Every outbox command accepts `--cwd ` to run against an app root in another directory. | Option | Description | | --- | --- | | `--batch-size ` | Maximum messages to claim in one pass. | | `--module ` | Outbox registry module. Defaults to `server/outbox.ts` or `paths.outbox`. | | `--json` | Print the drain result as JSON. | ## outbox list ```bash beignet outbox list --status deadLettered ``` Lists messages through `ports.outboxAdmin` from the app's outbox context. If the context does not expose `outboxAdmin`, the CLI accepts `ports.outbox` only when it also implements `OutboxAdminPort`, such as the memory outbox in tests. | Option | Description | | --- | --- | | `--status ` | `pending`, `claimed`, `delivered`, or `deadLettered`. | | `--kind ` | `event` or `job`. | | `--name ` | Event or job name. | | `--limit ` | Maximum messages to return. Defaults to 50. | | `--module ` | Outbox registry module. Defaults to `server/outbox.ts` or `paths.outbox`. | | `--json` | Print messages and total count as JSON. | ## outbox show ```bash beignet outbox show ``` Shows one outbox message, including payload, attempts, timestamps, and last error. Use `--json` for the full machine-readable record. ## outbox requeue ```bash beignet outbox requeue --reset-attempts ``` Returns one dead-lettered message to `pending` state. Requeue only after the handler or provider issue has been fixed; Beignet preserves the last error for inspection. | Option | Description | | --- | --- | | `--available-at ` | Earliest timestamp the message may be claimed again. Defaults to now. | | `--reset-attempts` | Reset attempts to zero before requeueing. | | `--module ` | Outbox registry module. Defaults to `server/outbox.ts` or `paths.outbox`. | | `--json` | Print the requeued message as JSON. | ## outbox purge ```bash beignet outbox purge --before 2026-01-01T00:00:00.000Z --dry-run beignet outbox purge --before 2026-01-01T00:00:00.000Z ``` Deletes dead-lettered messages whose `updatedAt` is before the cutoff. The command requires either `--before` or `--all`. | Option | Description | | --- | --- | | `--before ` | Only purge dead-lettered messages last updated before this timestamp. | | `--all` | Purge every dead-lettered message when `--before` is omitted. | | `--limit ` | Maximum messages to purge, deleting the oldest eligible rows first. | | `--dry-run` | Count matches without deleting rows. | | `--module ` | Outbox registry module. Defaults to `server/outbox.ts` or `paths.outbox`. | | `--json` | Print matched/deleted counts as JSON. | ## outbox prune ```bash beignet outbox prune --before 2026-01-01T00:00:00.000Z --dry-run beignet outbox prune --before 2026-01-01T00:00:00.000Z ``` Deletes delivered messages whose `deliveredAt` is before the cutoff. | Option | Description | | --- | --- | | `--before ` | Required delivered-row retention cutoff. | | `--limit ` | Maximum messages to prune, deleting the oldest eligible rows first. | | `--dry-run` | Count matches without deleting rows. | | `--module ` | Outbox registry module. Defaults to `server/outbox.ts` or `paths.outbox`. | | `--json` | Print matched/deleted counts as JSON. | Programmatic orchestrators can import `runAppTask`, `runAppSchedule`, `runOutboxDrain`, `runOutboxList`, `runOutboxShow`, `runOutboxRequeue`, `runOutboxPurge`, and `runOutboxPrune` plus their option and result types from `@beignet/cli`. Each function returns the same versioned report shape as its CLI `--json` command and operational MCP tool. Programmatic task input and schedule payloads accept any pre-parsed value supported by the registered Standard Schema, including raw strings. The CLI `--input` and `--payload` flags decode JSON before calling these APIs. ## mcp ```bash beignet mcp ``` Runs a Model Context Protocol server over stdio so coding agents can read app-local guidance and focused feature app-map resources, then call the CLI as tools: `app_map`, `explain`, `check`, `routes`, `doctor`, `db`, `db_status`, `db_schema_sync`, `task_run`, `schedule_run`, `outbox_inspect`, `outbox_run`, `doctor_fix_plan`, `doctor_fix`, `lint`, `make`, and `provider_add`. `doctor_fix_plan` is read-only and returns the same guarded repair plan as `beignet doctor --fix --dry-run --json`; pass its `planId` and optional `fixIds` to `doctor_fix`, or omit both to preserve apply-all behavior. `app_map` accepts optional `feature`, `kinds`, and `includeDiagnostics` inputs so an agent can inspect the whole app or request a token-bounded projection before editing. Pass `{ changed: true, base?: "origin/main" }` for the same bounded report as `beignet map --changed --json`. Changed mode rejects projection and diagnostic inputs. `explain` accepts `{ kind, target }` for any mapped concept or diagnostic and returns the same versioned result as `beignet explain --json`. `check` accepts optional `{ preflight?: boolean, preflightConnect?: boolean, connectTimeoutMs?: number, timeoutMs?: number }`. Connected preflight adds migration status and dependency health checks. Check bounds failure output and cancels its active package script when the MCP request is cancelled. It does not apply Beignet fixes, but app-owned scripts run with their normal side effects, so MCP clients should treat it as an execution tool rather than a read-only inspection tool. It returns the same versioned result as `beignet check --json`. `db` accepts `{ command: "generate" | "migrate" | "seed" | "reset", dryRun?, timeoutMs? }`, returns the matching versioned lifecycle report with bounded output, and stops the complete child process tree on cancellation or timeout. `db_status` is read-only and returns the same current, pending, or failed report as `beignet db status --json`; pending status exits 2. `db_schema_sync` accepts `{ dialect?, tables?, output?, dryRun? }` and returns the same report as `beignet db schema sync --json`; it is idempotent but writes app source unless dry-run is selected. `task_run` and `schedule_run` execute registered app workflows. `outbox_inspect` keeps `list` and `show` read-only, while `outbox_run` owns `drain`, `requeue`, `purge`, and `prune`; purge and prune support `dryRun`. These operational tools run app code in isolated process trees, bound structured results, and stop on request cancellation or timeout. Their optional `module` overrides are app-relative and cannot leave the app root where the MCP server started. Cancellation and timeouts cannot roll back side effects that already completed. Inspect app state before retrying an interrupted operation or one that reports a cleanup failure. The server publishes `beignet://app/guidance` and the `beignet://app/features/{feature}` resource template. Tool outputs are the same JSON the matching `--json` flags print, `make` takes the same artifact kinds as `beignet make`, and `provider_add` takes `{ preset, dryRun? }` for the same presets as `beignet provider add`. Generated apps ship a `.mcp.json` that runs `./node_modules/.bin/beignet mcp`, so MCP clients such as Claude Code pick up the app-local CLI version without configuration. See [Coding agents](/agents) for the tool list, manual client registration, and the rest of the agent surface. ## completion ```bash beignet completion install beignet completion install --shell zsh beignet completion uninstall ``` `install` writes a managed completion block to `~/.bashrc` or `~/.zshrc`, detecting the shell from `$SHELL`; `uninstall` removes it. Restart your shell or source the rc file to activate. Completions cover commands, subcommands, flags, and enum values such as `--with` and `--format`, and complete whatever `beignet` resolves to on your `PATH` through the internal `beignet completion propose` helper. | Option | Description | | --- | --- | | `--shell ` | `bash` or `zsh`. Defaults to `$SHELL`. | | `--json` | Print the install or uninstall result as JSON. | ## Exit codes Every command uses the same exit code contract, so CI scripts can branch on the result: | Code | Meaning | | --- | --- | | `0` | Success. `lint` and `doctor` found nothing to report. | | `1` | Findings. `lint` or `doctor` reported problems, or a command failed against the app. | | `2` | Usage or internal error, such as an unknown command, invalid flags, or an unexpected CLI failure. | --- # Packages and imports Source: https://www.beignetjs.com/package-reference Most app code imports Beignet through `@beignet/core` subpaths. This page maps each app responsibility to the core subpath, integration package, or provider package that provides it. > **Alpha software:** All published Beignet packages are in the experimental > `0.0.x` alpha line. APIs and package boundaries may change between releases. | Responsibility | Import path | | --- | --- | | Agent capability definitions and execution | `@beignet/core/agent-capabilities` | | Contracts | `@beignet/core/contracts` | | Server runtime | `@beignet/core/server` | | Web Fetch adapter | `@beignet/web` | | Web Fetch route testing | `@beignet/web/testing` | | Next.js adapter | `@beignet/next` | | Typed HTTP client | `@beignet/core/client` | | Client-only boundary helpers | `@beignet/core/client-only` | | Use cases | `@beignet/core/application` | | Operational task definitions | `@beignet/core/tasks` | | Ports, audit logging, redaction, cache, storage, and logging | `@beignet/core/ports` | | Provider lifecycle and instrumentation | `@beignet/core/providers` | | Domain helpers | `@beignet/core/domain` | | App errors | `@beignet/core/errors` | | HTTP error response helpers | `@beignet/core/errors/http` | | Product entitlements | `@beignet/core/entitlements` | | Environment config | `@beignet/core/config` | | Event definitions and listeners | `@beignet/core/events` | | Idempotency ports and helpers | `@beignet/core/idempotency` | | Job definitions and inline dispatch | `@beignet/core/jobs` | | Lease-backed locks and memory adapter | `@beignet/core/locks` | | Mail port and memory adapter | `@beignet/core/mail` | | Notification definitions, dispatchers, and test adapters | `@beignet/core/notifications` | | Durable outbox helpers | `@beignet/core/outbox` | | Payments port and memory adapter | `@beignet/core/payments` | | Search port and memory adapter | `@beignet/core/search` | | Inbound webhook definitions and verifiers | `@beignet/core/webhooks` | | Error reporting port and memory adapter | `@beignet/core/error-reporting` | | Feature flag definitions and adapters | `@beignet/core/flags` | | Schedule primitives | `@beignet/core/schedules` | | Server-only boundary helpers | `@beignet/core/server-only` | | Tenant scope helpers for repository boundaries | `@beignet/core/tenancy` | | Trace context helpers | `@beignet/core/tracing` | | Upload definitions, router, signer port, and test signer | `@beignet/core/uploads` | | Browser upload client | `@beignet/core/uploads/client` | | Pagination types and normalizers | `@beignet/core/pagination` | | Test fixtures, recording adapters, assertions, factories, and seeds | `@beignet/core/testing` | | OpenAPI generation | `@beignet/core/openapi` | | App scaffolding | `create-beignet` (run as `bun create beignet`, never imported) | | CLI and generators | `@beignet/cli` | | Local request, provider, and audit timeline | `@beignet/devtools` | | TanStack Query integration | `@beignet/react-query` | | React Hook Form integration | `@beignet/react-hook-form` | | React upload hooks | `@beignet/react-uploads` | | URL state integration | `@beignet/nuqs` | ## Server import policy Framework-neutral route declarations, route registries, hooks, request types, and server primitives come from `@beignet/core/server`. Runtime adapters do not re-export that surface: - Use `@beignet/next` for `createNextServer(...)`, lazy server loading, Next route handlers, Next client defaults, and Server Component context. - Use `@beignet/web` for `createFetchServer(...)`, Web Fetch conversion, and standard `Request`/`Response` adapters. This keeps feature routes independent from the app's deployment runtime and prevents an adapter package from becoming an accidental second home for core APIs. ## Provider packages Provider packages adapt common services to app-owned ports. They are named `provider--`; when an implementation spans multiple database backends, each backend is a subpath export, so the Drizzle package ships `@beignet/provider-db-drizzle/sqlite`, `/postgres`, and `/mysql`: | Service | Package | | --- | --- | | Better Auth | `@beignet/provider-auth-better-auth` | | Drizzle database | `@beignet/provider-db-drizzle` | | Memory event bus | `@beignet/provider-event-bus-memory` | | Redis event bus | `@beignet/provider-event-bus-redis` | | BullMQ jobs | `@beignet/provider-jobs-bullmq` | | Inngest jobs | `@beignet/provider-jobs-inngest` | | Pino logging | `@beignet/provider-logger-pino` | | Resend mail | `@beignet/provider-mail-resend` | | SMTP mail | `@beignet/provider-mail-smtp` | | Stripe payments | `@beignet/provider-payments-stripe` | | OpenFeature flags | `@beignet/provider-flags-openfeature` | | Sentry error reporting | `@beignet/provider-error-reporting-sentry` | | OpenTelemetry tracing and metrics | `@beignet/provider-tracing-opentelemetry` | | Redis locks | `@beignet/provider-locks-redis` | | Meilisearch search | `@beignet/provider-search-meilisearch` | | Local storage | `@beignet/provider-storage-local` | | S3-compatible storage | `@beignet/provider-storage-s3` | | Vercel Blob storage | `@beignet/provider-storage-vercel-blob` | | Redis | `@beignet/provider-cache-redis` | | Upstash rate limiting | `@beignet/provider-rate-limit-upstash` | ## Server integration packages These packages adapt vendor-specific server behavior without installing a Beignet lifecycle provider or port: | Integration | Package | | --- | --- | | Better Auth Agent Auth capabilities | `@beignet/agent-auth-better-auth` | | GitHub webhook verification | `@beignet/webhooks-github` | | Generic Stripe webhook verification | `@beignet/webhooks-stripe` | ## Installation pattern Start apps with the `create-beignet` package: ```bash bun create beignet my-app ``` Generated apps include `@beignet/cli` as a dev dependency and a `beignet` package script, so maintenance commands run as `bun beignet `. Add packages when the app enables the corresponding capability: ```bash bun add @beignet/core zod bun add @beignet/web # Web Fetch runtimes bun add @beignet/next # Next.js apps bun add @beignet/react-query @tanstack/react-query bun add @beignet/react-uploads bun add @beignet/devtools ``` Use the generated [API reference](/api-reference) for exact public export signatures. Use package READMEs — rendered on each package's page in the [`@beignet` npm org](https://www.npmjs.com/org/beignet) — for package-level setup notes, and use the docs site for app architecture and production workflow. --- # Coding agents Source: https://www.beignetjs.com/agents Beignet's conventions are enforced by tooling — `beignet lint` checks dependency direction, `beignet doctor` reports registration and structure drift, and generators produce canonical output. That makes a Beignet app unusually legible to coding agents: an agent can generate an artifact, check its own work, and fix drift without guessing at project conventions. Three integration points connect agents to that tooling. ## Package-shipped skills Beignet packages ship TanStack Intent skills with the npm package version that contains the code they describe. Generated apps trust the relevant Beignet packages through `package.json#intent.skills`, so an agent can load the guidance that matches the installed Beignet version instead of relying on a copied file that drifts. Start with Intent from the app root: ```bash bunx @tanstack/intent@latest install bunx @tanstack/intent@latest list bunx @tanstack/intent@latest load @beignet/core#app-architecture ``` Use the matching runner for the app's package manager, such as `npx`, `pnpm dlx`, or `yarn dlx`. Load the narrow skill for the task: | Skill | Use it for | | --- | --- | | `@beignet/core#app-architecture` | Schemas, contracts, use cases, errors, ports, policies, workflows, seeds, tests, and core imports | | `@beignet/next#routes-server` | Route groups, central registration, server context, Next route handlers, OpenAPI, devtools, uploads, storage, webhooks, schedules, and outbox drains | | `@beignet/web#fetch-server` | Web Fetch server composition, CLI web profiles, Bun and serverless mounting, raw/native responses, lifecycle, and adapter-level tests | | `@beignet/devtools#runtime-safety` | Devtools provider and route separation, non-development authorization, redaction, persistence, watchers, and production safety | | `@beignet/react-query#client` | Typed clients, query/mutation/infinite options, feature client helpers, hooks, cache keys, invalidation, and client/server boundary fixes | | `@beignet/react-hook-form#forms` | Contract-backed forms, Standard Schema resolvers, root form errors, React Query mutations, transforming body schemas, and form boundaries | | `@beignet/react-uploads#uploads-client` | Typed upload clients, React upload hooks, progress, abort/reset behavior, upload callbacks, invalidation, and upload client boundaries | | `@beignet/provider-db-drizzle#database-provider` | Drizzle provider setup, schema placement, config, `DbPort` typing, repositories, Unit of Work, audit, idempotency, outbox, migrations, and doctor drift | | `@beignet/provider-auth-better-auth#auth-provider` | Better Auth setup, auth routes, `AuthPort` typing, deferred port registration, server context, auth hooks, policies, tests, and doctor drift | | `@beignet/agent-auth-better-auth#agent-capabilities` | Typed agent capabilities, registries, authoritative delegated service context, Agent Auth grants, schema conversion, and execution tests | | `@beignet/cli#app-structure` | Generators, full-slice recipes, db schema sync, `beignet.config.*`, route inspection, lint, doctor, MCP tools, and generated app structure | ## The scaffolded guide files `beignet create` writes `AGENTS.md` and `CLAUDE.md` at the app root. Agents that follow the AGENTS.md convention read `AGENTS.md` automatically, and Claude reads `CLAUDE.md`; both files contain the same conventions and also apply to humans. It carries the conventions an agent cannot discover by reading code: - **Registration is explicit.** Generators create or update the central `server/` entrypoints for route groups, schedules, tasks, and outbox registries. Hand-written files still need to be wired there before they run, and `beignet doctor --fix --dry-run` previews exact guarded repairs before `beignet doctor --fix --plan ` applies them. - **Prefer generators.** `beignet make` output lands in the right place, wires registries, and passes `lint` and `doctor`; hand-written files often miss a wiring step. - **The capability index.** A need-to-API table for the machinery agents most often reimplement by accident: query-cache invalidation, service contexts for non-HTTP entrypoints, reaching ports outside a request, raw routes, metadata-driven rate limits and idempotency, route-level test harnesses, and env validation. - **The validation loop.** Run `beignet check` after each change — it runs the app's Biome lint, `beignet lint`, `beignet doctor --strict`, tests, and typecheck in one pass — and treat findings as the next task. - **Placement rules and the naming grammar.** Where feature artifacts live, and how `defineX`, `createX`, and `createXProvider` divide the API. The guide deliberately excludes anything discoverable from the code or covered elsewhere: package skills own framework-specific agent guidance, the `README.md` owns setup and run instructions, and `AGENTS.md` and `CLAUDE.md` own app-local conventions. Keeping them short keeps agents reading them. ## The MCP server `beignet mcp` runs a Model Context Protocol server over stdio, exposing the app's guidance and focused feature maps as resources plus the CLI's inspection, generation, and operational commands as tools. Running the MCP server requires Node.js 22.12 or newer. Resources are read-only: | Resource | What it provides | | --- | --- | | `beignet://app/guidance` | The bounded app-local `AGENTS.md` instructions, or concise fallback guidance when the file is absent. | | `beignet://app/features/{feature}` | A source-backed app-map projection for one feature. MCP clients can list available features and complete feature names. | | Tool | What it does | | --- | --- | | `app_map` | Project the source-backed app graph by optional feature or node kinds, or pass `{ changed: true, base? }` to map a Git change set to bounded, potentially affected concepts. Read-only. | | `explain` | Explain any mapped concept or diagnostic with `{ kind, target }`; returns source evidence, relationships, conventions, findings, suggested files, and follow-up commands. Read-only. | | `check` | Run the complete validation loop and return versioned step results, bounded failure output, durations, and overall `ok`. Accepts `{ preflight?: boolean, preflightConnect?: boolean, connectTimeoutMs?: number, timeoutMs?: number }`; connected preflight adds migration and dependency checks. It cancels the active script with the request. It does not apply Beignet fixes, but app scripts retain their normal side effects, so this is an execution tool. | | `db` | Run `generate`, `migrate`, `seed`, or `reset` with `{ command, dryRun?, timeoutMs? }`. Returns the matching versioned CLI/library report with bounded output, request cancellation, and a command timeout. Database scripts retain their app-owned side effects; `dryRun` reports the script without simulating its SQL or data changes. | | `db_status` | Run the app-owned read-only migration inspection with `{ timeoutMs? }`. Returns the same current, pending, or failed report as `beignet db status --json`; pending exits 2. | | `db_schema_sync` | Idempotently sync app-owned Beignet provider-table schema re-exports with `{ dialect?, tables?, output?, dryRun? }`. It writes source unless `dryRun` is true and never generates or applies SQL migrations. | | `task_run` | Run a registered task with `{ name, input?, tenant?, module?, timeoutMs? }` in an isolated process. Returns the matching versioned CLI/library report. | | `schedule_run` | Run a registered schedule with `{ name, payload?, runId?, attempt?, scheduledAt?, triggeredAt?, source?, module?, timeoutMs? }` in an isolated process. | | `outbox_inspect` | Read outbox state with `list` or `show`. Read-only, with operation-specific inputs and the matching CLI/library report. | | `outbox_run` | Run `drain`, `requeue`, `purge`, or `prune`. Purge and prune accept `dryRun`; every operation is treated as state-changing. | | `routes` | List the app's routes and contracts. Read-only. | | `doctor` | Report framework drift as JSON diagnostics. Accepts `{ strict?: boolean }`, default `true`. Read-only. | | `doctor_fix_plan` | Plan low-risk repairs with stable operation IDs, hashes, exact patches, and current doctor diagnostics. Plans cover mechanical registration drift, managed default provider-table exports, generated test support, and eligible direct OpenAPI arrays. Read-only. | | `doctor_fix` | Apply low-risk repairs. Pass `{ planId, fixIds? }` from `doctor_fix_plan` for guarded application; omitting both preserves apply-all. Custom or ambiguous registry, runtime-manifest, and schema code remains diagnostic-only. Its JSON matches guarded CLI apply, including `planId` and `operationIds`. | | `lint` | Report architecture and dependency-direction diagnostics. Read-only. | | `make` | Run a generator with `{ artifact, name, ...options }` — the same artifact kinds as `beignet make`. | | `provider_add` | Add a provider setup preset with `{ preset, dryRun? }`; presets match `beignet provider add`. | Operational tools isolate app code from the long-lived MCP server, bound the structured result, and stop the complete process tree when the request is cancelled or its timeout expires. Optional `module` overrides must remain app-relative; the server rejects paths outside the app root where it started. Cancellation and timeouts cannot roll back side effects that already completed. Inspect app state before retrying an interrupted operation or one that reports a cleanup failure. Changed `app_map` results distinguish direct changes, semantic consumers, related container members, feature context, and unresolved evidence. They do not run checks or claim that a change is correct. The optional `base` must already exist locally because Beignet does not fetch Git refs. Tool outputs are exactly the JSON the CLI's `--json` flags produce, so anything written against `beignet map --json`, `beignet explain ... --json`, `beignet check --json`, `beignet db ... --json`, `beignet doctor --json`, `beignet lint --json`, `beignet task run ... --json`, `beignet schedule run ... --json`, or `beignet outbox ... --json` reads MCP results unchanged. For persistent generated work, the intended coding loop is `app_map` to orient, `explain` to focus one concept, `make` or an edit to change it, `app_map { changed: true }` to inspect potential consumers, `db_schema_sync` when provider tables changed, `db` with `generate` then `migrate`, `db_status`, the relevant task, schedule, or outbox tool when operational work is required, and `check` to verify the whole app. Generated apps ship a `.mcp.json` that registers the server through the app-local `@beignet/cli` bin, so Claude Code picks it up with zero configuration. Other clients, such as Cursor and VS Code, take the same command in their own config files (`.cursor/mcp.json` or `.vscode/mcp.json`): ```json { "mcpServers": { "beignet": { "command": "./node_modules/.bin/beignet", "args": ["mcp"] } } } ``` Run the MCP client from the app root so the relative bin path resolves to the installed app version. For one-off usage outside an installed app, use the scoped package name with a registry runner, such as `bunx @beignet/cli mcp` or `npx @beignet/cli mcp`; never use the unscoped `beignet` npm name. ## llms.txt The docs site publishes two plain-text views of itself, rebuilt on every docs build: - [https://beignetjs.com/llms.txt](https://beignetjs.com/llms.txt) — an [llms.txt](https://llmstxt.org)-style index mirroring the docs navigation: every page with its title, URL, and description. - [https://beignetjs.com/llms-full.txt](https://beignetjs.com/llms-full.txt) — the full text of every docs page in one file. Use the index when an agent should navigate to the right page and fetch it; use the full file when an agent retrieves grep-style over the whole corpus. See the [CLI reference](/cli#mcp) for the `beignet mcp` command and [App architecture](/app-architecture) for the structure the conventions in `AGENTS.md` and `CLAUDE.md` describe. --- # Stability and releases Source: https://www.beignetjs.com/stability Beignet is alpha software on the `0.0.x` line. Concretely, that means the public API can still change between releases while the framework settles. It does not mean the project is unmaintained or untested: every release ships through changesets with a documented changelog, and every release candidate passes the same CI gates, including a conformance check that scaffolds, builds, and boots real generated apps against real databases. ## Versioning All `@beignet/*` packages and `create-beignet` version together as a fixed changesets group, so a given version number always refers to one coherent release across the whole framework. Installing any Beignet version gives you packages that were tested together. While the packages are pre-1.0: - Every change is a patch bump on the `0.0.x` line, including breaking changes. Do not read semver meaning into `0.0.x` increments. - Breaking changes are documented through changesets, package changelogs, and the [upgrade guide](/upgrading). - Release candidates are prepared continuously as changes land. The project has released multiple times per month since work started, often several times in a single week. ## How releases ship Published package changes include a patch changeset for every affected package. CI enforces that coverage and rejects minor or major changesets while Beignet is on `0.0.x`. After changes reach `main` and the complete release-candidate gate passes, Changesets creates or updates a version PR containing the coordinated package versions, changelogs, and dependency ranges. Merging that reviewed PR runs the gate again, then publishes every unpublished package through npm trusted publishing. GitHub supplies a short-lived OIDC identity for that one workflow run; the repository stores no npm publishing token. The release job also creates package tags and GitHub releases. Because the source repository is currently private, npm does not attach public provenance attestations; trusted publishing still provides tokenless, workflow-bound authentication. ## What every release passes Every Beignet version published to npm passes the same reproducible release-candidate gate: - The full test matrix with coverage, including provider tests that run against real Postgres and MySQL servers, plus lint, typecheck, and build for every package. - Architecture linting that enforces the dependency direction the framework teaches: domain code cannot import infra, use cases cannot import providers, routes cannot import concrete adapters. - Pack and release alignment checks that verify published package contents and keep the fixed version group consistent, enforce patch-only changesets, and verify that package changes carry their own release notes. - Committed API reports for every published TypeScript entry point. CI fails when an export, overload, generic, or referenced public declaration changes without an intentionally reviewed report update. - A generated-app conformance check that scaffolds real apps with the CLI, installs dependencies, typechecks, production-builds, passes lint and strict doctor checks, then boots the built app and exercises sign-up, authenticated todo create/list/update/delete, idempotency-key replay, OpenAPI, and devtools over HTTP — against SQLite, Postgres, and MySQL. - A cross-database conformance suite that proves the unit-of-work, outbox, and idempotency semantics are identical across the SQLite, Postgres, and MySQL backends. - Live Redis integration tests for the distributed event bus and execution locks, plus a full-history secret scan. ## The road to 1.0 1.0 is gated on outcomes, not dates: - The public API grammar settles — the `defineX` and `createX` surfaces stop changing shape between releases. - The workflow tier (events, jobs, schedules, outbox, idempotency, notifications) is validated in production use. - A written compatibility promise ships: semver, with documented migration notes for any breaking change. There is no announced timeline. The `0.0.x` line continues until those gates are met. ## Compatibility policy Public API means the exports reachable through a package's declared `package.json#exports` paths. Internal source paths, generated build output, and undocumented files are not public API. App code produced by generators is owned by the app after generation; later generator improvements do not rewrite it automatically. On the current `0.0.x` line, releases may remove or reshape public APIs. Those changes must include migration notes and keep docs, package READMEs, generators, skills, and validation apps aligned in the same change. Public TypeScript API changes also update the committed API report and include a changeset for each affected package. Starting with 1.0: - Patch releases will contain backwards-compatible fixes and documentation. - Minor releases may add APIs and deprecate existing ones without removing them. - Major releases may remove deprecated APIs or make other breaking changes and will include an upgrade guide. - All Beignet packages will continue to version together so the supported combination remains unambiguous. Raw provider clients exposed as escape hatches retain their vendor's own compatibility behavior. Beignet guarantees the typed existence of a documented escape hatch, not that a vendor SDK will avoid its own breaking changes. ## Following along - The [@beignet npm org](https://www.npmjs.com/org/beignet) lists every published package and version. - Each package ships its `CHANGELOG.md` in the published npm package, with breaking changes called out per release. The repository is not open source yet. The packages themselves are published publicly on npm. --- # Upgrading Source: https://www.beignetjs.com/upgrading Beignet packages version together. Upgrade every installed `@beignet/*` package, `@beignet/cli`, and `create-beignet` to the same release instead of mixing versions across the framework. ## Upgrade workflow 1. Read the target release notes and the migration section on this page. 2. Update all Beignet packages to the same version and reinstall dependencies. 3. Run `bun beignet doctor --strict` and `bun beignet lint`. 4. Run the app's `typecheck`, `test`, and production `build` scripts. 5. Exercise app-owned worker, cron, webhook, outbox, and task entrypoints that are not covered by the web build. Generated files belong to the app after creation. Do not rerun `create-beignet` over an existing app or copy a fresh starter wholesale. Apply migration steps to the app's current structure and use doctor fixers only for findings explicitly marked safe. ## Unreleased 1.0 stabilization changes The current stabilization pass removes redundant pre-adoption APIs before the compatibility promise hardens. ### CLI commands use one vocabulary Provider setup now uses the singular `provider` command group and capability-first preset names. Replace `beignet providers add redis-cache` with `beignet provider add cache-redis`, and replace `providers audit` with `provider audit`. App creation now accepts the same catalog through `--providers`; the former `--integrations` flag is removed. Workflow artifact names consistently use `feature.name`, including the value passed to `make listener --event`. The slash form is removed. `make feature --with` accepts singular addon names such as `event`, `job`, and `upload`. Resource generation spells out `--authorization` and `--tenant-scoped`, and manual schedule runs use `--run-id`. Existing-app `make`, `db`, `task`, `schedule`, and `outbox` commands now accept `--cwd ` consistently. ### Runtime port wiring has an explicit name The app's compile-time port shape remains in `ports/index.ts`. Rename the runtime wiring file and value so they cannot be confused with `AppPorts`: ```bash mv infra/app-ports.ts infra/port-wiring.ts ``` ```typescript // Before import { appPorts } from "@/infra/app-ports"; // After import { initialPorts } from "@/infra/port-wiring"; ``` Pass `initialPorts` to the server's `ports` option and use it as the base in test context factories. Custom CLI config replaces `paths.infrastructurePorts` with `paths.portWiring`: ```typescript export default defineConfig({ paths: { ports: "src/ports/index.ts", portWiring: "src/infra/port-wiring.ts", }, }); ``` There is no compatibility alias for the former path, symbol, or config key. ### Query schemas declare their URL transport Every HTTP contract with a query schema now passes an explicit transport as the second `.query(...)` argument. This keeps Standard Schema validation and transforms separate from the URL representation shared by typed clients, servers, and OpenAPI. ```typescript // Before listTodos.query( z.object({ limit: z.coerce.number().int().optional(), tags: z.array(z.string()).optional(), }), ) // After import { defineQueryTransport, query } from "@beignet/core/contracts"; listTodos.query( z.object({ limit: z.number().int().optional(), tags: z.array(z.string()).optional(), }), defineQueryTransport({ limit: query.integer(), tags: query.array(query.string()), }), ) ``` Map logical strings, numbers, integers, booleans, RFC 3339 strings, and JavaScript dates with the matching scalar helper. Scalar arrays use repeated query parameters, and `query.deepObject(...)` supports one flat object. Remove schema coercion that existed only to parse URL strings; the server decodes the declared transport before it runs the schema. Empty arrays and objects are omitted by default. Opt into `{ empty: "preserve" }` only when Beignet typed clients must distinguish empty from omitted values. Redesign deeper nested query objects because their wire shape is no longer inferred. `beignet doctor --strict` reports legacy one-argument query declarations that still need a transport. ### Event subscriptions expose lifecycle handles `EventBusPort.subscribe(...)` and `registerListeners(...)` now return an `EventSubscription` instead of a synchronous disposer. Await initial readiness before accepting work and await asynchronous cleanup during shutdown: ```typescript // Before const unregister = eventBus.subscribe(UserRegistered, handleUserRegistered); unregister(); // After const subscription = eventBus.subscribe( UserRegistered, handleUserRegistered, ); await subscription.ready; await subscription.unsubscribe(); ``` Move app listener registration from provider `setup()` into `start()`, await `registration.ready`, and keep the handle for `stop()`. The current `beignet make listener` output already uses this lifecycle. A listener registry has one 10-second readiness deadline by default; set `readyTimeoutMs` explicitly when the process needs another bounded startup policy. Startup failure starts all child cleanup within the same deadline; if transport cleanup cannot settle in time, the readiness rejection includes a `ListenerRegistrationCleanupTimeoutError` instead of blocking indefinitely. ### Provider registration uses factories Better Auth provider options are now explicit, and the package also exposes a direct port adapter for custom wiring: ```typescript // Before createBetterAuthProvider(auth) // After createBetterAuthProvider({ auth }) // Direct adapter for app-local provider wiring createBetterAuthPort({ auth }) ``` Provider packages no longer export shared `xProvider` instances. Import and call the matching factory in `server/providers.ts`: ```typescript import { createRedisCacheProvider } from "@beignet/provider-cache-redis"; export const providers = [createRedisCacheProvider()] as const; ``` The same mechanical change applies across first-party providers, for example `pinoLoggerProvider` becomes `createPinoLoggerProvider()` and `stripePaymentsProvider` becomes `createStripePaymentsProvider()`. ### Provider factories expose stable named types First-party provider factories now return named provider types such as `RedisCacheProvider`, `PinoLoggerProvider`, and `DrizzlePostgresProvider`. Their concrete Zod config schemas are private implementation details; exported config interfaces still describe the validated values when an app needs that shape. This does not change provider registration or `InferProviderPorts` results. `PostgresConfigSchema` and `MysqlConfigSchema` are no longer exported from the Drizzle backend subpaths. Apps that imported either schema should own any app-level environment validation and use `PostgresConfig` or `MysqlConfig` for the corresponding validated shape. ### Framework-neutral server imports come from core Route declarations, route registries, hooks, and framework-neutral server types come from `@beignet/core/server`. Runtime adapter packages expose only their platform-specific APIs: ```typescript import { contractsFromRoutes, createRoutes, defineRoutes, } from "@beignet/core/server"; import { createNextServer, createNextServerLoader } from "@beignet/next"; import type { AppContext } from "@/app-context"; const { defineRouteGroup } = createRoutes(); ``` Use `@beignet/web` for Web Fetch conversion and server adapters. In particular, replace `toNextResponse(...)` with `toWebResponse(...)` imported from `@beignet/web`. ### Test helpers use one subpath Move imports from `@beignet/core/ports/testing` to `@beignet/core/testing`. Fixtures, recording adapters, actors, policy helpers, assertions, factories, seeds, and provider test installation now share that single public boundary. ### Schedule instrumentation uses shared provider targets The schedule-specific `ScheduleInstrumentation` type was removed. `createInlineScheduleRunner(...)` now accepts a shared provider instrumentation target; existing record-only sinks remain structurally compatible. You can also pass the complete context ports object when running a schedule inside an application boundary: ```typescript const runner = createInlineScheduleRunner({ ctx, instrumentation: ctx.ports, }); ``` The runner now follows the shared [provider instrumentation contract](/writing-a-provider#instrumentation), records events under the `schedules` watcher, and isolates instrumentation sink failures. `onHookError` now reports lifecycle hook failures only. ### Route declarations use an app-bound builder Create the route builders once and import them from feature route files: ```typescript // lib/routes.ts import "@beignet/core/server-only"; import { createRoutes } from "@beignet/core/server"; import type { AppContext } from "@/app-context"; export const { defineRoute, defineRouteGroup } = createRoutes(); ``` Replace `defineRoute()(...)` and `defineRouteGroup()(...)` calls with the corresponding app-bound builder. Add `paths.routesBuilder` when the file is not `lib/routes.ts`. ### Listener identity is the first argument Move listener names out of the options object and put the event in options: ```typescript defineListener("posts.enqueue-published-email", { event: PostPublished, async handle({ payload, ctx }) {}, }); ``` ### Integration names match their role - Replace `createAuthBetterAuthProvider(...)` with `createBetterAuthProvider(...)`. - Replace `@beignet/provider-webhooks-github` with `@beignet/webhooks-github`. - Replace `@beignet/provider-webhooks-stripe` with `@beignet/webhooks-stripe`. The webhook packages are route-bound server integrations. They no longer carry provider audit metadata or appear in `beignet provider audit`. ### Removed aliases | Removed | Replacement | | --- | --- | | `contract.pathTemplate` | `contract.path` | | `defineFlagRegistry(...)` | `defineFlags(...)` | | `ctx.ports.db.db` | `ctx.ports.db.drizzle` | | `defineRoute()(...)` | app-bound `defineRoute(...)` from `lib/routes.ts` | | `defineRouteGroup()(...)` | app-bound `defineRouteGroup(...)` from `lib/routes.ts` | | `createAuthBetterAuthProvider(...)` | `createBetterAuthProvider(...)` | | `@beignet/provider-webhooks-github` | `@beignet/webhooks-github` | | `@beignet/provider-webhooks-stripe` | `@beignet/webhooks-stripe` | | `toNextResponse(...)` | `toWebResponse(...)` from `@beignet/web` | | `createInMemoryEventBus(...)` | `createMemoryEventBus(...)` | | `createInMemoryEventBusProvider(...)` | `createMemoryEventBusProvider(...)` | | `InMemoryEventBus*` types | `MemoryEventBus*` types | | `ScheduleInstrumentation` | `ProviderInstrumentationTarget` from `@beignet/core/providers` | ## Getting help from the compiler Most Beignet migrations are import or option-shape changes. TypeScript should identify every affected call site. When a removed symbol appears only in a generated registry or configuration string, `beignet doctor --strict` and `beignet provider audit` cover the registration and provider metadata that the compiler cannot inspect. See [Stability and releases](/stability) for the compatibility policy. --- # Writing a provider Source: https://www.beignetjs.com/writing-a-provider This page is for provider authors. A port is the app-facing interface, a provider adapts an external system to Beignet at startup, and an adapter in `infra/` wires them into app ports — see [Ports and adapters](/ports) and [Providers](/providers) for the app-side view. A reusable provider package combines four things: a `createProvider(...)` definition with a bounded lifecycle, typed contributed ports, provider instrumentation, and a static metadata manifest in `package.json`. ## Lifecycle Define providers with `createProvider(...)` from `@beignet/core/providers`. `setup` runs during server creation and returns the contributed ports plus optional hooks: `start` runs after all providers have contributed ports, and `stop` runs when the server is stopped. ```typescript import type { SearchPort } from "@beignet/core/search"; import { type AnyProviderConfigSchema, createProvider, type ServiceProvider, } from "@beignet/core/providers"; import { z } from "zod"; export interface SearchProviderConfig { API_KEY: string; REGION?: string; } const SearchProviderConfigSchema = z.object({ API_KEY: z.string(), REGION: z.string().optional(), }) satisfies z.ZodType; export interface SearchProviderPorts { search: SearchPort; } export type SearchProvider = ServiceProvider< unknown, AnyProviderConfigSchema, Pick >; export function createSearchProvider(): SearchProvider { return createProvider({ name: "search", config: { schema: SearchProviderConfigSchema, envPrefix: "SEARCH_", }, async setup({ config, ports }) { if (!config) throw new Error("Missing search provider config"); const client = await connectSearch(config.API_KEY, config.REGION); return { ports: { search: createSearchPort(client), } satisfies SearchProviderPorts, async stop() { await client.close(); }, }; }, }); } ``` `config` accepts any Standard Schema library. `envPrefix` reads matching environment variables and strips the prefix before validation, so `SEARCH_API_KEY` becomes `{ API_KEY: ... }`. `overrides` merges defined values over the env-derived input before validation, keyed by schema field name. Provider factories use it to make code-supplied options win over environment variables — the framework applies the precedence, so factories declare `overrides: { API_KEY: options.apiKey }` instead of hand-rolling schema defaults or setup-time fallbacks. `undefined` values are ignored, so absent options fall back to env and schema defaults. First-party Beignet providers declare their config schemas with Zod, and that is a sanctioned convention rather than drift from the framework's Standard Schema stance: the `config.schema` seam stays library-agnostic, but a provider package already carries vendor dependencies, its config schema is internal to the package, and Zod's ubiquity makes provider code easier to read and contribute to. Use whichever Standard Schema library you prefer in your own providers; nothing in the framework depends on the choice. Reusable provider packages should give the factory an explicit, named provider return type. Use `AnyProviderConfigSchema` to erase the private validation library and schema structure while keeping the validated output and contributed ports exact. Export a plain config interface for that output, and check the private schema with `satisfies z.ZodType`. This keeps schema library upgrades and validation refinements out of the package's compatibility surface without weakening `InferProviderPorts`. Lifecycle hooks should do bounded resource work: create clients, run startup checks, close resources. Do not start polling loops, queue consumers, or other unbounded background work from `setup` or `start` in serverless apps; put background work behind explicit entrypoints such as cron routes, job functions, or worker processes. Inside `setup`, `ports` contains base app ports plus ports contributed by earlier providers, and `createServiceContext` is a late-bound factory for app service contexts. It is unavailable during `setup`. After every provider has contributed its ports and Beignet has validated deferred bindings, the factory is available to `start()` hooks, runtime entrypoints, and `stop()` hooks. It is unavailable again after shutdown completes. Use `start()` for bounded consumer activation that needs a complete service context, such as registering listeners and awaiting their initial transport acknowledgement. Keep polling loops and request-independent workers behind explicit long-lived runtime entrypoints. ## Contributing ports and typing Name exports after the conventions on [Providers](/providers): `createXProvider(...)` for lifecycle provider factories and `createXPort()` or a domain-specific factory for direct implementations. Provider factories may use env-backed defaults when called without options; reusable packages should not export a shared provider instance. App-local providers should use the curried `createProvider()` form to declare the ports they require from earlier providers plus their app context and service-context input. Inside `setup`, `ports` is typed as the declared requirements and `createServiceContext` returns the app context. Annotate the returned ports with a `Pick` of the keys the provider fulfills: ```typescript const providedPorts: Pick = { ...repositories, uow: createUnitOfWork(ports.db.drizzle), }; return { ports: providedPorts }; ``` That keeps `AppRuntimePorts` aligned with the app port contracts instead of intersecting concrete adapter types. Two related inference details: - Lifecycle hooks returned from `setup` should close over setup locals. A `start(ctx)` hook with an unannotated parameter keeps TypeScript from inferring the provided ports from the returned `ports` object; annotate `ctx` with `ProviderLifecycleContext<...>` if the hook needs typed ports. - Apps merge contributed ports with `InferProviderPorts`, so the `Provided` type your setup result infers is part of your public API. When the port your provider installs is a stable Beignet port such as `CachePort` or `MailerPort`, also expose the raw client under a provider-specific key as an [escape hatch](/providers#escape-hatches), for example `redis` or `resend`. ## Instrumentation Add provider instrumentation when a provider performs meaningful external work that should appear in [devtools](/devtools). Use `createProviderInstrumentation()` from `@beignet/core/providers` instead of depending on devtools directly: ```typescript import { createProvider, createProviderInstrumentation, } from "@beignet/core/providers"; export function createSearchProvider() { return createProvider({ name: "search", setup({ ports }) { const instrumentation = createProviderInstrumentation(ports, { providerName: "search", watcher: "custom", }); return { ports: { search: { async query(text: string) { const results = await runSearch(text); instrumentation.custom({ name: "search.query", label: "Search query", summary: `${results.length} results`, details: { resultCount: results.length }, }); return results; }, }, }, }; }, }); } ``` The helper accepts a ports object or an instrumentation port and resolves the sink in one canonical order: `ports.instrumentation`, then `ports.devtools`. With no sink installed, recording is a no-op. The helper also checks watcher enablement, applies Beignet's default redaction to event details, attaches `providerName`, and isolates both synchronous sink failures and rejected asynchronous sink writes so instrumentation cannot replace the provider operation's result or error. Use the watcher that matches the provider's category — `db`, `cache`, `storage`, `uploads`, `mail`, `payments`, `notifications`, `auth`, `audit`, `policies`, `rateLimit`, `jobs`, `outbox`, `schedules`, or `eventBus` — and `custom` or a custom watcher name for application-specific integrations. ## Package metadata manifest Reusable provider packages declare static metadata in `package.json` under `beignet.provider`. It is side-effect-free and lets Beignet tooling inspect installed provider packages without importing provider code, peer dependencies, or environment-sensitive modules. ```json { "name": "@acme/beignet-provider-search", "beignet": { "provider": { "displayName": "Search provider", "ports": ["search"], "appPorts": [{ "name": "search", "type": "SearchPort" }], "env": ["SEARCH_API_KEY", "SEARCH_REGION"], "requiredEnv": ["SEARCH_API_KEY"], "registration": { "required": true, "tokens": ["createSearchProvider"] }, "watchers": ["custom"] } } } ``` `env` lists all environment variables the provider may read; `requiredEnv` is the subset that `beignet doctor --strict` should require in app config. For mutually exclusive credential paths, use `requiredEnvAlternatives` instead of `requiredEnv`. Each nested array is one complete configuration, and tooling accepts the provider when any one is complete: ```json { "env": ["SEARCH_API_KEY", "SEARCH_CLIENT_ID", "SEARCH_OIDC_TOKEN"], "requiredEnvAlternatives": [ ["SEARCH_API_KEY"], ["SEARCH_CLIENT_ID", "SEARCH_OIDC_TOKEN"] ] } ``` `registration.required: true` marks providers that apps must register in `server/providers.ts`; doctor reports a missing registration as a warning, which fails `beignet doctor --strict`. Optional-by-design providers such as `@beignet/devtools` declare `registration.severity: "hint"` instead, so an installed-but-unregistered package is reported as an informational hint that never fails doctor. `tokens` lists the export names doctor looks for in `server/providers.ts`. `beignet doctor --strict` reads this metadata to check the generated app convention: installed lifecycle provider packages registered, app-facing provider ports declared in `ports/index.ts`, required env vars present in app config. Malformed metadata is reported before provider-derived diagnostics are used. Validate the manifest shape with `parseProviderPackageMetadata`: ```typescript import { parseProviderPackageMetadata } from "@beignet/core/providers"; const result = parseProviderPackageMetadata(packageJson.beignet?.provider); ``` `beignet provider audit` reads the same manifest as a report-only inventory. Use it while developing provider packages or reviewing an app's infrastructure: the human table shows metadata validity, registration status, required env, required tables, and declared app ports without failing CI. Use `beignet provider audit --json` when a script or coding agent needs active variants, watchers, and the full provider facts in machine-readable form. Provider objects can also carry runtime-inert `metadata` (`packageName`, `ports`, `requires`, `env`, and `watchers`) for app-local tooling and custom diagnostics. ### Variants Packages whose subpath exports target different backends declare per-backend metadata under `variants` instead of one top-level requirement set. The first-party example is the Drizzle database provider, where each subpath reads different env vars and registers a different factory: ```json { "beignet": { "provider": { "displayName": "Drizzle database provider", "ports": ["db"], "env": [ "SQLITE_DB_URL", "SQLITE_DB_AUTH_TOKEN", "POSTGRES_DB_URL", "MYSQL_DB_URL" ], "watchers": ["db"], "variants": [ { "name": "sqlite", "displayName": "Drizzle SQLite provider", "env": ["SQLITE_DB_URL", "SQLITE_DB_AUTH_TOKEN"], "requiredEnv": ["SQLITE_DB_URL"], "registration": { "required": true, "tokens": ["drizzleSqliteProvider", "createDrizzleSqliteProvider"] } }, { "name": "postgres", "displayName": "Drizzle Postgres provider", "env": ["POSTGRES_DB_URL"], "requiredEnv": ["POSTGRES_DB_URL"], "registration": { "required": true, "tokens": [ "drizzlePostgresProvider", "createDrizzlePostgresProvider" ] } }, { "name": "mysql", "displayName": "Drizzle MySQL provider", "env": ["MYSQL_DB_URL"], "requiredEnv": ["MYSQL_DB_URL"], "registration": { "required": true, "tokens": ["drizzleMysqlProvider", "createDrizzleMysqlProvider"] } } ] } } } ``` Each variant accepts `name`, optional `displayName`, `env`, `requiredEnv`, `requiredEnvAlternatives`, `requiredTables`, and `registration` with the same shapes as the top-level fields. `requiredTables` is for tables that are required whenever that provider or variant is used; doctor checks them in `infra/db/schema/`, `drizzle/`, and app database setup files. Top-level `requiredEnv`, `requiredEnvAlternatives`, `requiredTables`, and `registration` must be absent when `variants` is present; declare them on each variant instead, and `parseProviderPackageMetadata` rejects manifests that mix the two. Doctor checks variant packages per detected variant: it matches each variant's `registration.tokens` against `server/providers.ts`, requires the `requiredEnv` of only the variants the app actually registers, and — when no variant is detected — reports a single registration diagnostic that lists every variant so the app can pick one. ## README checklist Provider package READMEs are part of the public setup surface. Keep first-party and reusable provider READMEs in the same shape so a user can answer the same questions every time: - install command, including `@beignet/core` and required vendor SDKs - required and optional environment variables, with local-development notes - minimal `server/providers.ts` registration or route/server-boundary wiring - ports installed by the provider, plus any provider-specific escape hatches - instrumentation and devtools events the provider records - failure behavior: what throws, what is retried, and what returns a framework response when a route helper is involved - local/test substitute, such as a memory port or fake verifier - deployment notes and provider-specific footguns Verifier packages that do not install lifecycle ports should say that explicitly and show where the verifier is passed, usually `createWebhookRoute(...)` or `verifyWebhook(...)`. ## Durable workflow conventions Providers that participate in jobs, events, schedules, or outbox delivery must be explicit about the failure semantics they own. Do not silently downgrade a Beignet retry policy. | Provider behavior | Requirement | | --- | --- | | Implements Beignet retry and dead-letter behavior | Store attempts, compute backoff or accept Beignet's computed retry time, and expose terminal failure state. | | Maps to an external provider retry model | Document the mapping, preserve Beignet's total-attempt language, and fail fast when the external provider cannot honor backoff, jitter, or retry classification. | | Runs work inline or in memory | Document that delivery is not durable and that process crashes can lose work. | | Starts background work | Put workers behind explicit entrypoints such as cron routes, job functions, or worker processes. Do not start unbounded loops from serverless provider lifecycle hooks. | | Depends on an external worker dependency | Expose or document a readiness check that can prove the queue, stream, scheduler, or worker backend is reachable after app boot. | First-party examples: `@beignet/provider-db-drizzle` implements the durable outbox port with claim leases, attempts, retry timing, and dead-letter state; `@beignet/provider-jobs-bullmq` maps Beignet fixed/exponential retry policy to BullMQ attempts/backoff, exposes queue health on its escape hatch, and keeps worker execution behind an explicit entrypoint; `@beignet/provider-jobs-inngest` maps Beignet job total attempts to Inngest retries and rejects retry fields Inngest cannot honor; `@beignet/provider-event-bus-redis` adapts Redis Pub/Sub for cross-process best-effort event delivery while documenting that missed messages are not replayed and failed handlers are not retried or dead-lettered; `@beignet/provider-event-bus-memory` is deterministic for tests but documents that it is not a durable delivery provider. ## Testing expectations Provider packages ship colocated tests covering both the port behavior and the provider adaptation: the direct factory against the port contract, the provider's config loading and lifecycle, and instrumentation events when the provider records them. Keep app-specific conventions out of the package; ship strong defaults and a README with setup docs instead. `@beignet/provider-event-bus-memory` is a compact reference implementation: a direct port factory (`createMemoryEventBus`), a provider factory (`createMemoryEventBusProvider`) that passes `ports` through to `createProviderInstrumentation`, typed contributed ports, and colocated tests. --- # API reference Source: https://www.beignetjs.com/api-reference Beignet's API reference is generated from the public TypeScript exports and TSDoc comments in the package source. Use it when you need exact function signatures, option shapes, return types, class members, or provider exports. The guide pages explain how the framework pieces fit together. The generated reference answers what a specific public API accepts and returns. [Open generated API reference](/typedoc/index.html)