UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

1,070 lines (981 loc) 29.1 kB
import type { StandardSchemaV1 } from "@standard-schema/spec"; import { type EventPublishOptions, prepareEventPayloadForTransport, } from "../events/index.js"; import { markEventPayloadParsed } from "../events/payload-state.js"; import { type ProviderInstrumentationEventInput, resolveProviderInstrumentationPort, } from "../providers/instrumentation.js"; import { createChildTraceContext, resolveTracingPort, runWithTracing, type TraceContextInput, } from "../tracing/index.js"; /** * Any Standard Schema compatible validator. */ export type StandardSchema = StandardSchemaV1<unknown, unknown>; /** * Infer the parsed output type from a Standard Schema. */ export type InferOutput<T extends StandardSchemaV1> = StandardSchemaV1.InferOutput<T>; /** * Infer the input type accepted by a Standard Schema. */ export type InferInput<T extends StandardSchemaV1> = StandardSchemaV1.InferInput<T>; type SchemaInput<T> = T extends StandardSchemaV1 ? InferInput<T> : never; type SchemaOutput<T> = T extends StandardSchemaV1 ? InferOutput<T> : never; /** * Boundary phase that failed use-case schema validation. */ export type UseCaseValidationPhase = "input" | "output"; /** * Error thrown when a use case input or output fails schema validation. */ export class UseCaseValidationError extends Error { readonly name = "UseCaseValidationError"; readonly useCaseName: string; readonly phase: UseCaseValidationPhase; readonly issues: readonly StandardSchemaV1.Issue[]; constructor(args: { useCaseName: string; phase: UseCaseValidationPhase; issues: readonly StandardSchemaV1.Issue[]; }) { super( `Use case "${args.useCaseName}" ${args.phase} validation failed: ${formatIssues(args.issues)}`, ); this.useCaseName = args.useCaseName; this.phase = args.phase; this.issues = args.issues; } } /** * Error thrown when a use case tries to emit an event it did not declare with * `.emits(...)`. */ export class UseCaseEventDeclarationError extends Error { readonly name = "UseCaseEventDeclarationError"; readonly useCaseName: string; readonly eventName: string; readonly declaredEventNames: readonly string[]; constructor(args: { useCaseName: string; eventName: string; declaredEventNames: readonly string[]; }) { const declared = args.declaredEventNames.length > 0 ? args.declaredEventNames.map((name) => `"${name}"`).join(", ") : "none"; super( `Use case "${args.useCaseName}" cannot emit undeclared event "${args.eventName}". Declare it with .emits([...]). Declared events: ${declared}.`, ); this.useCaseName = args.useCaseName; this.eventName = args.eventName; this.declaredEventNames = args.declaredEventNames; } } function formatPath(path: StandardSchemaV1.Issue["path"]): string { if (!path?.length) return ""; return path .map((segment) => typeof segment === "object" && segment !== null && "key" in segment ? String(segment.key) : String(segment), ) .join("."); } function formatIssues(issues: readonly StandardSchemaV1.Issue[]): string { return issues .map((issue) => { const path = formatPath(issue.path); return path ? `${path}: ${issue.message}` : issue.message; }) .join("; "); } /** * Error thrown when a use-case event helper fails to validate an event payload * before recording or publishing it. */ export class UseCaseEventValidationError extends Error { readonly name = "UseCaseEventValidationError"; readonly useCaseName: string; readonly eventName: string; readonly issues: readonly StandardSchemaV1.Issue[]; constructor(args: { useCaseName: string; eventName: string; issues: readonly StandardSchemaV1.Issue[]; }) { super( `Use case "${args.useCaseName}" event "${args.eventName}" payload validation failed: ${formatIssues(args.issues)}`, ); this.useCaseName = args.useCaseName; this.eventName = args.eventName; this.issues = args.issues; } } async function parseSchema<TSchema extends StandardSchemaV1>( schema: TSchema, value: unknown, useCaseName: string, phase: UseCaseValidationPhase, ): Promise<InferOutput<TSchema>> { const result = await schema["~standard"].validate(value); if (result.issues?.length) { throw new UseCaseValidationError({ useCaseName, phase, issues: result.issues, }); } if ("value" in result) { return result.value as InferOutput<TSchema>; } throw new Error("Invalid Standard Schema result: missing value"); } /** * Minimal domain event definition accepted by use-case event helpers. * * This structurally matches events from `@beignet/core/events` and compatible * app-owned definitions. */ export interface DomainEventLike { /** * Stable event name. */ name: string; /** * Standard Schema payload validator. */ payload: StandardSchema; } /** * Infer the output payload type from a use-case event definition. */ export type InferUseCaseEventPayload<E extends DomainEventLike> = E["payload"] extends StandardSchemaV1<unknown, infer Output> ? Output : never; /** * Minimal recorder shape accepted by use-case event helpers. */ export interface UseCaseEventRecorderTarget { /** * Record a domain event payload. */ record<E extends DomainEventLike>( event: E, payload: InferUseCaseEventPayload<E>, options?: EventPublishOptions, ): Promise<void> | void; } /** * Minimal event-bus shape accepted by use-case event helpers. */ export interface UseCaseEventBusTarget { /** * Publish a domain event payload. */ publish<E extends DomainEventLike>( event: E, payload: InferUseCaseEventPayload<E>, options?: EventPublishOptions, ): Promise<void> | void; } /** * Event helper scoped to the events declared by a use case. */ export interface UseCaseEventHelpers<Emits extends readonly DomainEventLike[]> { /** * The exact event definitions declared with `.emits(...)`. */ readonly declared: Emits; /** * Return whether an event is declared by this use case. */ isDeclared(event: DomainEventLike): boolean; /** * Throw if an event is not declared by this use case. */ assertDeclared(event: DomainEventLike): void; /** * Validate and record a declared event into a transaction-scoped recorder. */ record<E extends Emits[number]>( recorder: UseCaseEventRecorderTarget, event: E, payload: InferUseCaseEventPayload<E>, ): Promise<void>; /** * Validate and publish a declared event directly through an event bus. */ publish<E extends Emits[number]>( eventBus: UseCaseEventBusTarget, event: E, payload: InferUseCaseEventPayload<E>, ): Promise<void>; } async function parseEventPayload<E extends DomainEventLike>( event: E, payload: unknown, useCaseName: string, ): Promise<InferUseCaseEventPayload<E>> { const result = await event.payload["~standard"].validate(payload); if (result.issues?.length) { throw new UseCaseEventValidationError({ useCaseName, eventName: event.name, issues: result.issues, }); } if ("value" in result) { return result.value as InferUseCaseEventPayload<E>; } throw new Error("Invalid Standard Schema result: missing value"); } function createUseCaseEventHelpers<Emits extends readonly DomainEventLike[]>( useCaseName: string, declared: Emits, ): UseCaseEventHelpers<Emits> { const declaredEventNames = declared.map((event) => event.name); const declaredEventByName = new Map<string, DomainEventLike>(); for (const event of declared) { if (declaredEventByName.has(event.name)) { throw new Error( `Use case "${useCaseName}" declares duplicate event "${event.name}". Event names must be unique within .emits([...]).`, ); } declaredEventByName.set(event.name, event); } function resolveDeclared(event: DomainEventLike): DomainEventLike { const declaredEvent = declaredEventByName.get(event.name); if (declaredEvent) return declaredEvent; throw new UseCaseEventDeclarationError({ useCaseName, eventName: event.name, declaredEventNames, }); } function assertDeclared(event: DomainEventLike): void { resolveDeclared(event); } return { declared, isDeclared(event) { return declaredEventByName.has(event.name); }, assertDeclared, async record(recorder, event, payload) { const declaredEvent = resolveDeclared(event) as typeof event; const parsed = await parseEventPayload( declaredEvent, payload, useCaseName, ); const prepared = await prepareEventPayloadForTransport( declaredEvent, parsed, markEventPayloadParsed(declaredEvent, parsed), ); await recorder.record( declaredEvent, prepared.payload, prepared.publishOptions, ); }, async publish(eventBus, event, payload) { const declaredEvent = resolveDeclared(event) as typeof event; const parsed = await parseEventPayload( declaredEvent, payload, useCaseName, ); const prepared = await prepareEventPayloadForTransport( declaredEvent, parsed, markEventPayloadParsed(declaredEvent, parsed), ); await eventBus.publish( declaredEvent, prepared.payload, prepared.publishOptions, ); }, }; } /** * Use case kind - distinguishes commands (write/side-effect) from queries (read-only) */ export type UseCaseKind = "command" | "query"; /** * Symbol key for the trusted run path attached to finalized use cases. * * The server route binder calls this method instead of `run` when the route's * input was already validated by the exact same schema object at the HTTP * boundary. It behaves like `run` but skips the input parse; output * validation, instrumentation, events, and `onRun` are unchanged. * * The key uses `Symbol.for(...)` so the binder and the application builder * agree on the key even across separately bundled copies of the package. */ export const USE_CASE_TRUSTED_RUN: unique symbol = Symbol.for( "beignet.useCase.trustedRun", ); const USE_CASE_OUTPUT_VALIDATED = Symbol.for("beignet.useCase.outputValidated"); /** * Finalized use case definition. * * Use cases validate their input before `run(...)` executes and validate their * output before returning, unless validation is disabled on the builder. */ export interface UseCaseDef< Ctx, Name extends string, Kind extends UseCaseKind, InputSchema extends StandardSchemaV1, OutputSchema extends StandardSchemaV1, Emits extends readonly DomainEventLike[] = readonly [], > { /** * Stable use-case name, usually namespaced by feature. */ name: Name; /** * Whether this use case is a command or query. */ kind: Kind; /** Input schema, suitable for reuse in HTTP contracts and forms. */ inputSchema: InputSchema; /** Output schema, suitable for reuse in HTTP contracts and clients. */ outputSchema: OutputSchema; /** * Domain events this use case is allowed to record or publish through the * scoped `events` helper. */ emits: Emits; /** * Execute the use case with application context and typed input. */ run: (args: { ctx: Ctx; input: InferInput<InputSchema>; }) => Promise<InferOutput<OutputSchema>>; } /** * Event passed to the `onRun` hook for instrumentation. */ export interface UseCaseRunEvent<Ctx> { /** * Use-case name. */ name: string; /** * Use-case kind. */ kind: UseCaseKind; /** * Execution phase being observed. */ phase: "start" | "end" | "error"; /** * Elapsed time for end/error events. */ durationMs?: number; /** * Error captured for error events. */ error?: unknown; /** * Application context used for the run. */ ctx: Ctx; } /** * Options for `createUseCase(...)`. */ export interface CreateUseCaseOptions<Ctx> { /** * Optional app-owned observer called on use case start, end, and error. * * Observers run in addition to the built-in instrumentation. */ onRun?: (event: UseCaseRunEvent<Ctx>) => void | Promise<void>; /** * Built-in use-case instrumentation. * * By default every run records `usecase` lifecycle events (plus `error` * events for failed runs) into the provider instrumentation port resolved * from `ctx.ports` (`ports.instrumentation`, then `ports.devtools`). When no * port is installed, runs stay silent. Pass `false` to opt out. * * @default true */ instrumentation?: boolean; /** * Enable or disable schema validation for use case boundaries. * * Defaults to validating both input and output. Pass `false` to opt out, or * configure phases independently with `{ input: boolean, output: boolean }`. */ validate?: boolean | { input?: boolean; output?: boolean }; } function notifyUseCaseRunObserver<Ctx>( observer: CreateUseCaseOptions<Ctx>["onRun"], event: UseCaseRunEvent<Ctx>, ): void { try { const result = observer?.(event); if (result) void result.catch(() => {}); } catch { // Observers are best-effort instrumentation and cannot change execution. } } type ValidationOptions = { input: boolean; output: boolean; }; function normalizeValidationOptions( validate: CreateUseCaseOptions<unknown>["validate"], ): ValidationOptions { if (validate === false) { return { input: false, output: false }; } if (typeof validate === "object" && validate !== null) { return { input: validate.input ?? true, output: validate.output ?? true, }; } return { input: true, output: true }; } type UseCaseRunInstrumentation = { end(durationMs: number): void; error(durationMs: number, error: unknown): void; }; function getInstrumentationRequestId(ctx: unknown): string | undefined { if (!ctx || typeof ctx !== "object") return undefined; const requestId = (ctx as { requestId?: unknown }).requestId; return typeof requestId === "string" ? requestId : undefined; } function getInstrumentationTrace(ctx: unknown): TraceContextInput | undefined { if (!ctx || typeof ctx !== "object") return undefined; const context = ctx as TraceContextInput; if (!context.traceId && !context.spanId && !context.traceparent) { return undefined; } return { traceId: context.traceId, spanId: context.spanId, parentSpanId: context.parentSpanId, traceparent: context.traceparent, tracestate: context.tracestate, }; } function getRunErrorMessage(error: unknown): string { if (error instanceof Error) return error.message; if (typeof error === "string") return error; return "Unknown error"; } /** * Start built-in instrumentation for one use-case run. * * The instrumentation port is resolved from `ctx.ports` per run so use cases * stay decoupled from any specific sink. Runs without a resolved port stay * silent. */ function startUseCaseRunInstrumentation(args: { ctx: unknown; name: string; kind: UseCaseKind; }): UseCaseRunInstrumentation | undefined { const ports = args.ctx && typeof args.ctx === "object" ? (args.ctx as { ports?: unknown }).ports : undefined; const port = resolveProviderInstrumentationPort( ports as Parameters<typeof resolveProviderInstrumentationPort>[0], ); if (!port) return undefined; const useCasesEnabled = port.isWatcherEnabled?.("useCases") ?? true; const errorsEnabled = port.isWatcherEnabled?.("errors") ?? true; if (!useCasesEnabled && !errorsEnabled) return undefined; const requestId = getInstrumentationRequestId(args.ctx); const trace = resolveTracingPort(ports)?.current() ?? createChildTraceContext(getInstrumentationTrace(args.ctx) ?? {}); const record = (event: ProviderInstrumentationEventInput) => { try { port.record(event); } catch { // Instrumentation sinks must never affect use-case behavior. } }; const recordPhase = ( phase: "start" | "end" | "error", durationMs?: number, error?: unknown, ) => { if (useCasesEnabled) { record({ type: "usecase", requestId, traceId: trace.traceId, spanId: trace.spanId, parentSpanId: trace.parentSpanId, traceparent: trace.traceparent, tracestate: trace.tracestate, name: args.name, kind: args.kind, phase, durationMs, error: phase === "error" ? getRunErrorMessage(error) : undefined, }); } if (phase === "error" && errorsEnabled) { record({ type: "error", requestId, traceId: trace.traceId, spanId: trace.spanId, parentSpanId: trace.parentSpanId, traceparent: trace.traceparent, tracestate: trace.tracestate, message: getRunErrorMessage(error), stack: error instanceof Error ? error.stack : undefined, useCaseName: args.name, owner: "route", }); } }; recordPhase("start"); return { end: (durationMs) => recordPhase("end", durationMs), error: (durationMs, error) => recordPhase("error", durationMs, error), }; } /** * Internal configuration for the use case builder */ interface UseCaseBuilderConfig< Name extends string, Kind extends UseCaseKind, InputSchema extends StandardSchemaV1 | undefined, OutputSchema extends StandardSchemaV1 | undefined, Emits extends readonly DomainEventLike[], > { name: Name; kind: Kind; input?: InputSchema; output?: OutputSchema; emits: Emits; } /** * Fluent builder for creating use cases */ class UseCaseBuilder< Ctx, Name extends string, Kind extends UseCaseKind, InputSchema extends StandardSchemaV1 | undefined, OutputSchema extends StandardSchemaV1 | undefined, Emits extends readonly DomainEventLike[] = readonly [], > { constructor( private readonly config: UseCaseBuilderConfig< Name, Kind, InputSchema, OutputSchema, Emits >, private readonly onRun?: ( event: UseCaseRunEvent<Ctx>, ) => void | Promise<void>, private readonly validation: ValidationOptions = { input: true, output: true, }, private readonly instrumented: boolean = true, ) {} /** * Define the input schema for this use case */ input<I extends StandardSchemaV1>( schema: I, ): UseCaseBuilder<Ctx, Name, Kind, I, OutputSchema, Emits> { return new UseCaseBuilder<Ctx, Name, Kind, I, OutputSchema, Emits>( { ...this.config, input: schema, }, this.onRun, this.validation, this.instrumented, ); } /** * Define the output schema for this use case */ output<O extends StandardSchemaV1>( schema: O, ): UseCaseBuilder<Ctx, Name, Kind, InputSchema, O, Emits> { return new UseCaseBuilder<Ctx, Name, Kind, InputSchema, O, Emits>( { ...this.config, output: schema, }, this.onRun, this.validation, this.instrumented, ); } /** * Define the domain events that this use case may emit. */ emits<E extends readonly DomainEventLike[]>( events: E, ): UseCaseBuilder<Ctx, Name, Kind, InputSchema, OutputSchema, E> { return new UseCaseBuilder<Ctx, Name, Kind, InputSchema, OutputSchema, E>( { ...this.config, emits: events, }, this.onRun, this.validation, this.instrumented, ); } /** * Define the run function and finalize the use case definition */ run( fn: InputSchema extends StandardSchemaV1 ? OutputSchema extends StandardSchemaV1 ? (args: { ctx: Ctx; input: SchemaOutput<InputSchema>; events: UseCaseEventHelpers<Emits>; }) => Promise<SchemaOutput<OutputSchema>> | SchemaOutput<OutputSchema> : never : never, ): InputSchema extends StandardSchemaV1 ? OutputSchema extends StandardSchemaV1 ? UseCaseDef<Ctx, Name, Kind, InputSchema, OutputSchema, Emits> : never : never { if (!this.config.input) { throw new Error(`Use case "${this.config.name}" is missing input schema`); } if (!this.config.output) { throw new Error( `Use case "${this.config.name}" is missing output schema`, ); } const useCaseName = this.config.name; const useCaseKind = this.config.kind; const onRun = this.onRun; const instrumented = this.instrumented; const inputSchema = this.config.input as Extract< InputSchema, StandardSchemaV1 >; const outputSchema = this.config.output as Extract< OutputSchema, StandardSchemaV1 >; const validation = this.validation; const eventHelpers = createUseCaseEventHelpers( useCaseName, this.config.emits, ); const execute = async ( args: InputSchema extends StandardSchemaV1 ? OutputSchema extends StandardSchemaV1 ? { ctx: Ctx; input: SchemaInput<InputSchema>; } : never : never, parseInput: boolean, ) => { const traceAttributes = { "beignet.use_case.name": useCaseName, "beignet.use_case.kind": useCaseKind, } as const; return await runWithTracing( args.ctx, { name: `beignet.use_case ${useCaseName}`, type: "useCase", kind: "internal", attributes: traceAttributes, metricAttributes: traceAttributes, }, async () => { const startedAt = Date.now(); const instrumentation = instrumented ? startUseCaseRunInstrumentation({ ctx: args.ctx, name: useCaseName, kind: useCaseKind, }) : undefined; notifyUseCaseRunObserver(onRun, { name: useCaseName, kind: useCaseKind, phase: "start", ctx: args.ctx, }); try { const parsedInput = parseInput && validation.input ? await parseSchema( inputSchema, args.input, useCaseName, "input", ) : (args.input as SchemaOutput<InputSchema>); const rawResult = await fn({ ctx: args.ctx, input: parsedInput, events: eventHelpers, }); const result = validation.output ? await parseSchema( outputSchema, rawResult, useCaseName, "output", ) : (rawResult as SchemaOutput<OutputSchema>); const durationMs = Date.now() - startedAt; instrumentation?.end(durationMs); notifyUseCaseRunObserver(onRun, { name: useCaseName, kind: useCaseKind, phase: "end", durationMs, ctx: args.ctx, }); return result; } catch (err) { const durationMs = Date.now() - startedAt; instrumentation?.error(durationMs, err); notifyUseCaseRunObserver(onRun, { name: useCaseName, kind: useCaseKind, phase: "error", durationMs, error: err, ctx: args.ctx, }); throw err; } }, ); }; type RunArgs = Parameters<typeof execute>[0]; // Type assertion required to satisfy the conditional return type. // The runtime checks above ensure input/output schemas are set. // The conditional types ensure type safety at compile time - run() returns // UseCaseDef only when both InputSchema and OutputSchema are StandardSchemaV1. const def = { name: this.config.name, kind: this.config.kind, inputSchema: this.config.input, outputSchema: this.config.output, emits: this.config.emits, run: (args: RunArgs) => execute(args, true), }; // The trusted run path skips only the input parse. It is non-enumerable so // serialization and object spreads keep treating use cases as plain data. Object.defineProperty(def, USE_CASE_TRUSTED_RUN, { value: (args: RunArgs) => execute(args, false), enumerable: false, }); Object.defineProperty(def, USE_CASE_OUTPUT_VALIDATED, { value: validation.output, enumerable: false, }); return def as unknown as InputSchema extends StandardSchemaV1 ? OutputSchema extends StandardSchemaV1 ? UseCaseDef<Ctx, Name, Kind, InputSchema, OutputSchema, Emits> : never : never; } } /** * Root builder returned by createUseCase. */ export interface UseCaseBuilderRoot<Ctx> { /** * Create a command use case (write/side-effect path) */ command<Name extends string>( name: Name, ): UseCaseBuilder<Ctx, Name, "command", undefined, undefined, readonly []>; /** * Create a query use case (read-only path) */ query<Name extends string>( name: Name, ): UseCaseBuilder<Ctx, Name, "query", undefined, undefined, readonly []>; } /** * Infer the application context type from a finalized use case. */ export type UseCaseContext<TUseCase> = TUseCase extends { run: (args: { ctx: infer Ctx; input: infer _Input; }) => Promise<infer _Output>; } ? Ctx : never; /** * Infer the public input type accepted by a finalized use case. */ export type UseCaseInput<TUseCase> = TUseCase extends { run: (args: { ctx: infer _Ctx; input: infer Input; }) => Promise<infer _Output>; } ? Input : never; /** * Infer the public output type returned by a finalized use case. */ export type UseCaseOutput<TUseCase> = TUseCase extends { run: (args: { ctx: infer _Ctx; input: infer _Input; }) => Promise<infer Output>; } ? Output : never; type MaybePromise<T> = T | Promise<T>; /** * Small test harness for running use cases with typed inputs. */ export interface UseCaseTester<Ctx> { /** * Create a fresh test context. */ ctx(): Promise<Ctx>; /** * Run a use case with a typed input and either a fresh or explicit context. */ run<Input, Output>( useCase: { run(args: { ctx: Ctx; input: Input }): Promise<Output>; }, input: Input, options?: { ctx?: Ctx }, ): Promise<Output>; } /** * Create a small test harness for use cases. * * Pass a context factory when tests mutate ports or state. Pass a fixed context * for simple, immutable tests. */ export function createUseCaseTester<Ctx>( createContext: Ctx | (() => MaybePromise<Ctx>), ): UseCaseTester<Ctx> { const ctx = async () => typeof createContext === "function" ? await (createContext as () => MaybePromise<Ctx>)() : createContext; return { ctx, async run<Input, Output>( useCase: { run(args: { ctx: Ctx; input: Input }): Promise<Output>; }, input: Input, options?: { ctx?: Ctx }, ): Promise<Output> { return useCase.run({ ctx: options?.ctx ?? (await ctx()), input, }); }, }; } /** Empty emits array used as default. */ const EMPTY_EMITS = [] as const; /** * Create a use case builder with a specific context type. * * Create this once in app code, usually in `lib/use-case.ts`, then import that * configured builder from feature use-case modules. * * @example * ```ts * export const useCase = createUseCase<AppContext>(); * * export const createTodo = useCase * .command("todos.create") * .input(CreateTodoInput) * .output(CreateTodoOutput) * .run(async ({ ctx, input }) => ctx.ports.todos.create(input)); * ``` * * @param options - Optional instrumentation and validation configuration. * @returns A root builder for command and query use cases. */ export function createUseCase<Ctx>( options?: CreateUseCaseOptions<Ctx>, ): UseCaseBuilderRoot<Ctx> { const onRun = options?.onRun; const validation = normalizeValidationOptions(options?.validate); const instrumented = options?.instrumentation !== false; return { command<Name extends string>(name: Name) { return new UseCaseBuilder< Ctx, Name, "command", undefined, undefined, readonly [] >( { name, kind: "command", emits: EMPTY_EMITS, }, onRun, validation, instrumented, ); }, query<Name extends string>(name: Name) { return new UseCaseBuilder< Ctx, Name, "query", undefined, undefined, readonly [] >( { name, kind: "query", emits: EMPTY_EMITS, }, onRun, validation, instrumented, ); }, }; }