UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

487 lines 17.6 kB
import { prepareEventPayloadForTransport, } from "../events/index.js"; import { markEventPayloadParsed } from "../events/payload-state.js"; import { resolveProviderInstrumentationPort, } from "../providers/instrumentation.js"; import { createChildTraceContext, resolveTracingPort, runWithTracing, } from "../tracing/index.js"; /** * Error thrown when a use case input or output fails schema validation. */ export class UseCaseValidationError extends Error { name = "UseCaseValidationError"; useCaseName; phase; issues; constructor(args) { 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 { name = "UseCaseEventDeclarationError"; useCaseName; eventName; declaredEventNames; constructor(args) { 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) { 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) { 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 { name = "UseCaseEventValidationError"; useCaseName; eventName; issues; constructor(args) { 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, value, useCaseName, phase) { 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; } throw new Error("Invalid Standard Schema result: missing value"); } async function parseEventPayload(event, payload, useCaseName) { 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; } throw new Error("Invalid Standard Schema result: missing value"); } function createUseCaseEventHelpers(useCaseName, declared) { 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) { const declaredEvent = declaredEventByName.get(event.name); if (declaredEvent) return declaredEvent; throw new UseCaseEventDeclarationError({ useCaseName, eventName: event.name, declaredEventNames, }); } function assertDeclared(event) { resolveDeclared(event); } return { declared, isDeclared(event) { return declaredEventByName.has(event.name); }, assertDeclared, async record(recorder, event, payload) { const declaredEvent = resolveDeclared(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); const parsed = await parseEventPayload(declaredEvent, payload, useCaseName); const prepared = await prepareEventPayloadForTransport(declaredEvent, parsed, markEventPayloadParsed(declaredEvent, parsed)); await eventBus.publish(declaredEvent, prepared.payload, prepared.publishOptions); }, }; } /** * 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 = Symbol.for("beignet.useCase.trustedRun"); const USE_CASE_OUTPUT_VALIDATED = Symbol.for("beignet.useCase.outputValidated"); function notifyUseCaseRunObserver(observer, event) { try { const result = observer?.(event); if (result) void result.catch(() => { }); } catch { // Observers are best-effort instrumentation and cannot change execution. } } function normalizeValidationOptions(validate) { 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 }; } function getInstrumentationRequestId(ctx) { if (!ctx || typeof ctx !== "object") return undefined; const requestId = ctx.requestId; return typeof requestId === "string" ? requestId : undefined; } function getInstrumentationTrace(ctx) { if (!ctx || typeof ctx !== "object") return undefined; const context = ctx; 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) { 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) { const ports = args.ctx && typeof args.ctx === "object" ? args.ctx.ports : undefined; const port = resolveProviderInstrumentationPort(ports); 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) => { try { port.record(event); } catch { // Instrumentation sinks must never affect use-case behavior. } }; const recordPhase = (phase, durationMs, error) => { 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), }; } /** * Fluent builder for creating use cases */ class UseCaseBuilder { config; onRun; validation; instrumented; constructor(config, onRun, validation = { input: true, output: true, }, instrumented = true) { this.config = config; this.onRun = onRun; this.validation = validation; this.instrumented = instrumented; } /** * Define the input schema for this use case */ input(schema) { return new UseCaseBuilder({ ...this.config, input: schema, }, this.onRun, this.validation, this.instrumented); } /** * Define the output schema for this use case */ output(schema) { 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) { 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) { 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; const outputSchema = this.config.output; const validation = this.validation; const eventHelpers = createUseCaseEventHelpers(useCaseName, this.config.emits); const execute = async (args, parseInput) => { const traceAttributes = { "beignet.use_case.name": useCaseName, "beignet.use_case.kind": useCaseKind, }; 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; const rawResult = await fn({ ctx: args.ctx, input: parsedInput, events: eventHelpers, }); const result = validation.output ? await parseSchema(outputSchema, rawResult, useCaseName, "output") : rawResult; 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 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) => 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) => execute(args, false), enumerable: false, }); Object.defineProperty(def, USE_CASE_OUTPUT_VALIDATED, { value: validation.output, enumerable: false, }); return def; } } /** * 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) { const ctx = async () => typeof createContext === "function" ? await createContext() : createContext; return { ctx, async run(useCase, input, options) { return useCase.run({ ctx: options?.ctx ?? (await ctx()), input, }); }, }; } /** Empty emits array used as default. */ const EMPTY_EMITS = []; /** * 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(options) { const onRun = options?.onRun; const validation = normalizeValidationOptions(options?.validate); const instrumented = options?.instrumentation !== false; return { command(name) { return new UseCaseBuilder({ name, kind: "command", emits: EMPTY_EMITS, }, onRun, validation, instrumented); }, query(name) { return new UseCaseBuilder({ name, kind: "query", emits: EMPTY_EMITS, }, onRun, validation, instrumented); }, }; } //# sourceMappingURL=index.js.map