@beignet/core
Version:
Core framework primitives for Beignet
487 lines • 17.6 kB
JavaScript
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