@beignet/core
Version:
Core framework primitives for Beignet
316 lines (290 loc) • 7.43 kB
text/typescript
import type { StandardSchemaV1 } from "@standard-schema/spec";
import { runWithResolvedTracingContext } from "../tracing/execution.js";
import type { TracingPort } from "../tracing/index.js";
/**
* Any Standard Schema compatible validator.
*/
export type StandardSchema = StandardSchemaV1<unknown, unknown>;
/**
* Value or promise of that value.
*/
export type MaybePromise<T> = T | Promise<T>;
/**
* Infer the parsed output type from a Standard Schema.
*/
export type InferSchemaOutput<T extends StandardSchemaV1> =
StandardSchemaV1.InferOutput<T>;
/**
* Operational task definition created by `defineTask(...)`.
*/
export interface TaskDef<
Name extends string = string,
Input extends StandardSchema = StandardSchema,
Ctx = unknown,
Output = unknown,
> {
/**
* Discriminator for task definitions.
*/
readonly kind: "task";
/**
* Stable task name used by CLIs and operational runners.
*/
readonly name: Name;
/**
* Standard Schema input validator.
*/
readonly input: Input;
/**
* Optional human-readable description for docs and tooling.
*/
readonly description?: string;
/**
* Handle a parsed task input.
*/
handle(
args: TaskHandleArgs<TaskDef<Name, Input, Ctx, Output>, Ctx>,
): MaybePromise<Output>;
}
/**
* Infer the parsed input type for an operational task.
*/
export type InferTaskInput<T extends TaskDef> =
T["input"] extends StandardSchemaV1<unknown, infer Output> ? Output : never;
/**
* Infer the result type for an operational task.
*/
export type InferTaskOutput<T extends TaskDef> =
T extends TaskDef<string, StandardSchema, unknown, infer Output>
? Awaited<Output>
: never;
/**
* Arguments passed to a task handler.
*/
export interface TaskHandleArgs<T extends TaskDef, Ctx> {
/**
* Task definition being handled.
*/
task: T;
/**
* Parsed task input.
*/
input: InferTaskInput<T>;
/** Handler context. */
ctx: Ctx;
}
/**
* Options for `defineTask(...)`.
*/
export interface DefineTaskOptions<
Name extends string,
Input extends StandardSchema,
Ctx,
Output,
> {
/**
* Standard Schema input validator.
*/
input: Input;
/**
* Optional human-readable description for docs and tooling.
*/
description?: string;
/**
* Handle a parsed task input.
*/
handle(
args: TaskHandleArgs<TaskDef<Name, Input, Ctx, Output>, Ctx>,
): MaybePromise<Output>;
}
/**
* Options for one task run.
*/
export interface RunTaskOptions<Ctx> {
/**
* Raw task input. It is parsed with the task's Standard Schema before the
* handler runs.
*/
input: unknown;
/** Handler context or factory resolved inside the task span. */
ctx: Ctx | (() => MaybePromise<Ctx>);
/** Runtime tracing port used before a lazy context factory runs. */
tracing?: TracingPort;
}
/**
* Arguments `beignet task run` passes to the app's `createTaskContext` and
* `stopTaskContext` exports in `server/tasks.ts`.
*/
export interface TaskRunContextArgs<T extends TaskDef = TaskDef> {
/**
* Task definition being run.
*/
task: T;
/**
* Stable task name being run.
*/
taskName: string;
/**
* Schema-parsed task input.
*/
input: unknown;
/**
* Tenant id or slug from `--tenant`, resolved by the app.
*/
tenant?: string;
}
/**
* Context-bound operational task helper factory.
*/
export interface Tasks<Ctx> {
/**
* Define a task with the bound context type.
*/
defineTask<
Name extends string,
Input extends StandardSchema,
Output = unknown,
>(
name: Name,
options: DefineTaskOptions<Name, Input, Ctx, Output>,
): TaskDef<Name, Input, Ctx, Output>;
}
/**
* Error thrown when task input validation fails.
*/
export class TaskValidationError extends Error {
/**
* Raw Standard Schema validation issues.
*/
readonly issues: readonly StandardSchemaV1.Issue[];
constructor(args: {
name: string;
issues: readonly StandardSchemaV1.Issue[];
}) {
super(
`Task "${args.name}" input validation failed: ${formatIssues(args.issues)}`,
);
this.name = "TaskValidationError";
this.issues = args.issues;
}
}
function formatPath(path: StandardSchemaV1.Issue["path"]): string {
if (!path || path.length === 0) return "";
return path
.map((segment) => {
if (typeof segment === "number") return `[${segment}]`;
const key = String(segment);
return /^[A-Za-z_$][\w$]*$/.test(key)
? `.${key}`
: `[${JSON.stringify(key)}]`;
})
.join("")
.replace(/^\./, "");
}
function formatIssues(issues: readonly StandardSchemaV1.Issue[]): string {
return issues
.map((issue) => {
const path = formatPath(issue.path);
return path ? `${path}: ${issue.message}` : issue.message;
})
.join("; ");
}
async function parseInput<Schema extends StandardSchemaV1>(
schema: Schema,
input: unknown,
options: { name: string },
): Promise<InferSchemaOutput<Schema>> {
const result = await schema["~standard"].validate(input);
if (result.issues?.length) {
throw new TaskValidationError({
name: options.name,
issues: result.issues,
});
}
return (result as { value: InferSchemaOutput<Schema> }).value;
}
function defineTaskImpl<
Name extends string,
Input extends StandardSchema,
Ctx = unknown,
Output = unknown,
>(
name: Name,
options: DefineTaskOptions<Name, Input, Ctx, Output>,
): TaskDef<Name, Input, Ctx, Output> {
return {
kind: "task",
name,
input: options.input,
description: options.description,
handle: options.handle as TaskDef<Name, Input, Ctx, Output>["handle"],
};
}
/**
* Validate and parse a task input with the task's Standard Schema.
*/
export async function parseTaskInput<T extends TaskDef>(
task: T,
input: unknown,
): Promise<InferTaskInput<T>> {
return (await parseInput(task.input, input, {
name: task.name,
})) as InferTaskInput<T>;
}
/**
* Parse input and run an operational task.
*/
export async function runTask<
T extends TaskDef<string, StandardSchema, Ctx>,
Ctx,
>(task: T, options: RunTaskOptions<Ctx>): Promise<InferTaskOutput<T>> {
const traceAttributes = {
"beignet.task.name": task.name,
} as const;
return await runWithResolvedTracingContext({
tracing: options.tracing,
ctx: options.ctx,
operation: {
name: `beignet.task ${task.name}`,
type: "task",
kind: "internal",
attributes: traceAttributes,
metricAttributes: traceAttributes,
},
run: async (ctx) => {
const parsed = await parseTaskInput(task, options.input);
return (await task.handle({
task,
input: parsed,
ctx,
})) as InferTaskOutput<T>;
},
});
}
/**
* Define a task registry while preserving tuple inference.
*/
export function defineTasks<const Defs extends readonly TaskDef[]>(
tasks: Defs,
): Defs {
return tasks;
}
/**
* Create task helper methods bound to an application context type.
*
* Call it once in `lib/tasks.ts`:
*
* ```ts
* export const { defineTask } = createTasks<AppContext>();
* ```
*/
export function createTasks<Ctx>(): Tasks<Ctx> {
return {
defineTask<Name extends string, Input extends StandardSchema, Output>(
name: Name,
options: DefineTaskOptions<Name, Input, Ctx, Output>,
) {
return defineTaskImpl<Name, Input, Ctx, Output>(name, options);
},
};
}