@beignet/core
Version:
Core framework primitives for Beignet
452 lines (381 loc) • 22.3 kB
Markdown
---
name: app-architecture
description: "Build Beignet app feature slices with @beignet/core primitives: schemas, contracts, use cases, agent capabilities, app errors, ports, policies, TenantScope, context, providers, runtime integrity, domain events, listeners, jobs, schedules, tasks, notifications, outbox, best-effort work, mail, uploads, seeds, tests, auth helpers, audit, idempotency, storage, cache, rate limits, flags, entitlements, payments, search, webhooks, and explicit core subpath imports. Use when adding or fixing Beignet application behavior, dependency boundaries, tenant-owned repository access, app-facing ports, authorization, workflow primitives, agent entrypoints, or app-bound lib builders."
---
# Beignet App Architecture
Use this skill when working in an app that depends on `@beignet/core`.
Read the app's `AGENTS.md`, `beignet.config.*`, and closest existing feature
slice before changing code.
## Source Order
1. Follow local app instructions and configured paths.
2. Reuse the nearest feature slice's naming and helper exports.
3. Inspect installed `@beignet/core/*` exports and types when an API is unclear.
4. Read narrow Beignet docs only for the concept being changed.
## Feature Slice
Normal feature code belongs here:
```txt
features/<feature>/
contracts.ts
schemas.ts
routes.ts
agent-capabilities.ts
use-cases/
domain/
ports.ts
policy.ts
client/
components/
jobs/
listeners/
notifications/
schedules/
tasks/
uploads/
seeds/
tests/
factories/
```
Small features do not need every file. The starter ships a lean subset;
workflow folders, `lib/` builders, and central registries appear when a
generator or deliberate feature change introduces that concern.
App-wide operational sources use these paths:
- `drizzle/` contains checked-in SQL migrations and Drizzle metadata.
- `server/seed.ts` is an optional entrypoint that composes feature seed
definitions through an application service context.
- `server/workers/` contains optional long-running runtime entrypoints, such
as a BullMQ job worker. Do not start them from provider lifecycle hooks or
serverless routes.
Bind route declarations to the app context once in `lib/routes.ts` with
`createRoutes<AppContext>()`. Feature route files should import the resulting
`defineRoute` or `defineRouteGroup` helper instead of binding `AppContext`
themselves.
## Core Boundaries
- Contracts and schemas must be import-safe for clients and OpenAPI.
- Use cases own business workflows and depend on `ctx.ports`, not infra.
- Domain and use-case code must not import React, routes, providers, server
runtime, or concrete adapters.
- Infra implements ports and may import provider packages, SDKs, and database
clients.
- UI and feature client modules call server behavior through contracts and a
typed client, not by importing use cases.
- Server-only request context helpers may bridge Server Components to
Beignet's request-scoped `AppContext`. Keep them outside `client/`, usually
in `lib/server-context.ts`, and mark them with `@beignet/core/server-only`.
The context factory receives `requestInfo` from the server's explicit
`trustedProxy` policy; store it on request contexts when layouts need the
external URL, origin, host, protocol, or client IP. Layouts may read that
metadata with `ctx.auth` and `ctx.tenant` for redirects and shell state;
feature data still belongs behind use cases.
- Server-only React Query prefetch helpers may consume that context for
hydrated client state. Use them only for direct `{ contract, useCase }` route
bindings whose default request input satisfies the use-case input and whose
use-case output matches the contract success body. When the contract and use
case use different input shapes or the use case reads validated headers, add
an explicit route `input` mapper.
- Server, provider, and infra code may import `@beignet/core/server-only`;
browser-reachable client helpers may import `@beignet/core/client-only` as
lint markers when the app uses them.
## Contracts And Schemas
Use `@beignet/core/contracts` and keep shared DTOs in `schemas.ts`.
```ts
import {
defineContractGroup,
defineQueryTransport,
query,
} from "@beignet/core/contracts";
import { z } from "zod";
import { errors } from "@/features/shared/errors";
export const projectSchema = z.object({
id: z.uuid(),
name: z.string().min(1),
});
const projects = defineContractGroup()
.namespace("projects")
.prefix("/api/projects");
export const getProject = projects
.get("/:id")
.pathParams(z.object({ id: z.uuid() }))
.errors({ ProjectNotFound: errors.ProjectNotFound })
.responses({ 200: projectSchema });
export const listProjects = projects
.get("/")
.query(
z.object({ archived: z.boolean().optional() }),
defineQueryTransport({ archived: query.boolean() }),
)
.responses({ 200: z.array(projectSchema) });
```
If a route-backed use case throws `appError("ProjectNotFound")`, the matching
contract should declare `ProjectNotFound` with `.errors(...)`.
Every query schema needs an explicit transport. The schema owns logical input
validation and transforms; the transport owns client, server, and OpenAPI URL
serialization.
## Use Cases
Use the app-bound `useCase` builder from `lib/use-case.ts`.
```ts
import { requireUserId } from "@beignet/core/ports";
import { appError } from "@/features/shared/errors";
import { projectIdInputSchema, projectSchema } from "@/features/projects/schemas";
import { useCase } from "@/lib/use-case";
export const getProjectUseCase = useCase
.query("projects.get")
.input(projectIdInputSchema)
.output(projectSchema)
.run(async ({ ctx, input }) => {
const userId = requireUserId(ctx);
const project = await ctx.ports.projects.findById(input.id);
if (!project) throw appError("ProjectNotFound", { details: input });
await ctx.gate.authorize("projects.read", project);
return project;
});
```
Use `ctx.ports.uow.transaction(...)` when writes, audit entries, events, jobs,
or outbox records must commit together.
## Errors And Policies
- Generated apps usually place the error catalog in `features/shared/errors.ts`.
Some apps centralize it elsewhere; reuse the existing catalog.
- Business authorization belongs in use cases or feature policies, not only
route metadata.
- Put a policy in the feature that owns the authorized resource. Cross-feature
abilities may still live there when they inspect that resource.
## Ports And Providers
- Feature-specific ports usually live in `features/<feature>/ports.ts`.
- App-wide ports live in `ports/`.
- `ports/index.ts` defines the completed `AppPorts` and transaction-port types.
- `infra/port-wiring.ts` exports `initialPorts`, the app-owned values and
deferred keys passed into server startup.
- `bound` ports are app-owned at boot, such as gates, config, clocks, IDs, or
no-op reporters.
- `deferred` ports are supplied by providers at startup, such as auth,
database, mail, logger, jobs, repositories, idempotency, rate limits,
storage, search, payments, flags, locks, best-effort work, and error reporting.
Do not import provider packages from contracts, use cases, routes, UI, or
feature client modules.
Prefer typed options objects for provider factories. Explicit first-party
options override matching environment values. Treat injected SDK or database
clients as caller-owned unless an option such as `closeOnStop: true` delegates
shutdown to one provider instance, and preserve provider instrumentation when
using a lower-level direct adapter.
Tenant-owned repository methods should accept `TenantScope` instead of raw
tenant IDs. Use cases derive it with `requireTenantScope(ctx)` or
`createTenantScope(...)`; adapters unwrap it with `tenantScopeId(scope)` and
include that storage ID in read, write, update, and delete predicates.
Provider-correlation lookups such as webhook customer IDs are not tenant
authorization by themselves.
Resolve request tenants only from membership-backed app state or provider
claims that the app explicitly treats as authoritative and current. Keep that
decision behind an app-owned resolver. Never turn caller-supplied tenant IDs or
unverified session fields directly into `TenantContext`.
## Encryption
Use `EncryptionPort` from `@beignet/core/encryption` for recoverable server
secrets. Construct `createEncryption({ key, previousKeys })` in
`infra/port-wiring.ts` and bind `encryption` in `AppPorts`; workflows call the
port. Repository adapters may encrypt before writes and decrypt on reads.
Call `encrypt({ value, context? })` and `decrypt({ value, context? })` with strings;
context is a stable plain string record derived from the expected purpose,
authorized tenant, and record. It is authenticated, not authorization.
Generate a persistent key once with `beignet encryption key`. Never generate
keys at startup, expose them to clients, or log plaintext. Keep current and
previous keys in server secret configuration. For rolling rotation, distribute
the next key to all readers before switching writes; keep old keys for existing
records and retained backups. `EncryptionDecryptionError` must fail closed:
never fall back to plaintext or overwrite unreadable data. See the encryption
concept page for re-encryption tasks, recovery, and KMS adapter boundaries.
## Workflows
Feature-owned workflow definitions belong under the feature:
```txt
features/<feature>/domain/events/
features/<feature>/jobs/
features/<feature>/listeners/
features/<feature>/schedules/
features/<feature>/notifications/
features/<feature>/tasks/
features/<feature>/uploads/
features/<feature>/seeds/
```
Small existing apps may use feature-root `jobs.ts` or `schedules.ts`; follow
local style unless migrating deliberately. Central registries and service
contexts live under `server/`.
Context-free declarations stay top-level, such as `defineEvent(...)`.
Context-bound definitions should use app-bound builders created once in `lib/`,
for example `createJobs<AppContext>()`, `createListeners<AppContext>()`,
`createSchedules<AppContext>()`, `createNotifications<AppContext>()`, and
`createTasks<AppContext>()`.
Agent-callable application actions use `@beignet/core/agent-capabilities`.
Create the app-bound capability and registry builders together, keep
definitions feature-owned, and compose them into one explicit registry. The
bound registry rejects definitions from another context or principal type.
Delegate business behavior to existing use cases. The executor's app-owned
context resolver must authenticate the transport principal, re-read tenant
membership from an authoritative port, and call
`server.createServiceContext(...)`; never trust a role from agent claims or
assemble `AppContext` directly. Agent registration, grants, approval, and token
verification belong to the transport integration, not the capability handler.
Executor completion hooks receive validated input and output for best-effort
activity projections. Error hooks receive validated input only after parsing
succeeds. Keep mandatory audit writes in the workflow and never copy raw
malformed input or unvalidated output into instrumentation.
Register workflow artifacts explicitly:
- listeners in `server/listeners.ts`, then through `registerListeners(...)` in
server provider wiring; register in `start()`, await `registration.ready`, and
await `registration.unsubscribe()` in `stop()`
- schedules in `server/schedules.ts`
- tasks in `server/tasks.ts`
- outbox events and jobs in `server/outbox.ts`
- Inngest-backed job functions in `server/inngest.ts`; share one app-owned
client with the jobs provider and mount the registry through the host's
Inngest adapter
- queued notification definitions and their delivery job in
`server/notifications.ts`, then add that job to every worker/outbox registry
- seeds through the app's seed entrypoint, usually `server/seed.ts`
Every event publication path requires canonical JSON Standard Schema output.
Use null, strings, finite numbers, booleans, arrays, and plain objects; represent
timestamps as strings or numbers rather than `Date` objects. Keep validation
deterministic and side-effect-free and transforms idempotent because Beignet can
validate at producer and receiving boundaries. In-process buses preserve proven
validation state, while Redis and outbox boundaries serialize the canonical
output and repeat the stability check after decoding.
Outbox registration describes what a drain may deliver; it does not install
delivery transports. A registry containing events requires
`ctx.ports.eventBus`, and one containing jobs requires `ctx.ports.jobs`. Bind
those ports directly or defer them to providers before exposing a drain.
`drainOutbox(...)` rejects missing transports before claiming a batch, so
wiring failures do not consume delivery attempts.
Notification channels run independently and return per-channel outcomes. Use
the inline dispatcher for immediate delivery and the queued dispatcher/provider
for one independently retryable job per channel. Preferences are an optional
app-owned `NotificationPreferencesPort`; inbox persistence remains app-owned
through `beignet make inbox`.
For apps that opt into runtime boot checks, declare workflow artifacts in
`server/runtime-integrity.ts` with `defineRuntimeManifest(...)`, compare them
to the central registries with `defineRuntimeRegistries(...)`, and pass the
resulting `runtimeIntegrity` check to server startup. This check is pure and
serverless-safe: it proves registry coverage, not worker deployment health.
Uploads are feature-owned definitions under `features/<feature>/uploads/`,
then exposed through an app route with `createUploadRouter(...)` and the
platform adapter. Webhooks and payment webhooks are inbound transport concerns:
define reusable verification or fulfillment in feature/application modules and
keep provider-specific route glue in focused route files.
GitHub and Stripe signature verification use the route-bound
`@beignet/webhooks-github` and `@beignet/webhooks-stripe` integrations. They do
not fill ports or install lifecycle providers, so keep them out of
`server/providers.ts` and provider-audit expectations.
Providers should not start unbounded background loops on server boot. Expose
cron routes, worker entrypoints, task runners, schedule runners, or outbox
drains instead.
Use `BestEffortWorkPort` only for non-durable work whose loss cannot change the
originating operation's result. A platform adapter owns scheduler, callback,
and error-observer failure isolation. In tests, use
`createRecordingBestEffortWork()` or the default recorder from
`createTestPorts(...)`, then flush one bounded batch explicitly. Required or
retryable delivery belongs in a job or transaction-scoped outbox. Request
best-effort work only after the authoritative mutation succeeds, typically
from a listener invoked by an after-commit event flush.
In Next.js 15.1 and newer, an app may request one bounded outbox drain through
`after()` after its Unit of Work commits. Treat this as push-assisted polling,
not durable execution: the transaction-scoped outbox row owns durability and a
recovery cron still owns missed callbacks, delayed messages, and retries.
Outbox drains are serial by default. Parallel delivery is explicitly unordered.
Active claims renew on a serialized heartbeat, and renewals stop after a
bounded maximum duration. Treat nonzero `settlementFailed` or `leaseLost`
counters as terminal operational failures, and never translate a successful
external delivery into `markFailed(...)` when acknowledgement storage is
uncertain. Synchronize drain host clocks because durable adapters compare
worker-supplied lease timestamps.
When tracing is installed, pass the tracing port to outbox recorders and job
dispatchers. Beignet's durable event-bus, BullMQ, and Inngest adapters capture
the active W3C trace carrier automatically and restore it as the consumer-span
parent. Trace metadata is versioned and best-effort: malformed metadata or a
capture failure must never prevent message delivery.
Report failures once at the boundary that knows they are terminal. Retryable
job and outbox attempts stay in instrumentation and logs. Use
`tryReportException(...)` for app-owned listener or direct-runner boundaries;
do not also report inside handlers when an HTTP hook, queue worker, route, or
CLI runner owns the terminal failure. Attach stable identifiers instead of
payloads, and remember that structured redaction does not rewrite exception
messages or make `AppError.details` safe to export.
Best-effort capture is bounded to one second by default. Tune `timeoutMs` when
needed; use `false` only when reporting may intentionally block the boundary.
## Runtime Safety
- Metadata-driven rate limits namespace default global, IP, and user buckets by
contract name. User scope fails closed unless route hooks resolve a user
actor. Custom `key` and `earlyKey` callbacks own the complete key, so include
route identity unless cross-route aggregation is deliberate.
- HTTP idempotency defaults to the current actor and includes the current
tenant when one exists. The default actor scope fails closed when the
required identity is missing. Use
`meta.scope: "global"` only for deliberately public operations whose callers
should share one key namespace.
- Idempotency ports must reject `complete(...)` and `fail(...)` when the
fingerprint, reservation token, or in-progress state no longer matches.
Never silently drop a stale mutation: callers would mistake an unstored
result for a replayable one. The memory adapter throws
`IdempotencyMutationError`; Drizzle dialects expose corresponding
`Drizzle*IdempotencyMutationError` classes.
- `formatMailAddress(...)` rejects carriage returns and line feeds before a
provider builds headers; providers still own complete email-syntax
validation. Mail providers make one vendor call, so put retryable delivery
behind jobs or the outbox.
- Keep storage keys relative and reuse `assertValidStorageKey(...)`,
`normalizeStorageKeyPrefix(...)`, `prefixStorageKey(...)`, and
`createStoragePublicUrl(...)` from `@beignet/core/ports` in custom adapters.
Add provider-specific restrictions only after the shared validation.
- Uploads are protected by default. Declare `authorize(...)` or deliberately
opt into `access: "public"`; server uploads authorize each file before
reading bytes for validation. Include actor, tenant, or resource ownership
in object keys.
- Direct-upload completion is stateless, so make `onComplete(...)` idempotent
by upload ID or object key and use app-owned issuance state for single-use or
revocable grants. Before app-owned completion starts, a failed server batch
removes objects already written. After `onComplete(...)` starts, durable
references may exist and transaction or compensation belongs to the app.
## Validation
After core app-architecture changes, run from the app root:
```bash
beignet lint
beignet doctor --strict
bun run test
bun run typecheck
```
Use the app's package manager and scripts. If package-manager shims are not on
PATH, use local binaries such as `./node_modules/.bin/beignet lint`.
## Broadcasting
Use `@beignet/core/broadcasting` for browser-safe `defineChannel` contracts in
`features/<feature>/channels.ts`. Use `@beignet/core/broadcasting/server` for
`BroadcastPort`, `createBroadcasting<AppContext>()`, explicit authorization
bindings in `broadcasts.ts`, and the registry in `server/broadcasts.ts`. Bind the
builders once in `lib/broadcasting.ts`. Recheck current user, tenant, membership,
and resource access on every connection, including renewal; public channels
also require an explicit authorization callback.
Broadcasts are ephemeral UI hints. Publish after the authoritative write; for
required attempts, record a publication job through the transaction outbox.
Inbox writes and publication jobs share one transaction. The existing app-owned
inbox `channel.ts` is a notification workflow module and may dispatch `tx.jobs`.
Independent notification channels do not guarantee order. Use
`defineBroadcastNotificationChannel` for ephemeral notification delivery.
Capture `resolveBroadcastOrigin` at the HTTP boundary using authenticated
principal/tenant/namespace. Pass captured `broadcastOrigin` through jobs and
publish with `excludeOrigin`; never derive it from worker identity. Keep IDs and
payloads out of telemetry. Browser clients must refetch on initial readiness and
reconnect, close on user/workspace changes, and never import server bindings.
Memory connects one process only; Redis connects replicas/workers and has no
replay. SSE lifetime defaults to 60,000 ms; `maxLifetimeMs` accepts positive safe
integers up to 3,600,000 ms (one hour), including 240,000 for a four-minute
stream. Leave setup/cleanup headroom below the hosting request deadline.
Longer lifetimes reduce renewal/refetch frequency and increase the interval
between authorization checks. The browser honors the advertised lifetime with
five seconds of watchdog grace for the whole connection; later readiness and
heartbeats do not extend it. Missing or invalid lifetime metadata blocks
subscriptions. Heartbeat/readiness timeouts stay independent, and every renewal still needs
`onSync`. Do not start background worker loops in serverless providers.
Broadcast client callbacks also receive `BroadcastConnectionInfo`: `onSync(info)`
and `onStatusChange(status, info)`. Callbacks may omit the metadata argument.
Reasons are `initial`, `planned-renewal` (terminal renewal control frame followed
by EOF), `subscription-change`, `interruption`, or `unknown`. Never infer planned renewal
from elapsed time or an unexplained EOF. Reconcile even after planned renewal.
The browser accepts an optional existing instrumentation sink; do not log
credentials, payloads, or channel parameters. The Next/Web endpoint's optional
`admit` owns connection resource release; policies/expiring leases belong to the
application. React Query's optional `refreshGate` delays only broadcast-driven
refreshes. Consult the integration packages' broadcast-coordination references
for the mutation helper, custom mutation-plus-lock gate, and lease examples.