@beignet/core
Version:
Core framework primitives for Beignet
868 lines (808 loc) • 21.6 kB
text/typescript
import type { StandardSchemaV1 } from "@standard-schema/spec";
import {
createProviderInstrumentation,
type ProviderInstrumentation,
type ProviderInstrumentationTarget,
} from "../providers/index.js";
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>;
/**
* Date input accepted by schedule runners.
*/
export type ScheduleDateInput = Date | string | number;
/**
* Metadata for one schedule run.
*/
export interface ScheduleRunContext {
/**
* Optional provider run ID.
*/
readonly id?: string;
/**
* One-based provider attempt number for this run, when available.
*/
readonly attempt?: number;
/**
* Time the provider planned the run.
*/
readonly scheduledAt?: Date;
/**
* Time the runner triggered this execution.
*/
readonly triggeredAt: Date;
/**
* Optional provider or app source label.
*/
readonly source?: string;
}
/**
* Minimal schedule definition shape accepted by schedule helpers.
*/
export interface SchedulePayloadDef<
Name extends string = string,
Payload extends StandardSchema = StandardSchema,
> {
/**
* Stable schedule name.
*/
readonly name: Name;
/**
* Standard Schema payload validator.
*/
readonly payload: Payload;
}
/**
* Schedule definition created by `defineSchedule(...)`.
*/
export interface ScheduleDef<
Name extends string = string,
Payload extends StandardSchema = StandardSchema,
Ctx = unknown,
> extends SchedulePayloadDef<Name, Payload> {
/**
* Discriminator for schedule definitions.
*/
readonly kind: "schedule";
/**
* Cron expression consumed by schedule providers.
*/
readonly cron: string;
/**
* Optional IANA timezone consumed by schedule providers.
*/
readonly timezone?: string;
/**
* Optional human-readable description for docs and tooling.
*/
readonly description?: string;
/**
* Build a payload when the provider does not supply one.
*/
createPayload?(
args: ScheduleCreatePayloadArgs<ScheduleDef<Name, Payload, Ctx>>,
): MaybePromise<InferSchemaOutput<Payload>>;
/**
* Handle a parsed schedule payload.
*/
handle(
args: ScheduleHandleArgs<ScheduleDef<Name, Payload, Ctx>, Ctx>,
): MaybePromise<void>;
}
/**
* Infer the parsed payload type for a schedule definition.
*/
export type InferSchedulePayload<S extends SchedulePayloadDef> =
S["payload"] extends StandardSchemaV1<unknown, infer Output> ? Output : never;
/**
* Arguments passed to a schedule `createPayload` callback.
*/
export interface ScheduleCreatePayloadArgs<S extends SchedulePayloadDef> {
/**
* Schedule definition being run.
*/
schedule: S;
/**
* Run metadata.
*/
run: ScheduleRunContext;
}
/**
* Arguments passed to a schedule handler.
*/
export interface ScheduleHandleArgs<S extends ScheduleDef, Ctx> {
/**
* Schedule definition being handled.
*/
schedule: S;
/**
* Parsed schedule payload.
*/
payload: InferSchedulePayload<S>;
/** Handler context. */
ctx: Ctx;
/**
* Run metadata.
*/
run: ScheduleRunContext;
}
/**
* Options for `defineSchedule(...)`.
*/
export interface DefineScheduleOptions<
Name extends string,
Payload extends StandardSchema,
Ctx,
> {
/**
* Cron expression consumed by schedule providers.
*/
cron: string;
/**
* Optional IANA timezone consumed by schedule providers.
*/
timezone?: string;
/**
* Standard Schema payload validator.
*/
payload: Payload;
/**
* Optional human-readable description for docs and tooling.
*/
description?: string;
/**
* Build a payload when the provider does not supply one.
*/
createPayload?(
args: ScheduleCreatePayloadArgs<ScheduleDef<Name, Payload, Ctx>>,
): MaybePromise<InferSchemaOutput<Payload>>;
/**
* Handle a parsed schedule payload.
*/
handle(
args: ScheduleHandleArgs<ScheduleDef<Name, Payload, Ctx>, Ctx>,
): MaybePromise<void>;
}
/**
* Options for one manual schedule run.
*/
export interface ScheduleRunOptions<Payload = unknown> {
/**
* Payload supplied by the provider or manual runner.
*/
payload?: Payload;
/**
* Optional provider run ID.
*/
id?: string;
/**
* One-based provider attempt number for this run, when available.
*/
attempt?: number;
/**
* Time the provider planned the run.
*/
scheduledAt?: ScheduleDateInput;
/**
* Time the runner triggered the execution.
*/
triggeredAt?: ScheduleDateInput;
/**
* Optional provider or app source label.
*/
source?: string;
}
/**
* Arguments for `runSchedule(...)`.
*/
export type ScheduleRunArgs<
Ctx,
Payload = unknown,
> = ScheduleRunOptions<Payload> & {
/** Handler context or factory resolved inside the schedule span. */
ctx: Ctx | (() => MaybePromise<Ctx>);
/** Runtime tracing port used before a lazy context factory runs. */
tracing?: TracingPort;
};
/**
* Arguments passed to schedule lifecycle hooks.
*/
export interface ScheduleLifecycleArgs<S extends ScheduleDef = ScheduleDef> {
/**
* Schedule definition being run.
*/
schedule: S;
/**
* Parsed schedule payload.
*/
payload: InferSchedulePayload<S>;
/**
* Run metadata.
*/
run: ScheduleRunContext;
}
/**
* Arguments passed to a schedule error hook.
*/
export interface ScheduleErrorArgs<S extends ScheduleDef = ScheduleDef> {
/**
* Schedule definition being run.
*/
schedule: S;
/**
* Parsed payload when validation or payload creation completed.
*/
payload?: InferSchedulePayload<S>;
/**
* Run metadata.
*/
run: ScheduleRunContext;
/**
* Error thrown by payload creation, validation, or the handler.
*/
error: unknown;
}
/**
* Schedule lifecycle hook names.
*/
export type ScheduleHookName = "start" | "success" | "error";
/**
* Devtools event recorded by the inline schedule runner for each run.
*/
export interface ScheduleDevtoolsEvent {
/**
* Devtools event type.
*/
type: "schedule";
/**
* Watcher category used by devtools.
*/
watcher: "schedules";
/**
* Stable schedule name.
*/
scheduleName: string;
/**
* Schedule run lifecycle status.
*/
status: "started" | "completed" | "failed";
/**
* Cron expression for the schedule.
*/
cron: string;
/**
* IANA timezone for the schedule, when declared.
*/
timezone?: string;
/**
* Request correlation ID, when the trigger ran inside a request.
*/
requestId?: string;
/**
* Trace identifier for distributed tracing integrations.
*/
traceId?: string;
/**
* Structured run details such as `source`, `scheduledAt`, and `error`.
*/
details?: Record<string, unknown>;
}
/**
* Correlation fields attached to schedule instrumentation events.
*/
export interface ScheduleInstrumentationContext {
/**
* Request correlation ID for the triggering invocation.
*/
requestId?: string;
/**
* Trace identifier for the triggering invocation.
*/
traceId?: string;
}
/**
* Arguments passed when a schedule lifecycle hook itself fails.
*/
export interface ScheduleHookErrorArgs<S extends ScheduleDef = ScheduleDef> {
/**
* Schedule definition being run.
*/
schedule: S;
/**
* Parsed payload when available.
*/
payload?: InferSchedulePayload<S>;
/**
* Run metadata.
*/
run: ScheduleRunContext;
/**
* Lifecycle hook that failed.
*/
hook: ScheduleHookName;
/**
* Hook error.
*/
error: unknown;
/**
* Original schedule error when the failing hook is `onError`.
*/
scheduleError?: unknown;
}
/**
* Options for the inline schedule runner.
*/
export interface InlineScheduleRunnerOptions<Ctx> {
/**
* Static schedule context or factory evaluated for each run.
*/
ctx?: Ctx | (() => MaybePromise<Ctx>);
/** Runtime tracing port used before a lazy context factory runs. */
tracing?: TracingPort;
/**
* Clock used when run timestamps are not provided.
*/
now?: () => Date;
/**
* Provider instrumentation target that receives `schedule` events for each
* run. Pass `ctx.ports`, `ctx.ports.instrumentation`, or
* `ctx.ports.devtools` directly.
*
* The runner records `started`, `completed`, and `failed` events. Recording
* failures are isolated from schedule execution.
*/
instrumentation?: ProviderInstrumentationTarget;
/**
* Correlation fields attached to recorded schedule events.
*/
instrumentationContext?: ScheduleInstrumentationContext;
/**
* Called after payload validation and before the schedule handler.
*/
onStart?<S extends ScheduleDef<string, StandardSchema, Ctx>>(
args: ScheduleLifecycleArgs<S>,
): MaybePromise<void>;
/**
* Called after the schedule handler completes.
*/
onSuccess?<S extends ScheduleDef<string, StandardSchema, Ctx>>(
args: ScheduleLifecycleArgs<S>,
): MaybePromise<void>;
/**
* Called when payload creation, validation, or the handler fails.
*/
onError?<S extends ScheduleDef<string, StandardSchema, Ctx>>(
args: ScheduleErrorArgs<S>,
): MaybePromise<void>;
/**
* Called when a lifecycle hook throws.
*/
onHookError?<S extends ScheduleDef<string, StandardSchema, Ctx>>(
args: ScheduleHookErrorArgs<S>,
): MaybePromise<void>;
}
/**
* Port shape for running schedules.
*/
export interface ScheduleRunnerPort<Ctx = unknown> {
/**
* Run a schedule with optional provider metadata and payload.
*/
run<S extends ScheduleDef<string, StandardSchema, Ctx>>(
schedule: S,
options?: ScheduleRunOptions<InferSchedulePayload<S>>,
): Promise<void>;
}
/**
* Local/test schedule runner that executes handlers inline.
*/
export interface InlineScheduleRunner<Ctx = unknown>
extends ScheduleRunnerPort<Ctx> {}
/**
* Context-bound schedule helper factory.
*/
export interface Schedules<Ctx> {
/**
* Define a schedule with the bound context type.
*/
defineSchedule<Name extends string, Payload extends StandardSchema>(
name: Name,
options: DefineScheduleOptions<Name, Payload, Ctx>,
): ScheduleDef<Name, Payload, Ctx>;
}
/**
* Error thrown when schedule payload validation fails.
*/
export class ScheduleValidationError extends Error {
/**
* Raw Standard Schema validation issues.
*/
readonly issues: readonly StandardSchemaV1.Issue[];
constructor(args: {
name: string;
issues: readonly StandardSchemaV1.Issue[];
}) {
super(
`Schedule "${args.name}" payload validation failed: ${formatIssues(args.issues)}`,
);
this.name = "ScheduleValidationError";
this.issues = args.issues;
}
}
/**
* Error thrown when schedule run metadata cannot be normalized.
*/
export class ScheduleRunContextError extends Error {
constructor(message: string) {
super(message);
this.name = "ScheduleRunContextError";
}
}
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("; ");
}
async function parsePayload<Schema extends StandardSchemaV1>(
schema: Schema,
input: unknown,
args: { name: string },
): Promise<InferSchemaOutput<Schema>> {
const result = await schema["~standard"].validate(input);
if (result.issues?.length) {
throw new ScheduleValidationError({
name: args.name,
issues: result.issues,
});
}
if ("value" in result) {
return result.value as InferSchemaOutput<Schema>;
}
throw new Error("Invalid Standard Schema result: missing value");
}
function normalizeDate(
value: ScheduleDateInput | undefined,
field: string,
fallback: () => Date,
): Date {
if (value === undefined) return fallback();
const date =
value instanceof Date ? new Date(value.getTime()) : new Date(value);
if (Number.isNaN(date.getTime())) {
throw new ScheduleRunContextError(
`Schedule run ${field} must be a valid date.`,
);
}
return date;
}
function normalizeOptionalDate(
value: ScheduleDateInput | undefined,
field: string,
): Date | undefined {
if (value === undefined) return undefined;
return normalizeDate(value, field, () => new Date());
}
function normalizeOptionalAttempt(
value: number | undefined,
): number | undefined {
if (value === undefined) return undefined;
if (!Number.isInteger(value) || value < 1) {
throw new ScheduleRunContextError(
"Schedule run attempt must be a positive integer.",
);
}
return value;
}
function createRunContext(
options: ScheduleRunOptions<unknown>,
now: () => Date,
): ScheduleRunContext {
return {
id: options.id,
attempt: normalizeOptionalAttempt(options.attempt),
scheduledAt: normalizeOptionalDate(options.scheduledAt, "scheduledAt"),
triggeredAt: normalizeDate(options.triggeredAt, "triggeredAt", now),
source: options.source,
};
}
function scheduleTraceOperation(
schedule: ScheduleDef,
options: ScheduleRunOptions<unknown>,
) {
const attributes = {
"beignet.schedule.name": schedule.name,
...(options.attempt === undefined
? {}
: { "beignet.schedule.attempt": options.attempt }),
...(options.source === undefined
? {}
: { "beignet.schedule.source": options.source }),
} as const;
const metricAttributes = {
"beignet.schedule.name": schedule.name,
...(options.attempt === undefined
? {}
: { "beignet.schedule.attempt": options.attempt }),
} as const;
return {
name: `beignet.schedule ${schedule.name}`,
type: "schedule" as const,
kind: "consumer" as const,
attributes,
metricAttributes,
};
}
async function resolveSchedulePayload<S extends ScheduleDef>(
schedule: S,
options: ScheduleRunOptions<InferSchedulePayload<S>>,
run: ScheduleRunContext,
): Promise<InferSchedulePayload<S>> {
const hasExplicitPayload = Object.hasOwn(options, "payload");
const rawPayload = hasExplicitPayload
? options.payload
: await schedule.createPayload?.({ schedule, run });
return parseSchedulePayload(schedule, rawPayload);
}
async function reportHookError<
Ctx,
S extends ScheduleDef<string, StandardSchema, Ctx>,
>(
onHookError: InlineScheduleRunnerOptions<Ctx>["onHookError"] | undefined,
args: ScheduleHookErrorArgs<S>,
): Promise<void> {
try {
await onHookError?.(args);
} catch {
// Hook failures are isolated from schedule execution.
}
}
async function runLifecycleHook<
Ctx,
S extends ScheduleDef<string, StandardSchema, Ctx>,
>(
hook: Exclude<ScheduleHookName, "error">,
handler: ((args: ScheduleLifecycleArgs<S>) => MaybePromise<void>) | undefined,
onHookError: InlineScheduleRunnerOptions<Ctx>["onHookError"] | undefined,
args: ScheduleLifecycleArgs<S>,
): Promise<void> {
try {
await handler?.(args);
} catch (error) {
await reportHookError(onHookError, { ...args, hook, error });
}
}
async function runErrorHook<
Ctx,
S extends ScheduleDef<string, StandardSchema, Ctx>,
>(
handler: ((args: ScheduleErrorArgs<S>) => MaybePromise<void>) | undefined,
onHookError: InlineScheduleRunnerOptions<Ctx>["onHookError"] | undefined,
args: ScheduleErrorArgs<S>,
): Promise<void> {
try {
await handler?.(args);
} catch (error) {
await reportHookError(onHookError, {
schedule: args.schedule,
payload: args.payload,
run: args.run,
hook: "error",
error,
scheduleError: args.error,
});
}
}
async function recordScheduleEvent<
Ctx,
S extends ScheduleDef<string, StandardSchema, Ctx>,
>(
instrumentation: ProviderInstrumentation,
instrumentationContext: ScheduleInstrumentationContext | undefined,
schedule: S,
status: ScheduleDevtoolsEvent["status"],
run: ScheduleRunContext,
details?: Record<string, unknown>,
): Promise<void> {
instrumentation.record({
type: "schedule",
watcher: "schedules",
requestId: instrumentationContext?.requestId,
traceId: instrumentationContext?.traceId,
scheduleName: schedule.name,
status,
cron: schedule.cron,
timezone: schedule.timezone,
details: {
source: run.source,
scheduledAt: run.scheduledAt?.toISOString(),
...details,
},
});
}
function defineScheduleImpl<
Name extends string,
Payload extends StandardSchema,
Ctx = unknown,
>(
name: Name,
options: DefineScheduleOptions<Name, Payload, Ctx>,
): ScheduleDef<Name, Payload, Ctx> {
return {
kind: "schedule",
name,
cron: options.cron,
timezone: options.timezone,
payload: options.payload,
description: options.description,
createPayload: options.createPayload as
| ScheduleDef<Name, Payload, Ctx>["createPayload"]
| undefined,
handle: options.handle as ScheduleDef<Name, Payload, Ctx>["handle"],
};
}
/**
* Validate and parse a schedule payload with the schedule's Standard Schema.
*/
export async function parseSchedulePayload<S extends SchedulePayloadDef>(
schedule: S,
payload: unknown,
): Promise<InferSchedulePayload<S>> {
return (await parsePayload(schedule.payload, payload, {
name: schedule.name,
})) as InferSchedulePayload<S>;
}
/**
* Run one schedule directly with an explicit context.
*/
export async function runSchedule<
Ctx,
S extends ScheduleDef<string, StandardSchema, Ctx>,
>(
schedule: S,
args: ScheduleRunArgs<Ctx, InferSchedulePayload<S>>,
): Promise<void> {
await runWithResolvedTracingContext({
tracing: args.tracing,
ctx: args.ctx,
operation: scheduleTraceOperation(schedule, args),
run: async (ctx) => {
const run = createRunContext(args, () => new Date());
const payload = await resolveSchedulePayload(schedule, args, run);
await schedule.handle({
schedule,
payload,
ctx,
run,
});
},
});
}
/**
* Create a local/test schedule runner that executes handlers inline.
*/
export function createInlineScheduleRunner<Ctx>(
options: InlineScheduleRunnerOptions<Ctx> = {},
): InlineScheduleRunner<Ctx> {
const now = options.now ?? (() => new Date());
const instrumentation = createProviderInstrumentation(
options.instrumentation,
{
providerName: "schedules",
watcher: "schedules",
},
);
return {
async run<S extends ScheduleDef<string, StandardSchema, Ctx>>(
schedule: S,
runOptions: ScheduleRunOptions<InferSchedulePayload<S>> = {},
) {
const run = createRunContext(runOptions, now);
let payload: InferSchedulePayload<S> | undefined;
try {
payload = await resolveSchedulePayload(schedule, runOptions, run);
const lifecycleArgs = { schedule, payload, run };
await recordScheduleEvent(
instrumentation,
options.instrumentationContext,
schedule,
"started",
run,
);
await runLifecycleHook(
"start",
options.onStart,
options.onHookError,
lifecycleArgs,
);
await runWithResolvedTracingContext({
tracing: options.tracing,
ctx: options.ctx as Ctx | (() => MaybePromise<Ctx>),
operation: scheduleTraceOperation(schedule, runOptions),
run: (ctx) =>
schedule.handle({
schedule,
payload: payload as InferSchedulePayload<S>,
ctx,
run,
}),
});
await recordScheduleEvent(
instrumentation,
options.instrumentationContext,
schedule,
"completed",
run,
);
await runLifecycleHook(
"success",
options.onSuccess,
options.onHookError,
lifecycleArgs,
);
} catch (error) {
await recordScheduleEvent(
instrumentation,
options.instrumentationContext,
schedule,
"failed",
run,
{ error },
);
await runErrorHook(options.onError, options.onHookError, {
error,
schedule,
payload,
run,
});
throw error;
}
},
};
}
/**
* Create schedule helper methods bound to an application context type.
*
* Call it once in `lib/schedules.ts`:
*
* ```ts
* export const { defineSchedule } = createSchedules<AppContext>();
* ```
*
* Cron and timezone are metadata for schedule providers. The inline runner only
* runs schedules when its `run(...)` method is called.
*/
export function createSchedules<Ctx>(): Schedules<Ctx> {
return {
defineSchedule<Name extends string, Payload extends StandardSchema>(
name: Name,
options: DefineScheduleOptions<Name, Payload, Ctx>,
): ScheduleDef<Name, Payload, Ctx> {
return defineScheduleImpl(name, options);
},
};
}