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; /** * Infer the parsed output type from a Standard Schema. */ export type InferOutput = StandardSchemaV1.InferOutput; /** * Infer the input type accepted by a Standard Schema. */ export type InferInput = StandardSchemaV1.InferInput; type SchemaInput = T extends StandardSchemaV1 ? InferInput : never; type SchemaOutput = T extends StandardSchemaV1 ? InferOutput : 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( schema: TSchema, value: unknown, useCaseName: string, phase: UseCaseValidationPhase, ): Promise> { 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; } 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["payload"] extends StandardSchemaV1 ? Output : never; /** * Minimal recorder shape accepted by use-case event helpers. */ export interface UseCaseEventRecorderTarget { /** * Record a domain event payload. */ record( event: E, payload: InferUseCaseEventPayload, options?: EventPublishOptions, ): Promise | void; } /** * Minimal event-bus shape accepted by use-case event helpers. */ export interface UseCaseEventBusTarget { /** * Publish a domain event payload. */ publish( event: E, payload: InferUseCaseEventPayload, options?: EventPublishOptions, ): Promise | void; } /** * Event helper scoped to the events declared by a use case. */ export interface UseCaseEventHelpers { /** * 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( recorder: UseCaseEventRecorderTarget, event: E, payload: InferUseCaseEventPayload, ): Promise; /** * Validate and publish a declared event directly through an event bus. */ publish( eventBus: UseCaseEventBusTarget, event: E, payload: InferUseCaseEventPayload, ): Promise; } async function parseEventPayload( event: E, payload: unknown, useCaseName: string, ): Promise> { 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; } throw new Error("Invalid Standard Schema result: missing value"); } function createUseCaseEventHelpers( useCaseName: string, declared: Emits, ): UseCaseEventHelpers { const declaredEventNames = declared.map((event) => event.name); const declaredEventByName = new Map(); 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; }) => Promise>; } /** * Event passed to the `onRun` hook for instrumentation. */ export interface UseCaseRunEvent { /** * 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 { /** * Optional app-owned observer called on use case start, end, and error. * * Observers run in addition to the built-in instrumentation. */ onRun?: (event: UseCaseRunEvent) => void | Promise; /** * 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( observer: CreateUseCaseOptions["onRun"], event: UseCaseRunEvent, ): 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["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[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, ) => void | Promise, private readonly validation: ValidationOptions = { input: true, output: true, }, private readonly instrumented: boolean = true, ) {} /** * Define the input schema for this use case */ input( schema: I, ): UseCaseBuilder { return new UseCaseBuilder( { ...this.config, input: schema, }, this.onRun, this.validation, this.instrumented, ); } /** * Define the output schema for this use case */ output( schema: O, ): UseCaseBuilder { return new UseCaseBuilder( { ...this.config, output: schema, }, this.onRun, this.validation, this.instrumented, ); } /** * Define the domain events that this use case may emit. */ emits( events: E, ): UseCaseBuilder { return new UseCaseBuilder( { ...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; events: UseCaseEventHelpers; }) => Promise> | SchemaOutput : never : never, ): InputSchema extends StandardSchemaV1 ? OutputSchema extends StandardSchemaV1 ? UseCaseDef : 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; } : 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); const rawResult = await fn({ ctx: args.ctx, input: parsedInput, events: eventHelpers, }); const result = validation.output ? await parseSchema( outputSchema, rawResult, useCaseName, "output", ) : (rawResult as SchemaOutput); 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[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 : never : never; } } /** * Root builder returned by createUseCase. */ export interface UseCaseBuilderRoot { /** * Create a command use case (write/side-effect path) */ command( name: Name, ): UseCaseBuilder; /** * Create a query use case (read-only path) */ query( name: Name, ): UseCaseBuilder; } /** * Infer the application context type from a finalized use case. */ export type UseCaseContext = TUseCase extends { run: (args: { ctx: infer Ctx; input: infer _Input; }) => Promise; } ? Ctx : never; /** * Infer the public input type accepted by a finalized use case. */ export type UseCaseInput = TUseCase extends { run: (args: { ctx: infer _Ctx; input: infer Input; }) => Promise; } ? Input : never; /** * Infer the public output type returned by a finalized use case. */ export type UseCaseOutput = TUseCase extends { run: (args: { ctx: infer _Ctx; input: infer _Input; }) => Promise; } ? Output : never; type MaybePromise = T | Promise; /** * Small test harness for running use cases with typed inputs. */ export interface UseCaseTester { /** * Create a fresh test context. */ ctx(): Promise; /** * Run a use case with a typed input and either a fresh or explicit context. */ run( useCase: { run(args: { ctx: Ctx; input: Input }): Promise; }, input: Input, options?: { ctx?: Ctx }, ): Promise; } /** * 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( createContext: Ctx | (() => MaybePromise), ): UseCaseTester { const ctx = async () => typeof createContext === "function" ? await (createContext as () => MaybePromise)() : createContext; return { ctx, async run( useCase: { run(args: { ctx: Ctx; input: Input }): Promise; }, input: Input, options?: { ctx?: Ctx }, ): Promise { 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(); * * 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( options?: CreateUseCaseOptions, ): UseCaseBuilderRoot { const onRun = options?.onRun; const validation = normalizeValidationOptions(options?.validate); const instrumented = options?.instrumentation !== false; return { command(name: Name) { return new UseCaseBuilder< Ctx, Name, "command", undefined, undefined, readonly [] >( { name, kind: "command", emits: EMPTY_EMITS, }, onRun, validation, instrumented, ); }, query(name: Name) { return new UseCaseBuilder< Ctx, Name, "query", undefined, undefined, readonly [] >( { name, kind: "query", emits: EMPTY_EMITS, }, onRun, validation, instrumented, ); }, }; }