# 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 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 ```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 ``` The initial migration ships vendored in the scaffold's `drizzle/` folder, so `db migrate` is the only database step before the first run. 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=beignet_test -p 5432:5432 postgres:17-alpine # MySQL 8.0+ docker run --rm -d -e MYSQL_ROOT_PASSWORD=beignet -e MYSQL_DATABASE=beignet_test -p 3306:3306 mysql:8.4 ``` ## 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 run lint bun beignet lint bun beignet doctor bun run typecheck ``` `routes` confirms which contracts are wired to route files. `bun run lint` runs Biome's code lint. `bun run format` applies Biome formatting. `beignet lint` checks that feature layers do not import runtime, UI, or infrastructure code in the wrong direction. `doctor` checks for route, OpenAPI, and resource drift. `typecheck` verifies that route handlers, use cases, ports, clients, and tests still agree with the contracts. ## 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/app-ports.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/app-ports.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." - **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. ## 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/app-ports.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 ``` 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/app-ports.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 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. 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//components/` | Feature-owned UI and client workflows | | `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/providers.ts` | Beignet lifecycle providers installed at server startup | | `server/tasks.ts`, `server/outbox.ts`, `server/schedules.ts` | App-owned registries and CLI contexts for tasks, outbox draining, and schedules | | `app/api/` | Thin Next.js route files that expose `server.api` | | `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/app-ports.ts` | | `lib/` | Small app helpers: `env.ts`, `auth.ts`, and the `use-case.ts` builder | | `client/` | Typed Beignet client and frontend adapter factories | 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. `beignet doctor` treats their absence as fine and their misplacement as drift. ## 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) | | 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) | | 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) | | Scheduled work | `features//schedules/` and a cron/provider trigger | [Schedules](/schedules) | | Mail | `ctx.ports.mailer` and mail provider adapters | [Mail](/mail) | | Notifications | `features//notifications/` and `ctx.ports.notifications` | [Notifications](/notifications) | | Structured logging | `ctx.ports.logger` and request logging hooks | [Logging](/logging) | | 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//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 files and 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. 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. - 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/`. 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 helpers plus their feature's contracts, and call endpoints through React Query and form adapters. `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, and a catch-all `app/api/[[...path]]/route.ts` exposes `server.api`. That convention gives 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). ## Custom paths Use `beignet.config.ts` when your app keeps the same architecture under different paths: ```typescript import { defineConfig } from "@beignet/cli/config"; export default defineConfig({ paths: { contracts: "src/features", features: "src/features", routes: "src/app/api", server: "src/core/server/index.ts", }, }); ``` Config changes where the CLI looks and writes. It does not replace 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. --- # 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 } 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 `server.api`; 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 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(), })) .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(), }), }) }); ``` ### 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. ## 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). ## 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 generation currently requires Zod schemas. ### 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, ... } ``` --- # 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`, creates a child span from the request's trace context, 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. ```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"`. ## 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 "@beignet/next"; import type { AppContext } from "@/app-context"; 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. The server owns the input merge 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. --- # 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 appPorts = 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 appPorts; ``` ## 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 appPorts = 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 server = await createNextServer({ ports: appPorts, 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 } from "@beignet/next"; import type { AppContext } from "@/app-context"; export const server = await createNextServer({ ports: appPorts, context: ({ ports, req }) => ({ requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(), ports, }), }); ``` ```typescript import { defineRouteGroup } from "@beignet/next"; import type { AppContext } from "@/app-context"; 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; }); ``` ## 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 = createInMemoryEventBus(); export const appPorts = 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 and parses each event payload before publishing. If after-commit flushing fails, the UOW rejects without running rollback hooks because the transaction work already succeeded. ## 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. 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 } from "@beignet/next"; import { appPorts } from "@/infra/app-ports"; import { appContext } from "@/server/context"; import { routes } from "@/server/routes"; export const server = await createNextServer({ ports: appPorts, 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) — and the `instrumentation` option covered in [Request lifecycle](/request-lifecycle#request-instrumentation). ## 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 { server } from "@/server"; export const DELETE = server.api; export const GET = server.api; export const HEAD = server.api; export const OPTIONS = server.api; export const PATCH = server.api; export const POST = server.api; export const PUT = server.api; ``` Next App Router requires literal named exports for each HTTP method, so Beignet exposes one `server.api` handler that you assign to every verb your API should accept. 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, and outbox drain routes. `server.contracts` is populated by `createNextServer({ routes })`; 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; see the [`@beignet/web` README](https://www.npmjs.com/package/@beignet/web) for setup. 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 { 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; 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"; export type AppServiceContextInput = | { tenantId?: string; } | undefined; export const appContext = defineServerContext< AppContext, AppContext["ports"] >()({ gate: (ports) => ports.gate, request: async ({ ports, req, requestId, trace }) => { const auth = await ports.auth.getSession(req); const tenantId = req.headers.get("x-tenant-id") || undefined; return { actor: auth ? createUserActor(auth.user.id) : createAnonymousActor(), auth, requestId, ...trace, ports, tenant: tenantId ? createTenant(tenantId) : undefined, }; }, 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` and `trace` values, and returns the context fields available to all handlers. 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`. An optional `service` factory powers `server.createServiceContext(...)`, the context used by schedules, outbox drains, and background work; it receives fresh `requestId` and `trace` values per call and typically defaults `actor` to `createServiceActor(...)`. 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: 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 }`). `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 // features/todos/routes.ts import { defineRouteGroup } from "@beignet/next"; import type { AppContext } from "@/app-context"; 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`. The use case input defaults to a merge of the parsed request parts, and 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. Reusing schemas by reference is how the framework knows validation already happened. The default input mapping — query lowest, then body, then path, headers never merged — is exported as `defaultBinderInput`. Routes that read headers, 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` (the raw 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. Compose feature groups at the server boundary: ```typescript // server/routes.ts import { contractsFromRoutes, defineRoutes } from "@beignet/next"; 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. ## 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 export const POST = 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 } }; }); export const GET = server.route(downloadReport).handle(async () => new Response(await loadReportBytes(), { headers: { "Content-Type": "application/pdf", "Content-Disposition": 'attachment; filename="report.pdf"', }, }), ); export const GET = server.route(robotsTxt).handle(async () => new Response("User-agent: *\nAllow: /\n", { headers: { "Content-Type": "text/plain; charset=utf-8" }, }), ); export const POST = server.route(startCheckout).handle(async () => Response.redirect("https://checkout.example.com/session/123", 303), ); ``` 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. A single `Set-Cookie` header works in the framework-neutral `{ status, headers, body }` form, so the route keeps response validation and correlation headers. To set multiple cookies in one response — HTTP requires one `Set-Cookie` header per cookie — return a native `Response` and `headers.append("set-cookie", ...)` each one. Auth providers such as Better Auth own their cookie flows through their own mounted routes, so most apps never set cookies from contract routes. 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 server = await 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. ## 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). --- # 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 -> beforeHandle hooks -> route handler or use case -> route-owned response validation -> beforeSend hooks (correlation response headers) -> 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`. A catch-all `app/api/[[...path]]/route.ts` file exposes `server.api`, so the adapter can dispatch the incoming request to the registered contract and handler. 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. `HEAD` is not implicitly mapped to `GET`: a `HEAD` request on a GET-only path gets a `405` with `Allow: GET`, so metadata-driven hooks never run a handler for a method the contract did not declare. CORS preflight `onRequest` hooks still short-circuit `OPTIONS` requests before the `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. ## 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. 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. ## 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. Use them for non-JSON payloads, redirects, streaming, and Server-Sent Events. 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 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 [Logging](/logging) and [Rate limiting](/rate-limiting). Server hooks are configured on the server: ```typescript export const server = await 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, createLoggingHooks, } from "@beignet/core/server"; const hooks = [ createCorsHooks({ origins: "*" }), createLoggingHooks({ logger }), ]; ``` Route hooks live beside route groups: ```typescript import { createAuthHooks, defineRouteGroup } from "@beignet/core/server"; 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. Server `beforeHandle` hooks run before scoped route hooks. 5. Route hooks run and add route-specific context. After every hook, the server re-attaches the gate so policies always see the current identity. 6. The route runs: the bound use case or the full `handle` implementation. 7. `beforeSend` can shape the final response. 8. `afterSend` observes completion. 9. `onCaughtError` observes caught failures. 10. `mapUnhandledError` maps unknown or otherwise unhandled failures. ## `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 curried `defineRouteGroup()({ ... })` form when group hooks add fields to `ctx`; route hooks append after group hooks. Ordinary route groups that do not add context should use the direct `defineRouteGroup({ ... })` form: ```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 "@beignet/core/server"; const route = defineRoute(); route({ 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(), ], ``` 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 or shape framework-owned responses. 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. ## 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 "@beignet/next"; import type { AppContext } from "@/app-context"; 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. ## 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[] ``` ### 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 validation), `"network"` (a failed fetch), or `"contract"` (a malformed or contract-invalid response). 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, and the client serializes the parsed values returned by your schema. Input validation is off by default. ```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. ## Serving from a route Expose the spec as a JSON endpoint: ```typescript // app/api/openapi/route.ts import { createOpenAPIHandler } from "@beignet/next"; import { server } from "@/server"; export const GET = createOpenAPIHandler(server.contracts, { title: "My API", version: "1.0.0", }); ``` `server.contracts` is populated from the route list passed to `createNextServer({ routes })`. 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, export an explicit contract list or an importable route registry and pass that to `createOpenAPIHandler(...)`. ## 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", }); ``` 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. 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. 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`. ## 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. Use stable, explicit `operationId` values before publishing a route externally. Prefer versioned paths such as `/api/v1/issues` for breaking request or response changes. Mark old operations with `.openapi({ deprecated: true })` while they are still served. 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. ## 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, defineRouteGroup } from "@beignet/core/server"; 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/core/ports` 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`) | ```typescript import { requireTenant, requireUser } from "@beignet/core/ports"; const createPost = useCase .command("posts.create") .input(CreatePostInput) .output(PostOutput) .run(async ({ ctx, input }) => { const user = requireUser(ctx); const tenant = requireTenant(ctx); return ctx.ports.posts.create({ ...input, tenantId: tenant.id, authorId: user.id, }); }); ``` 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`, so 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.11 ``` The starter pins Better Auth to `1.6.11` to avoid transitive adapter drift in clean installs. Revisit the pin intentionally when upgrading Better Auth. ```typescript import { createAuthBetterAuthProvider } from "@beignet/provider-auth-better-auth"; import { auth } from "@/lib/better-auth"; export const providers = [ createAuthBetterAuthProvider(auth), ]; ``` The provider wraps an already configured Better Auth instance and installs the same shared `AuthPort` on `ctx.ports.auth`. Better Auth still owns its own login, signup, callback, and session routes. Mount those routes beside your Beignet API routes. ## 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 } from "@beignet/core/ports"; import { createAuthHooks } from "@beignet/core/server"; import { z } from "zod"; import type { AppContext } from "@/app-context"; import { env } from "@/lib/env"; const serviceHeadersSchema = z.object({ "x-api-key": z.string().min(1), "x-service-name": z.string().min(1), }); export const serviceAuth = createAuthHooks()({ name: "internal.service", headers: serviceHeadersSchema, resolve: ({ headers }) => { if (headers["x-api-key"] !== env.INTERNAL_API_KEY) return null; return { actor: createServiceActor(headers["x-service-name"]), }; }, }); ``` 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. - [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. ## 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/app-ports.ts export const appPorts = 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"; export const appContext = defineServerContext< AppContext, AppContext["ports"] >()({ gate: (ports) => ports.gate, request: async ({ ports, req, requestId, trace }) => { const tenantId = req.headers.get("x-tenant-id") || undefined; 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: tenantId ? createTenant(tenantId) : undefined, }; }, }); ``` 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 const result = await ctx.ports.posts.findMany({ page, tenantId: ctx.tenant?.id, }); ``` 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 `requireTenantId(ctx)` (or `requireTenant(ctx)`) from `@beignet/core/ports`. The helpers throw `TenantRequiredError`, which the server maps to a framework-owned `403` with code `TENANT_REQUIRED`: ```typescript import { requireTenantId } from "@beignet/core/ports"; const tenantId = requireTenantId(ctx); ``` For sensitive records, prefer repository methods that require tenant scope: ```typescript const record = await ctx.ports.records.findById({ recordId: input.recordId, tenantId, }); if (!record) { throw appError("RecordNotFound", { details: { recordId: input.recordId }, }); } await ctx.gate.authorize("records.view", record); ``` ## 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); ``` ## Test policies Use `@beignet/core/ports/testing` for matrix tests that document tenant, ownership, and role decisions without going through HTTP: ```typescript import { createPolicyTester } from "@beignet/core/ports/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( auditEntry(ctx, { 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`. --- # 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. ## 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 "bun:test"; import { createUseCaseTester } from "@beignet/core/application"; import { createTestUserActor } from "@beignet/core/ports/testing"; import { createTestContextFactory, createTestPorts } from "@beignet/core/testing"; import type { AppContext } from "@/app-context"; import { createProjectUseCase } from "@/features/projects/use-cases"; import { appPorts } from "@/infra/app-ports"; import { createInMemoryProjectRepository } from "@/infra/projects/in-memory-project-repository"; test("creates a project", async () => { const fixture = createTestPorts({ base: appPorts, overrides: { gate: appPorts.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. 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/ports/testing"; import { createTransactionPorts } from "@/infra/db/transaction-ports"; import type { AppTransactionPorts } from "@/ports"; const projects = createInMemoryProjectRepository(); const fixture = createTestPorts({ base: appPorts, overrides: { gate: appPorts.gate, projects }, transaction: { outbox: true, ports: (ports) => createTransactionPorts({ audit: ports.audit, repositories: { projects }, idempotency: ports.idempotency, outbox: ports.outbox, }), }, }); // ...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 "bun:test"; import { createTestPorts } from "@beignet/core/testing"; import { defineRoutes } from "@beignet/web"; 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 { appPorts } from "@/infra/app-ports"; 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: appPorts, overrides: { auth: { getSession: async () => ({ user: { id: "user_test" } }) }, gate: appPorts.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. 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. ## 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 "bun:test"; import { createTestSystemActor } from "@beignet/core/ports/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 an `infra/db/seed.ts` entrypoint. ## Assertion helpers `@beignet/core/ports/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/ports/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/ports/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 run test bun run lint bun run typecheck bun beignet lint bun beignet doctor ``` `test` covers behavior. `bun run lint` runs Biome's code lint. `typecheck` catches contract/type drift. `beignet lint` checks dependency direction. `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(redisProvider, { 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/ errors.ts forms.ts index.ts features/ todos/ components/ todo-app.tsx contracts.ts ``` The feature component imports the contract and uses the client helpers: ```typescript "use client"; import { useMutation, useQuery } from "@tanstack/react-query"; import { rq } from "@/client"; import { rhf } from "@/client/forms"; import { createTodo, listTodos } from "@/features/todos/contracts"; const createTodoForm = rhf(createTodo); export function TodoApp() { const todosQuery = useQuery(rq(listTodos).queryOptions({ query: {} })); const form = createTodoForm.useForm({ defaultValues: { title: "" } }); const createTodoMutation = useMutation(rq(createTodo).mutationOptions()); const onSubmit = form.handleSubmit((body) => { createTodoMutation.mutate({ body }); }); return
{/* fields */}
; } ``` This is feature colocation, not server colocation. Components can import contracts and frontend helpers, but they should not import use cases, route groups, infra adapters, provider packages, `server/`, or `app-context.ts`. ## 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. ## 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. ## 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 `mutate(...)` invocation 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" } }); ``` Separate `mutate(...)` calls get separate keys, so retry stability does not deduplicate double-clicks — disable the submit button while the mutation is pending, or pass an explicit key when two 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` object entries so cache keys match URL serialization. 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 tenant or workspace header, for example — opt that specific header into keys at the adapter level: ```typescript export const rq = createReactQuery(apiClient, { keyHeaders: ["X-Tenant-Id"], }); ``` 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-Tenant-Id": "tenant-1", Authorization: "Bearer ..." }, }); // queryKey: ["beignet", "todos", "listTodos", "GET /todos", // { headers: { "x-tenant-id": "tenant-1" } }] ``` Never whitelist credential headers. For one-off cases, the per-call `key` override remains the escape hatch. ## Infinite queries For paginated data, use `infiniteQueryOptions`. ```typescript import { useInfiniteQuery } from "@tanstack/react-query"; import { listTodos } from "@/features/todos/contracts"; import { rq } from "@/client"; 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, }), ); ``` For cursor pagination, keep stable filters in the generated key and put the cursor in `page(...)`: ```typescript const todosQuery = useInfiniteQuery( rq(listTodos).infiniteQueryOptions({ query: { status: "open", limit: 20, }, initialPageParam: null as string | null, page: ({ pageParam }) => ({ query: { cursor: pageParam, }, }), getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, }), ); ``` Body-paginated contracts, such as a POST search endpoint, work the same way: 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.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 it when the browser should keep owning the server-state workflow after the first render. When a Server Component only needs server-rendered data and no client cache, call the use case directly with `server.createContextFromNext()` instead of making an internal HTTP request. ## 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. ## 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. ## Basic form ```typescript 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 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 ``` ## 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 scope uploads by tenant can pass a `headers` function (for example `"x-tenant-id"`). 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()` | 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 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. ## 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, 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 } from "@beignet/next"; import { loggerPinoProvider } from "@beignet/provider-logger-pino"; import { redisProvider } from "@beignet/provider-redis"; import { appPorts } from "@/infra/app-ports"; export const server = await createNextServer({ ports: appPorts, providers: [loggerPinoProvider, redisProvider], 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/app-ports.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. 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.db), uow: createUnitOfWork(ports.db.db), }; 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 // Ready-to-install provider singletons go directly in providers: [] redisProvider loggerPinoProvider mailSmtpProvider // Provider factories accept app-owned runtime input and return a provider createDrizzleSqliteProvider({ schema }) createAuthBetterAuthProvider(auth) createInMemoryEventBusProvider() // Direct port factories return concrete implementations for manual wiring createInMemoryEventBus() createMemoryMailer() ``` Use `xProvider` or `createXProvider(...)` for Beignet lifecycle providers registered with `providers: []`. Use `createXPort()` or a domain-specific factory name for direct implementations assigned under `ports`. ## 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 appPorts = 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 server = await createNextServer({ ports: appPorts, providers: [loggerPinoProvider, mailSmtpProvider], 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 server = await createNextServer({ ports: appPorts, providers: [ loggerPinoProvider, // installs ctx.ports.logger redisProvider, // 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. ```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-redis` | `cache`, plus `redis` escape hatch | [Cache](/cache) | | Storage | `@beignet/provider-storage-local`, `@beignet/provider-storage-s3` | `storage`, plus `s3Storage` for S3-compatible provider escape hatch | [Storage](/storage) | | Mail | `@beignet/provider-mail-resend`, `@beignet/provider-mail-smtp` | `mailer`, plus `resend` or `smtp` escape hatch | [Mail](/mail) | | 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` | `eventBus` | [Events](/events) | | Auth | `@beignet/provider-auth-better-auth` | `auth` | [Authentication](/authentication) | | Jobs | `@beignet/provider-inngest` | `jobs`, plus `inngest` escape hatch | [Jobs](/jobs) | ## 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. [Writing a provider](/writing-a-provider) covers all of these. ## 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 `infra/db/seed.ts` entrypoint decides which feature seeds run for local/demo environments. New apps do not scaffold seeds; add `infra/db/seed.ts` and a `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.db); const idempotency = createDrizzleSqliteIdempotencyPort(ports.db.db); const providedPorts: Pick = { ...repositories, idempotency, uow: createDrizzleSqliteUnitOfWork({ db: ports.db.db, 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/app-ports.ts`, and the server fails boot if a deferred port is still unbound after providers have started. `ctx.ports.db.db` is an infrastructure escape hatch. Keep it out of use cases. Use cases should call `ctx.ports.posts` or `ctx.ports.uow.transaction(...)`. ## 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. ## 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 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, input.tenantId), 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.db, 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. 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. ## 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.db, 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. 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 seed beignet db reset ``` `beignet db generate` and `beignet db migrate` delegate to the app's Drizzle Kit scripts. `beignet db seed` and `beignet db reset` delegate to app-owned entrypoints such as `infra/db/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`. 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-redis ioredis ``` ```typescript import { createNextServer } from "@beignet/next"; import { redisProvider } from "@beignet/provider-redis"; import { appPorts } from "@/infra/app-ports"; export const server = await createNextServer({ ports: appPorts, providers: [redisProvider], 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 `createRedisProvider(options)` when the app should own connection defaults. Environment variables still win when both are set: ```typescript import { createRedisProvider } from "@beignet/provider-redis"; const redisProvider = createRedisProvider({ connectTimeoutMs: 2000, maxRetriesPerRequest: 1, }); ``` ## 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; } ``` 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. --- # 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 { localStorageProvider } from "@beignet/provider-storage-local"; export const providers = [localStorageProvider]; ``` 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 { server } from "@/server"; export const { GET, HEAD } = createStorageRoute(server.ports.storage, { basePath: "/storage", }); ``` The route serves public objects only. Missing objects, private objects, invalid keys, and paths outside `basePath` all return 404. 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 ``` ```typescript import { s3StorageProvider } from "@beignet/provider-storage-s3"; export const providers = [s3StorageProvider]; ``` 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. 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`. ## 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: ```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, 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. ## 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. ## Define an upload Put feature-owned upload definitions under `features//uploads/`: ```typescript // features/posts/uploads/attachment.ts import { defineUpload } from "@beignet/core/uploads"; import { z } from "zod"; import type { AppContext } from "@/app-context"; const Metadata = z.object({ postSlug: z.string().min(1), }); export const PostAttachmentUpload = defineUpload< "posts.attachment", typeof Metadata, AppContext, { attachmentIds: string[] } >("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 extension = file.name.split(".").pop(); return `posts/${tenantId}/${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.create({ 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. 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 { server } from "@/server"; const uploadRouter = createUploadRouter({ uploads: uploadsFromRegistry(postUploads), ctx: () => server.createContextFromNext(), storage: server.ports.storage, instrumentation: server.ports.devtools, }); export const { POST } = createUploadRoute(uploadRouter); ``` 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, then run `onComplete`. | ## 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 { createS3UploadSigner } from "@beignet/provider-storage-s3"; const uploadRouter = 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. ## 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, validates file count, content type, and size, then writes accepted files through `ctx.ports.storage`. ## Error codes Upload routes return errors as a JSON envelope with an optional `details` value: ```json { "error": { "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 (415), or completed-object verification failed (422). | | `UNAUTHORIZED_UPLOAD` | 403 | The upload's `authorize` hook denied the request. | | `UPLOAD_OBJECT_NOT_FOUND` | 404 | Completion could not find the direct-uploaded object in storage. | 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. Model them as app-owned attachment status, jobs, events, or provider adapters that run after the object exists. The upload primitive keeps the boundary focused on validated object creation and completion hooks. --- # 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. ## 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, so local development can see outgoing mail without sending anything. ```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 ``` ```typescript import { mailResendProvider } from "@beignet/provider-mail-resend"; export const providers = [mailResendProvider]; ``` 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 ``` ```typescript import { mailSmtpProvider } from "@beignet/provider-mail-smtp"; export const providers = [mailSmtpProvider]; ``` SMTP reads `MAIL_HOST`, `MAIL_PORT`, `MAIL_USER`, `MAIL_PASS`, and `MAIL_FROM`. 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. ## 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 Inngest 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 } from "@beignet/next"; import { createAnonymousActor } from "@beignet/core/ports"; import { createRateLimitHooks } from "@beignet/core/server"; import { upstashRateLimitProvider } from "@beignet/provider-rate-limit-upstash"; import { appPorts } from "@/infra/app-ports"; export const server = await createNextServer({ ports: appPorts, providers: [upstashRateLimitProvider], hooks: [createRateLimitHooks()], context: ({ ports }) => ({ actor: createAnonymousActor(), ports, }), }); ``` The provider reads `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN`, and the optional `UPSTASH_PREFIX` and `UPSTASH_ALGORITHM` 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. For `scope: "user"` limits, install auth hooks before rate limiting so the anonymous baseline actor is replaced with the signed-in user actor. ## 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 | `global` | | `ip` | `onRequest`, before parsing and context | `ip:` | | `user` | `beforeHandle`, after context exists | `user:` | Use `global` for coarse protection, `ip` for anonymous traffic, and `user` for signed-in workflows. For `user` limits, put the auth hook before the rate limit hook so `ctx.actor` is assigned to a user actor before enforcement. If the request actor is missing, anonymous, service, or system, the default user key falls back to `global`. ## 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 `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. ## Trusted proxies and client IPs `ip`-scoped limits resolve the client IP from the `x-forwarded-for` header. Proxies append the address they saw to the end of the header, so the last entry is the one written by your platform's trusted reverse proxy. Earlier entries — including the first — are sent by the client and can be forged to rotate buckets and bypass IP limits. By default the hook uses the last `x-forwarded-for` entry. Use the `ipSource` option to change the strategy: ```typescript // Default: last entry, appended by the platform's trusted proxy. createRateLimitHooks({ ipSource: "x-forwarded-for-last" }); // First entry. Only safe behind an edge that strips and rewrites the header. createRateLimitHooks({ ipSource: "x-forwarded-for-first" }); // Platform-specific headers set by a trusted edge. createRateLimitHooks({ ipSource: (req) => req.headers.get("cf-connecting-ip") ?? undefined, }); ``` Use `"x-forwarded-for-first"` only when a trusted edge normalizes the header before it reaches the app. Prefer a custom `ipSource` function when your platform sets a dedicated client-IP header such as `cf-connecting-ip` or `x-real-ip`. When no IP can be resolved, the key falls back to `ip:unknown`, which shares a single bucket across all unidentified clients. ## 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`. 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 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`. ## 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. --- # Workflows Source: https://www.beignetjs.com/workflows Beignet splits durable application work into small primitives: events, jobs, schedules, tasks, notifications, 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 | | 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 the eighth primitive: 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. 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. ## 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. 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`, and `createTasks` follow the same shape in `lib/listeners.ts`, `lib/notifications.ts`, `lib/schedules.ts`, and `lib/tasks.ts`. New apps do not ship these files; the `beignet make` generators create each one on first use. ## 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. See [Routes and server](/server) for the blueprint. ## 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 { appError } from "@/features/shared/errors"; import { auditEntry } from "@/lib/audit"; 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 }) => { return ctx.ports.uow.transaction(async (tx) => { const intake = await tx.intakes.findById({ id: input.intakeId, tenantId: ctx.tenant.id, }); 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( auditEntry(ctx, { 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 patients before appointments, 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/ports/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, parses, and publishes them. 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 and parses the payload before publishing through the event bus. ## 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(PostPublished, { name: "posts.enqueue-published-email", async handle({ payload, ctx }) { await ctx.ports.jobs.dispatch(SendPostPublishedEmailJob, payload); }, }); ``` Listeners should live with domain or application code, then be registered from infrastructure startup. ## Register listeners ```typescript import { registerListeners } from "@beignet/core/events"; import { postListeners } from "@/features/posts/listeners"; const unregister = registerListeners(eventBus, postListeners, { ctx, onError(error, listener) { ctx.ports.logger.error("Listener failed", { error, listener: listener.name, }); }, }); ``` Call `unregister()` during teardown when your runtime has a long-lived process. `beignet make event` scaffolds and registers new events, and `beignet doctor` flags unregistered listeners and events; see [CLI](/cli) for the generator and doctor details. ## 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 { createInMemoryEventBusProvider } from "@beignet/provider-event-bus-memory"; export const providers = [createInMemoryEventBusProvider()] as const; ``` Tests and app-owned wiring can also create the bus directly with `createInMemoryEventBus()` from the same package. To swap in a real bus, keep the `eventBus: EventBusPort` entry in `AppPorts` and replace the provider registration in `server/providers.ts` with an adapter for your queue or stream. Production apps that need durable event delivery should use [Outbox](/outbox) or adapt their transport behind the same event bus port; feature code keeps publishing through `ctx.ports.eventBus` either way. ## 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 Inngest 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 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(...)`. ## 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, }); }, }); ``` ## Durable dispatch with Inngest Install the Inngest provider when production jobs should be queued outside the request process: ```bash bun add @beignet/provider-inngest @beignet/core inngest ``` ```typescript import { inngestProvider } from "@beignet/provider-inngest"; export const providers = [inngestProvider]; ``` The provider installs `ctx.ports.jobs` and exposes `ctx.ports.inngest.client` as an escape hatch for Inngest-specific features. Workers are defined separately from your Beignet HTTP server: ```typescript // app/api/inngest/route.ts import { createInngestJobFunction } from "@beignet/provider-inngest"; import { serve } from "inngest/next"; import { SendWelcomeEmailJob } from "@/features/users/jobs"; import { createBackgroundContext } from "@/infra/background-context"; import { inngest } from "@/infra/inngest"; const sendWelcomeEmail = createInngestJobFunction({ client: inngest, job: SendWelcomeEmailJob, ctx: () => createBackgroundContext(), }); export const { GET, POST, PUT } = serve({ client: inngest, functions: [sendWelcomeEmail], }); ``` `createBackgroundContext()` is app-owned. Build it next to your infrastructure so worker jobs get the same ports, logger, auth assumptions, and devtools instrumentation shape as cron routes or other background workflows. See [Deployment](/deployment#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. ## 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. | | 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 equivalent failure queue. | ## 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). ## 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 { server } from "@/server"; export const schedules = [...digestSchedules] as const; export async function createScheduleContext(): Promise { return server.createServiceContext({ actor: createServiceActor("beignet-schedule"), }); } export async function stopScheduleContext(): Promise { 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 { server } from "@/server"; import { schedules } from "@/server/schedules"; export const runtime = "nodejs"; export const { GET, POST } = createScheduleRoute({ server, 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 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 [Deployment](/deployment#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). ## Observability Pass an instrumentation sink 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: ```typescript import { resolveProviderInstrumentationPort } from "@beignet/core/providers"; const runner = createInlineScheduleRunner({ ctx, instrumentation: resolveProviderInstrumentationPort(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 the instrumentation port resolved from `ctx.ports`, and providers can emit the same `schedule` devtools events through provider instrumentation. For custom logging or metrics, the runner also exposes `onStart`, `onSuccess`, and `onError` lifecycle hooks. 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. 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); // The client generates and attaches the 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. Automatic keys prevent retry storms: HTTP retries of one logical call reuse one key, so the server executes the command once. They do not deduplicate separate calls — a double-click that triggers two `call(...)` or `mutate(...)` invocations sends two keys and runs the command twice. Guard double-submits in the UI, or pass the same explicit `idempotencyKey` for both attempts. `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"; export const server = await createNextServer({ ports: appPorts, hooks: [createIdempotencyHooks()], // ... }); ``` The hook reads `contract.metadata.idempotency` and enforces it with `ctx.ports.idempotency`. After request parsing 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 2xx responses for replay; on 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 | | --- | --- | | `global` | `"global"` | | `actor` | `{ actorId: ctx.actor?.id }` | | `tenant` | `{ tenantId: ctx.tenant?.id }` | | `actor-tenant` | `{ actorId: ctx.actor?.id, tenantId: ctx.tenant?.id }` | 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; 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. 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 JSON representation, omitting the key itself and other request-only metadata, and does not store the original payload. `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/app-ports.ts import { createMemoryIdempotencyStore } from "@beignet/core/idempotency"; export const appPorts = definePorts({ idempotency: createMemoryIdempotencyStore(), // other ports... }); ``` 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. ## 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. ## 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 `server.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. --- # 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. ## 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 port is intentionally small: ```typescript import type { OutboxPort } from "@beignet/core/outbox"; export type AppPorts = { outbox: OutboxPort; }; ``` 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. ## 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 } from "@beignet/core/outbox"; import { createDrizzleSqliteOutboxPort, createDrizzleSqliteUnitOfWork, } from "@beignet/provider-db-drizzle/sqlite"; uow: createDrizzleSqliteUnitOfWork({ db: ports.db.db, createTransactionPorts: (tx) => { const outbox = createDrizzleSqliteOutboxPort(tx); return { posts: createPostRepository(tx), audit: createAuditLog(tx), events: createOutboxEventRecorder(outbox), outbox, }; }, }); ``` This keeps `.emits(...)` and `events.record(...)` enforcement intact while moving durability into infrastructure. ## 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. ## Registry and draining The worker needs an explicit registry of typed events and jobs it may deliver. New apps do not ship `server/outbox.ts`; create it by hand when a feature first needs durable delivery: ```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 { server } from "@/server"; export const outboxRegistry = defineOutboxRegistry({ events: postEvents, jobs: postJobs, }); export async function createOutboxDrainContext(): Promise { return server.createServiceContext({ actor: createServiceActor("beignet-outbox"), }); } export async function stopOutboxDrainContext(): Promise { await server.stop(); } ``` `server.createServiceContext(...)` builds a [service context](/workflows#service-contexts) for the drain worker. Once `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. 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 { server } from "@/server"; import { outboxRegistry } from "@/server/outbox"; export const runtime = "nodejs"; export const { GET, POST } = createOutboxDrainRoute({ server, registry: outboxRegistry, secret: env.CRON_SECRET, batchSize: 100, }); ``` 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 [Deployment](/deployment#outbox-drains) for the difference between cron routes, worker-hosted drains, and command-based drains. `drainOutbox(...)` claims available messages, validates payloads against the registry, publishes events through `eventBus`, dispatches jobs through `jobs`, then marks each message delivered. Failed deliveries are retried with backoff until `maxAttempts`, then dead-lettered. 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. 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. Customize the retry delay for outbox delivery when the worker needs an override: ```typescript await drainOutbox({ outbox, registry, eventBus, jobs, retryDelayMs: ({ message }) => Math.min(60_000, 1000 * message.attempts), }); ``` ## Drizzle SQLite `@beignet/provider-db-drizzle` includes a durable outbox adapter on its `/sqlite` subpath: ```typescript bun add @beignet/provider-db-drizzle ``` ```typescript import { createDrizzleSqliteOutboxPort, createDrizzleSqliteOutboxSetupStatements, } from "@beignet/provider-db-drizzle/sqlite"; const outbox = createDrizzleSqliteOutboxPort(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. Production apps can call the same port from jobs or listeners so notification delivery is backed by jobs and outbox after the database transaction commits. ## 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. ## 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 sends notification -> notification channel sends mail or dispatches channel work ``` --- # 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 } from "@beignet/core/tasks"; import type { AppContext } from "@/app-context"; import { issueTasks } from "@/features/issues/tasks"; import { server } from "./index"; export const tasks = defineTasks([...issueTasks] as const); export async function createTaskContext(): Promise { return server.createServiceContext({ actor: createServiceActor("beignet-cli"), }); } export async function stopTaskContext(): Promise { await server.stop(); } ``` The `tasks` export is the registry the CLI loads. `createTaskContext` builds the app context for a run — a service actor plus the app's ports, providers, audit, and devtools wiring — and `stopTaskContext` 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 --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. Pass `--json` for machine-readable output in CI or release jobs. ## 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 [Going to production](/deployment#one-off-tasks) for 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. --- # 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 } from "@beignet/next"; import { loggerPinoProvider } from "@beignet/provider-logger-pino"; import { appPorts } from "@/infra/app-ports"; export const server = await createNextServer({ ports: appPorts, providers: [loggerPinoProvider], context: ({ ports, req }) => ({ requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(), ports, }), }); ``` 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. ## 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, 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: new URL(req.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`. ## 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. 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** sends exceptions and failure context to an external system such as Sentry, Datadog, Honeycomb, Axiom, or a hosted log pipeline. - **Alerting** turns symptoms into operator action. Beignet does not ship a first-party error-reporting provider yet. Use an app-owned port so your use cases and hooks do not depend on a vendor SDK directly. ## App-owned error reporter port Define the port in your app: ```typescript // ports/error-reporter.ts export interface ErrorReporterPort { captureException( error: unknown, context?: Record, ): Promise | void; captureMessage( message: string, context?: Record, ): Promise | void; } ``` Add it to `AppPorts`, then implement the adapter in `infra/`: ```typescript // infra/error-reporting/noop-error-reporter.ts import type { ErrorReporterPort } from "@/ports/error-reporter"; export function createNoopErrorReporter(): ErrorReporterPort { return { captureException: () => {}, captureMessage: () => {}, }; } ``` Production adapters can wrap a vendor SDK while tests use the no-op or a memory reporter. Keep vendor SDK imports in `infra/`, provider packages, or worker entrypoints; do not import them from use cases, domain code, contracts, or feature routes. ## HTTP request errors Use `onCaughtError` to observe errors without changing the response mapping. Use `mapUnhandledError` only to decide the response body. ```typescript const errorReportingHooks = { name: "error-reporting", onCaughtError: async ({ err, ctx, req, contract }) => { await ctx?.ports.errorReporter.captureException(err, { requestId: ctx.requestId, contractName: contract.name, method: req.method, path: new URL(req.url).pathname, actorId: ctx.actor?.id, tenantId: ctx.tenant?.id, }); }, }; export const server = await createNextServer({ ports: appPorts, hooks: [errorReportingHooks], context: appContextBlueprint, mapUnhandledError: ({ ctx }) => ({ status: 500, body: { code: "INTERNAL_SERVER_ERROR", message: "Internal server error", requestId: ctx?.requestId, }, }), }); ``` Report unexpected exceptions and infrastructure failures. Expected business errors 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. ## Jobs, schedules, and outbox drains HTTP hooks do not see every background failure. Add reporting where work runs: - Job handlers should report terminal failures or failures that exceed retry policy. - Outbox drains should report dead-lettered messages and repeated drain failures. - Schedule handlers should report failed runs after the scheduler or job layer has decided whether to retry. - Operational tasks should report failed backfills, imports, exports, and repair jobs. 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. Use [Privacy lifecycle](/privacy-lifecycle) to define which fields may leave app-owned storage and which fields must only appear as stable identifiers. Use Beignet's shared redaction helpers before sending structured metadata: ```typescript import { redactHeaders, redactValue } from "@beignet/core/ports"; ctx.ports.errorReporter.captureException(error, { headers: redactHeaders(request.headers), provider: redactValue(providerMetadata), }); ``` ## 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. ## Devtools versus production reporting Devtools are local diagnostics. They are useful for reproducing failures and checking request correlation, but they are not a production alerting system. Use devtools locally, structured logs in production, and a production error reporter for exceptions that operators need to triage. ## Testing Use a memory reporter in tests: ```typescript export function createMemoryErrorReporter(): ErrorReporterPort & { exceptions: Array<{ error: unknown; context?: Record }>; } { const exceptions: Array<{ error: unknown; context?: Record; }> = []; return { exceptions, captureException: (error, context) => { exceptions.push({ error, context }); }, captureMessage: () => {}, }; } ``` Assert that terminal failure paths report enough context, and assert that expected business failures do not create noisy production alerts. ## 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"; const audit = createAmbientAuditLog( createInstrumentedAuditLog({ audit: createDrizzleAuditLog(db), instrumentation: ports, }), ); ``` 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. ## 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. ## 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(PostPublished, { name: "posts.enqueue-published-email", 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 `patients.update` or `posts.publish` | | `actor` | User, service, system, or anonymous actor that initiated the action | | `tenant` | Tenant, organization, clinic, 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, and provider activity. ```bash bun add @beignet/devtools ``` ## Setup ### 1. Register the provider ```typescript import { createDevtoolsProvider } from "@beignet/devtools"; import { createNextServer } from "@beignet/next"; export const server = await 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`. 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 { server } from "@/server"; export const { GET, POST } = createDevtoolsRoute(server.ports.devtools, { basePath: "/api/devtools", }); ``` 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. 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. ### 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, 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`, `notifications`, `auth`, `audit`, `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`, `cookie`, `set-cookie`, `x-api-key`, `token`, `password`, `secret`, and `credentials` are replaced with `[redacted]`. 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`, `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 | | `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. Both default to development-only behavior. ```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 return `404` when `NODE_ENV === "production"` unless explicitly enabled. Generated apps wire `DEVTOOLS_ENABLED` through `lib/env.ts`: the devtools route is enabled when `DEVTOOLS_ENABLED=true`, disabled when `DEVTOOLS_ENABLED=false`, and development-only by default. The starter's sidebar reads the same value, so the Devtools link appears exactly when the route is available. For staging or internal production diagnostics, add application-owned authorization: ```typescript export const { GET, POST } = createDevtoolsRoute(server.ports.devtools, { basePath: "/api/devtools", enabled: process.env.DEVTOOLS_ENABLED === "true", authorize: (req: Request) => req.headers.get("x-devtools-token") === process.env.DEVTOOLS_TOKEN, }); ``` If `authorize` returns `false`, devtools responds with `404`. If it returns a `Response`, that response is used. --- # 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 these in CI before shipping: ```bash bun run lint bun run test bun run typecheck bun beignet lint bun beignet doctor --strict ``` `bun run lint` runs Biome's code lint. 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 meant for CI: 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. Confirm the CLI can inspect the route map: ```bash bun beignet routes ``` If your app exposes OpenAPI, make sure the OpenAPI route is generated from the same route list used by the server, typically with `server.contracts` or `contractsFromRoutes(routes)`. Run `doctor` after changing contracts or route registration. ## 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. - Cron routes receive the expected `Authorization` header. - Build logs do not print secrets. - Upload body limits match the largest allowed Beignet upload definition. - OpenAPI and devtools routes have the intended exposure. - Source maps, stack traces, and error reporting settings match the team's incident response plan. ## CORS, CSRF, and client IPs For credentialed browser requests: - Do not combine `credentials: true` with `origin: "*"` or `origins: ["*"]`. Use an explicit origin allow-list per environment. Beignet's doctor warns about credentialed wildcard CORS. - Keep cookies `HttpOnly`, `Secure` in production, and `SameSite=Lax` or `SameSite=Strict` unless your auth flow requires cross-site cookies. For cross-site cookie flows, add app-owned CSRF protection through your auth provider or a route hook. 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. See [Rate limiting](/rate-limiting) for the trusted-proxy details, `ipSource` options, 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. Check these before shipping: - Database clients and migrations are ready for the target environment. - 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. ## Runtime entrypoints Beignet apps should run background and operational work from explicit bounded entrypoints. That keeps serverless deployments safe and gives long-running worker deployments the same app-owned context, ports, actor, tenant, logging, audit, and devtools wiring as HTTP requests. | Work | Runtime entrypoint | Beignet surface | | --- | --- | --- | | API requests | The app's catch-all route or fetch handler | `server.api`, `createNextServer(...)`, or `createFetchHandler(...)` | | Cron-triggered schedules | Platform cron route, scheduled function, or worker-hosted task | `features//schedules/`, `server/schedules.ts`, `beignet schedule run ` | | Durable event and job delivery | Cron route, worker process, queue consumer, or schedule | `server/outbox.ts`, `createOutboxDrainRoute(...)`, `beignet outbox drain` | | One-off maintenance | Local shell, CI job, release job, or admin worker | `features//tasks/`, `server/tasks.ts`, `beignet task run ` | | Provider-backed jobs | Provider route or worker host | Job provider helpers such as `createInngestJobFunction(...)` | Do not start polling loops, queue consumers, or interval drains from provider `setup` or `start` hooks in serverless apps. Provider hooks should install ports and prepare clients. The host should decide when to invoke the work. Every HTTP-triggered operational entrypoint can mutate production data without a browser user, so protect each one. `createScheduleRoute(...)` and `createOutboxDrainRoute(...)` authenticate with `CRON_SECRET`; for custom cron routes, compare `Authorization: Bearer ` before doing work. ## 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`, creates the app context, runs the schedule, records devtools events, and returns a status. See [Schedules](/schedules) for the route code and failure semantics, or generate a schedule with its cron route in one step with `beignet make schedule / --cron "0 9 * * *" --route`. 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 posts.log-daily-summary --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 Next.js deployments where a platform cron invokes HTTP. See [Outbox](/outbox) for the drain route code, retry timing, and dead-letter handling. Use the CLI when the host can run a bounded command: ```bash beignet outbox drain --batch-size 100 ``` Both paths should load the same `server/outbox.ts` registry and context. A serverless invocation should drain one bounded batch and exit. A long-running worker may call the same command or app function repeatedly, but the loop belongs to the worker host, not provider lifecycle hooks. ## Workers Provider-backed workers should adapt Beignet definitions instead of bypassing them. For example, an Inngest route can map a Beignet job definition to an Inngest function while using an app-owned background context. Worker deployments should document which Beignet definitions they may execute, how they create actor and tenant context, and which retry semantics the provider owns. Prefer the outbox when work must be committed atomically with database writes. Prefer provider-backed jobs when the provider should own queueing, scheduling, or retries. Beignet does not expose a generic `jobs worker` command yet. Use `beignet outbox drain` for Beignet-owned durable rows and provider entrypoints such as Inngest functions for provider-owned queues. Add a framework job worker only when an adapter has a clear queue protocol for claiming, retrying, and shutting down work safely. ## One-off tasks Use app [tasks](/tasks) for backfills, repair jobs, import/export work, 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, and prefer CLI entrypoints over exposed HTTP routes for work that operators or CI trigger by hand. ## Devtools Devtools are for local development by default. Production route handlers return `404` unless explicitly enabled. If you enable devtools in staging or an internal environment, add an `authorize` callback to the devtools route, keep event retention short, and redact sensitive fields before recording custom events. Beignet's doctor warns when a devtools route is explicitly enabled without an authorization callback. See [Devtools](/devtools) for the route options and production-enable semantics. ## Uploads and storage Every upload definition should set explicit file constraints: allowed content types, `maxSizeBytes`, and visibility. Require `authorize(...)` on definitions that write user-owned data, 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`. See [Uploads](/uploads) and [Storage](/storage) for definition options and object ownership. ## 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. - [Schedules](/schedules), [Outbox](/outbox), [Jobs](/jobs), and [Tasks](/tasks) for the runtime entrypoints this page wires up. - [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. --- # 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. 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 lint` | Enforce architecture dependency direction. | | `beignet doctor` | Report framework drift, optionally fixing it. | | `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 integrations to add. A selection flag (`--api`, `--db`, or `--integrations`) 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, and Pino are part of every starter, so passing them to `--integrations` is an error. Integrations add external service providers on top: the provider package, peer dependencies, wiring in `server/providers.ts`, `.env.example` entries, and setup notes in `docs/integrations.md`. | Integration | Adds | | --- | --- | | `inngest` | `@beignet/core/jobs`, `@beignet/provider-inngest`, and `inngest` | | `resend` | `@beignet/provider-mail-resend` and `resend` | | `upstash-rate-limit` | `@beignet/provider-rate-limit-upstash`, `@upstash/ratelimit`, and `@upstash/redis` | | 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. | | `--integrations ` | One value or a comma-separated list. | | `--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`, `lint`, and `doctor` — resolve `beignet.config.*` (`.ts`, `.json`, `.mjs`, or `.js`) path overrides first; omitted paths fall back to the generated defaults. 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`. 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. | ### 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`, `task`/`tasks`, `event`/`events`, `job`/`jobs`, `notification`/`notifications`, `ui`, `upload`/`uploads`. | Each addon writes the same output as the matching standalone generator; `ui` writes a feature-colocated React component wired to the typed client and TanStack Query. ### make resource ```bash beignet make resource projects beignet make resource projects --auth --tenant --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, and 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 | | --- | --- | | `--auth` | Authorization metadata, policy wiring, `ctx.gate.authorize(...)` checks, and a policy matrix test. | | `--tenant` | Tenant-scoped schemas, repository filters, 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 schedule posts/daily-summary --cron "0 9 * * *" --timezone America/Chicago --route beignet make task posts/backfill-search beignet make upload posts/attachment ``` 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 event` and `make job` append the feature's registries to `defineOutboxRegistry({...})` in `server/outbox.ts` when that file already exists; the outbox registry is opt-in, so the generators never create it. See [Outbox](/outbox). - `make event` and `make resource --events` wire `eventBus: EventBusPort` into `AppPorts`, register `createInMemoryEventBusProvider()` 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 integrations 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. | Command | Options | | --- | --- | | `make listener` | `--event /` (required). | | `make schedule` | `--cron ` (defaults to `0 9 * * *`), `--timezone `, `--route`. | 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; run seeds from the app-owned `infra/db/seed.ts` entrypoint with `runSeeds(...)`. See [Database](/database). ## db ```bash beignet db generate beignet db migrate beignet db seed beignet db reset ``` Each subcommand delegates to the app-owned package script of the same name (`db:generate`, `db:migrate`, `db:seed`, `db:reset`) and checks prerequisites first — a missing script, missing `drizzle.config.*`, or removed seed/reset entrypoint produces an error naming the exact file to restore. The starter ships `db:generate`, `db:migrate`, and `db:reset`; run `db migrate` first since the initial migration is vendored, and add a `db:seed` script with your first feature seeds. See [Database](/database). | Option | Description | | --- | --- | | `--dry-run` | Print the command that would run without running it. | | `--json` | Print the script, runner, and captured output as JSON. | ## 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. | Option | Description | | --- | --- | | `--json` | Machine-readable route list. | | `--cwd ` | Inspect an app root in another directory. Must point at an app, not a monorepo root. | ## lint ```bash beignet lint ``` Enforces the architecture boundaries described in [App architecture](/app-architecture): it scans static imports across app layers, runs an additional value-import graph check for contracts and client roots, and exits non-zero on findings. Diagnostics include the offending `file:line:column`. | 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 ``` The framework integrity report. Diagnostics cover seven areas: - **Routes and contracts** — contracts without handlers, handlers without contracts, 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 `server/outbox.ts` exists, listeners that no `registerListeners(...)` call references, and serverless footguns such as background timers in provider files, outbox draining from lifecycle hooks, and outbox registries without a drain entrypoint. - **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, unwired repository adapters, ports without adapters, unguarded resets, and seeds without factories or a `db:seed` script. - **Security and providers** — devtools enabled without authorization, cron routes without `CRON_SECRET`, installed providers without expected env configuration, Better Auth without an auth route or trusted origins, credentialed wildcard CORS, uploads without routes or size limits, and notification dispatchers that bypass `ctx.ports.notifications`. - **Structure and versions** — feature artifacts in non-canonical folders, 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`). 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: add a missing `test` script, register route groups, register unregistered schedule, task, and outbox event/job registries in their existing central files, and repair direct `createOpenAPIHandler([...])` arrays when the contracts are already imported. Registry fixes are append-only and bail out when the central file is missing or customized; listener registration is report-only. | | `--json` | Versioned payload (`schemaVersion: 1`) with `targetDir`, `config`, `strict`, `convention`, `contracts`, `routes`, `diagnostics`, and `fixes`. | | `--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. | ## task run ```bash beignet task run posts.backfill-search --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 `{}`. | | `--module ` | Task registry module. Defaults to `server/tasks.ts` or `paths.tasks`. | | `--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`. | | `--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. | | `--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). | 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. | ## mcp ```bash beignet mcp ``` Runs a Model Context Protocol server over stdio so coding agents can call the CLI as tools: `routes`, `doctor`, `doctor_fix`, `lint`, and `make`. Tool outputs are the same JSON the matching `--json` flags print, and `make` takes the same artifact kinds as `beignet make`. Generated apps ship a `.mcp.json` that registers the server, so MCP clients such as Claude Code pick it up 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 | | --- | --- | | 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` | | Use cases | `@beignet/core/application` | | Operational task definitions | `@beignet/core/tasks` | | Ports, audit logging, redaction, cache, storage, and test adapters | `@beignet/core/ports` and `@beignet/core/ports/testing` | | Provider lifecycle and instrumentation | `@beignet/core/providers` | | Domain helpers | `@beignet/core/domain` | | App errors | `@beignet/core/errors` | | Environment config | `@beignet/core/config` | | Event definitions and listeners | `@beignet/core/events` | | Job definitions and inline dispatch | `@beignet/core/jobs` | | Mail port and memory adapter | `@beignet/core/mail` | | Notification definitions, dispatchers, and test adapters | `@beignet/core/notifications` | | Durable outbox helpers | `@beignet/core/outbox` | | Schedule primitives | `@beignet/core/schedules` | | 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 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` | ## 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` | | Inngest jobs | `@beignet/provider-inngest` | | Pino logging | `@beignet/provider-logger-pino` | | Resend mail | `@beignet/provider-mail-resend` | | SMTP mail | `@beignet/provider-mail-smtp` | | Local storage | `@beignet/provider-storage-local` | | S3-compatible storage | `@beignet/provider-storage-s3` | | Redis | `@beignet/provider-redis` | | Upstash rate limiting | `@beignet/provider-rate-limit-upstash` | ## 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. ## 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 not automatic.** A route group, schedule, task, or outbox registry entry must be registered in its central `server/` file before it runs. The guide lists what to register where, and notes that `beignet doctor --fix` repairs most registration drift. - **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 validation loop.** Run the app's Biome lint, `beignet lint`, `beignet doctor --strict`, tests, and typecheck after each change, 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: the `README.md` owns setup and run instructions, and `AGENTS.md` and `CLAUDE.md` own conventions. Keeping them short keeps agents reading them. ## The MCP server `beignet mcp` runs a Model Context Protocol server over stdio, exposing the CLI's inspection and generation commands as tools: | Tool | What it does | | --- | --- | | `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` | Apply doctor's low-risk fixes: route-group, schedule, task, and outbox registration drift. Listener drift stays report-only and must be fixed by hand. | | `lint` | Report architecture and dependency-direction diagnostics. Read-only. | | `make` | Run a generator with `{ artifact, name, ...options }` — the same artifact kinds as `beignet make`. | Tool outputs are exactly the JSON the CLI's `--json` flags produce, so anything written against `beignet doctor --json` or `beignet lint --json` reads MCP results unchanged. Generated apps ship a `.mcp.json` that registers the server through the app's package runner, 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": "bunx", "args": ["beignet", "mcp"] } } } ``` In npm, pnpm, or yarn apps, use `npx` as the command instead. Both runners resolve the app-local `beignet` bin that generated apps install, so the server always matches the app's CLI version. ## 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, and no compatibility promise has been written yet. It does not mean the project is unmaintained or untested: every release ships through changesets with a documented changelog, and every commit 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 in each package's `CHANGELOG.md`, generated by changesets and shipped inside the published npm package. - Releases ship as changes land. The project has released multiple times per month since work started, often several times in a single week. ## What every release passes CI runs the same gates on every commit to `main`: - 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. - 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. If a release is on npm, it passed all of this. ## 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. ## 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. --- # 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 { createProvider } from "@beignet/core/providers"; import { z } from "zod"; export const searchProvider = createProvider({ name: "search", config: { schema: z.object({ API_KEY: z.string(), REGION: z.string().optional() }), envPrefix: "SEARCH_", }, async setup({ config, ports }) { const client = await connectSearch(config.API_KEY, config.REGION); return { ports: { search: createSearchPort(client) }, 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: ... }`. 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. Calling it before all providers have started throws, so only invoke it lazily from runtime entrypoints such as job dispatch or listeners. ## Contributing ports and typing Name exports after the conventions on [Providers](/providers): `xProvider` for ready-to-install singletons, `createXProvider(...)` for factories that take options, `createXPort()` or a domain factory for direct implementations. 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.db), }; 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 const searchProvider = 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 swallows sink failures so instrumentation can never break provider work. Use the watcher that matches the provider's category — `db`, `cache`, `storage`, `uploads`, `mail`, `notifications`, `auth`, `audit`, `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": ["searchProvider", "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. `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); ``` Provider objects can also carry runtime-inert `metadata` (`packageName`, `ports`, `requires`, `env`, `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`, and `registration` with the same shapes as the top-level fields. Top-level `requiredEnv` 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. ## 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. | First-party examples: `@beignet/provider-db-drizzle` implements the durable outbox port with claim leases, attempts, retry timing, and dead-letter state; `@beignet/provider-inngest` maps Beignet job total attempts to Inngest retries and rejects retry fields Inngest cannot honor; `@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 (`createInMemoryEventBus`), a provider factory (`createInMemoryEventBusProvider`) 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)