@beignet/core
Version:
Core framework primitives for Beignet
1,070 lines (981 loc) • 29.1 kB
text/typescript
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,
);
},
};
}