UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

868 lines (808 loc) 21.6 kB
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); }, }; }