UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

452 lines (381 loc) 22.3 kB
--- name: app-architecture description: "Build Beignet app feature slices with @beignet/core primitives: schemas, contracts, use cases, agent capabilities, app errors, ports, policies, TenantScope, context, providers, runtime integrity, domain events, listeners, jobs, schedules, tasks, notifications, outbox, 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.