/** * @since 1.0.0 */ import * as Cause from "effect/Cause" import * as Context from "effect/Context" import * as Data from "effect/Data" import * as Effect from "effect/Effect" import * as Exit from "effect/Exit" import * as Fiber from "effect/Fiber" import { constFalse, constTrue, dual, identity } from "effect/Function" import * as Layer from "effect/Layer" import * as Option from "effect/Option" import type { Pipeable } from "effect/Pipeable" import * as Predicate from "effect/Predicate" import * as PrimaryKey from "effect/PrimaryKey" import type * as Schedule from "effect/Schedule" import * as Schema from "effect/Schema" import type * as AST from "effect/SchemaAST" import * as Scope from "effect/Scope" import { makeHashDigest } from "./internal/crypto.js" import type { WorkflowEngine, WorkflowInstance } from "./WorkflowEngine.js" /** * @since 1.0.0 * @category Symbols */ export const TypeId: unique symbol = Symbol.for("@effect/workflow/Workflow") /** * @since 1.0.0 */ export declare namespace Workflow { /** * Extracts the type of the Payload of a `Workflow`. * * @since 1.0.0 * @category Type-level Utils */ export type Payload> = W extends Workflow ? Payload["Type"] : never /** * Extracts the type of the Success of a `Workflow`. * * @since 1.0.0 * @category Type-level Utils */ export type Success> = W extends Workflow ? Success["Type"] : never /** * Extracts the type of the Error of a `Workflow`. * * @since 1.0.0 * @category Type-level Utils */ export type Error> = W extends Workflow ? Error["Type"] : never } /** * @since 1.0.0 * @category Symbols */ export type TypeId = typeof TypeId /** * @since 1.0.0 * @category Models */ export interface Workflow< Name extends string, Payload extends AnyStructSchema, Success extends Schema.Schema.Any, Error extends Schema.Schema.All > { readonly [TypeId]: TypeId readonly name: Name readonly payloadSchema: Payload readonly successSchema: Success readonly errorSchema: Error readonly annotations: Context.Context /** * Add an annotation to the workflow. */ annotate(tag: Context.Tag, value: S): Workflow< Name, Payload, Success, Error > /** * Add the annotations from a Context object to the workflow. */ annotateContext(context: Context.Context): Workflow< Name, Payload, Success, Error > /** * Execute the workflow with the given payload. */ readonly execute: ( payload: [keyof Payload["fields"]] extends [never] ? void : Schema.Simplify>, options?: { readonly discard?: Discard } ) => Effect.Effect< Discard extends true ? string : Success["Type"], Discard extends true ? never : Error["Type"], WorkflowEngine | Payload["Context"] | Success["Context"] | Error["Context"] > /** * Poll a workflow execution for its current status. * * If the workflow has not run yet, it will return `undefined`, otherwise it * will return the current `Workflow.Result`. */ readonly poll: (executionId: string) => Effect.Effect< Result | undefined, never, WorkflowEngine | Success["Context"] | Error["Context"] > /** * Interrupt a workflow execution for the given execution ID. */ readonly interrupt: (executionId: string) => Effect.Effect /** * Manually resume a workflow execution for the given execution ID. */ readonly resume: (executionId: string) => Effect.Effect /** * Create a layer that registers the workflow and provides an effect to * execute it. */ readonly toLayer: ( execute: ( payload: Payload["Type"], executionId: string ) => Effect.Effect ) => Layer.Layer< never, never, | WorkflowEngine | Exclude | Scope.Scope> | Payload["Context"] | Success["Context"] | Error["Context"] > /** * For the given payload, compute the deterministic execution ID. */ readonly executionId: ( payload: Schema.Simplify> ) => Effect.Effect /** * Add compensation logic to an effect inside a Workflow. The compensation finalizer will be * called if the entire workflow fails, allowing you to perform cleanup or * other actions based on the success value and the cause of the workflow failure. * * NOTE: Compensation will not work for nested activities. Compensation * finalizers are only registered for top-level effects in the workflow. */ readonly withCompensation: { ( compensation: (value: A, cause: Cause.Cause) => Effect.Effect ): ( effect: Effect.Effect ) => Effect.Effect | Scope.Scope> ( effect: Effect.Effect, compensation: (value: A, cause: Cause.Cause) => Effect.Effect ): Effect.Effect | Scope.Scope> } } /** * @since 1.0.0 */ export interface AnyStructSchema extends Pipeable { readonly [Schema.TypeId]: any readonly make: any readonly Type: any readonly Encoded: any readonly Context: any readonly ast: AST.AST readonly fields: Schema.Struct.Fields readonly annotations: any } /** * @since 1.0.0 * @category constructors */ export interface AnyTaggedRequestSchema extends AnyStructSchema { readonly _tag: string readonly Type: PrimaryKey.PrimaryKey readonly success: Schema.Schema.Any readonly failure: Schema.Schema.All } /** * @since 1.0.0 * @category Models */ export interface Execution { readonly _: unique symbol readonly name: Name } /** * @since 1.0.0 * @category Models */ export interface Any { readonly [TypeId]: TypeId readonly name: string readonly payloadSchema: AnyStructSchema readonly successSchema: Schema.Schema.Any readonly errorSchema: Schema.Schema.All readonly annotations: Context.Context readonly executionId: (payload: any) => Effect.Effect } /** * @since 1.0.0 * @category Models */ export type Requirements = Workflows extends Workflow< infer _Name, infer _Payload, infer _Success, infer _Error > ? _Payload["Context"] | _Success["Context"] | _Error["Context"] : never const EngineTag = Context.GenericTag( "@effect/workflow/WorkflowEngine" satisfies typeof WorkflowEngine.key ) const InstanceTag = Context.GenericTag( "@effect/workflow/WorkflowEngine/WorkflowInstance" satisfies typeof WorkflowInstance.key ) /** * @since 1.0.0 * @category Constructors */ export const make = < const Name extends string, Payload extends Schema.Struct.Fields | AnyStructSchema, Success extends Schema.Schema.Any = typeof Schema.Void, Error extends Schema.Schema.All = typeof Schema.Never >( options: { readonly name: Name readonly payload: Payload readonly idempotencyKey: ( payload: Payload extends Schema.Struct.Fields ? Schema.Struct.Type : Payload["Type"] ) => string readonly success?: Success readonly error?: Error readonly suspendedRetrySchedule?: Schedule.Schedule | undefined readonly annotations?: Context.Context } ): Workflow : Payload, Success, Error> => { const makeExecutionId = (payload: any) => makeHashDigest(`${options.name}-${options.idempotencyKey(payload)}`) const self: Workflow = { [TypeId]: TypeId, name: options.name, payloadSchema: Schema.isSchema(options.payload) ? options.payload : Schema.Struct(options.payload as any), successSchema: options.success ?? Schema.Void as any, errorSchema: options.error ?? Schema.Never as any, annotations: options.annotations ?? Context.empty(), annotate(tag, value) { return make({ ...options, annotations: Context.add(self.annotations, tag, value) }) }, annotateContext(context) { return make({ ...options, annotations: Context.merge(self.annotations, context) }) }, execute: Effect.fnUntraced( function*(fields: any, opts) { const payload = self.payloadSchema.make(fields) const engine = yield* EngineTag const executionId = yield* makeExecutionId(payload) yield* Effect.annotateCurrentSpan({ executionId }) return yield* engine.execute(self, { executionId, payload, discard: opts?.discard, suspendedRetrySchedule: options.suspendedRetrySchedule }) }, Effect.withSpan(`${options.name}.execute`, { captureStackTrace: false }) ), poll: Effect.fnUntraced( function*(executionId: string) { const engine = yield* EngineTag return yield* engine.poll(self, executionId) }, (effect, executionId) => Effect.withSpan(effect, `${options.name}.poll`, { captureStackTrace: false, attributes: { executionId } }) ), interrupt: Effect.fnUntraced( function*(executionId: string) { const engine = yield* EngineTag yield* engine.interrupt(self, executionId) }, (effect, executionId) => Effect.withSpan(effect, `${options.name}.interrupt`, { captureStackTrace: false, attributes: { executionId } }) ), resume: Effect.fnUntraced( function*(executionId: string) { const engine = yield* EngineTag yield* engine.resume(self, executionId) }, (effect, executionId) => Effect.withSpan(effect, `${options.name}.resume`, { captureStackTrace: false, attributes: { executionId } }) ), toLayer: (execute) => Layer.scopedDiscard(Effect.gen(function*() { const engine = yield* EngineTag return yield* engine.register(self, execute) })) as any, executionId: (payload) => makeExecutionId(self.payloadSchema.make(payload)), withCompensation } return self } /** * @since 1.0.0 * @category Constructors */ export const fromTaggedRequest = (schema: S, options?: { readonly suspendedRetrySchedule?: Schedule.Schedule | undefined }): Workflow => make({ name: schema._tag, payload: schema as any, success: schema.success, error: schema.failure, idempotencyKey: PrimaryKey.value, suspendedRetrySchedule: options?.suspendedRetrySchedule }) /** * @since 1.0.0 * @category Result */ export const ResultTypeId: unique symbol = Symbol.for("@effect/workflow/Workflow/Result") /** * @since 1.0.0 * @category Result */ export type ResultTypeId = typeof ResultTypeId /** * @since 1.0.0 * @category Result */ export const isResult = (u: unknown): u is Result => Predicate.hasProperty(u, ResultTypeId) /** * @since 1.0.0 * @category Result */ export type Result = Complete | Suspended /** * @since 1.0.0 * @category Result */ export type ResultEncoded = CompleteEncoded | typeof Suspended.Encoded /** * @since 1.0.0 * @category Result */ export class Complete extends Data.TaggedClass("Complete")<{ readonly exit: Exit.Exit }> { /** * @since 1.0.0 */ readonly [ResultTypeId]: ResultTypeId = ResultTypeId /** * @since 1.0.0 */ static SchemaFromSelf(_options: { readonly success: Success readonly error: Error }): Schema.Schema> { return Schema.declare((u): u is Complete => isResult(u) && u._tag === "Complete", { typeConstructor: { _tag: "effect/workflow/Workflow.Complete" } }) } /** * @since 1.0.0 */ static SchemaEncoded(options: { readonly success: Success readonly error: Error }) { return Schema.Struct({ _tag: Schema.tag("Complete"), exit: Schema.Exit({ success: options.success, failure: options.error, defect: Schema.Defect }) }) } /** * @since 1.0.0 */ static Schema(options: { readonly success: Success readonly error: Error }): Schema.Schema< Complete, CompleteEncoded > { return Schema.transform( this.SchemaEncoded(options), this.SchemaFromSelf(options), { decode(fromA) { return new Complete({ exit: fromA.exit }) }, encode(toI) { return toI } } ) as any } } /** * @since 1.0.0 * @category Result */ export interface CompleteEncoded { readonly _tag: "Complete" readonly exit: Schema.ExitEncoded } /** * @since 1.0.0 * @category Result */ export class Suspended extends Schema.TaggedClass("@effect/workflow/Workflow/Suspended")("Suspended", { cause: Schema.optional(Schema.Cause({ error: Schema.Never, defect: Schema.Defect })) }) { /** * @since 1.0.0 */ readonly [ResultTypeId]: ResultTypeId = ResultTypeId } /** * @since 1.0.0 * @category Result */ export const Result = ( options: { readonly success: Success readonly error: Error } ): Schema.Schema< Result, ResultEncoded, Success["Context"] | Error["Context"] > => Schema.Union(Complete.Schema(options), Suspended) /** * @since 1.0.0 * @category Result */ export const intoResult = ( effect: Effect.Effect ): Effect.Effect, never, Exclude | WorkflowInstance> => Effect.contextWithEffect((context: Context.Context) => { const instance = Context.get(context, InstanceTag) const captureDefects = Context.get(instance.workflow.annotations, CaptureDefects) const suspendOnFailure = Context.get(instance.workflow.annotations, SuspendOnFailure) return effect.pipe( // So we can use external interruption to suspend a workflow Effect.fork, Effect.flatMap((fiber) => Effect.onInterrupt(Fiber.join(fiber), () => Fiber.interrupt(fiber))), Effect.interruptible, suspendOnFailure ? Effect.catchAllCause((cause) => { instance.suspended = true if (!Cause.isInterruptedOnly(cause)) { instance.cause = Cause.die(Cause.squash(cause)) } return Effect.interrupt }) : identity, Effect.scoped, Effect.matchCauseEffect({ onSuccess: (value) => Effect.succeed(new Complete({ exit: Exit.succeed(value) })), onFailure(cause): Effect.Effect> { const isInterruptedOnly = Cause.isInterruptedOnly(cause) const filtered = isInterruptedOnly ? cause : withoutInterrupts(cause) return instance.suspended && isInterruptedOnly ? Effect.succeed(new Suspended({ cause: instance.cause })) : (!instance.interrupted && isInterruptedOnly) || (!captureDefects && Cause.isDie(cause)) ? Effect.failCause(filtered as Cause.Cause) : Effect.succeed(new Complete({ exit: Exit.failCause(filtered) })) } }), Effect.onExit((exit) => { if (Exit.isFailure(exit)) { return Scope.close(instance.scope, exit) } else if (exit.value._tag === "Complete") { return Scope.close(instance.scope, exit.value.exit) } return Effect.void }), Effect.uninterruptible ) }) const withoutInterrupts = (cause: Cause.Cause): Cause.Cause => Cause.isInterrupted(cause) ? Cause.filter(cause, (cause) => !Cause.isInterruptType(cause)) : cause /** * @since 1.0.0 * @category Result */ export const wrapActivityResult = ( effect: Effect.Effect, isSuspend: (value: A) => boolean ): Effect.Effect => Effect.contextWithEffect((context: Context.Context) => { const instance = Context.get(context, InstanceTag) const state = instance.activityState if (state.count === 0) state.latch.unsafeClose() state.count++ return Effect.onExit(effect, (exit) => { state.count-- const isSuspended = Exit.isSuccess(exit) && isSuspend(exit.value) if (Exit.isSuccess(exit) && isResult(exit.value) && exit.value._tag === "Suspended" && exit.value.cause) { instance.cause = instance.cause ? Cause.sequential(instance.cause, exit.value.cause) : exit.value.cause } return state.count === 0 ? state.latch.open : isSuspended ? waitForZero(instance) : Effect.void }) }) const waitForZero = Effect.fnUntraced(function*(instance: WorkflowInstance["Type"]) { const state = instance.activityState while (true) { if (state.count > 0) { yield* state.latch.await yield* Effect.yieldNow() continue } yield* Effect.yieldNow() if (state.count === 0) return } }) /** * Accesses the workflow scope. * * The workflow scope is only closed when the workflow execution fully * completes. * * @since 1.0.0 * @category Scope */ export const scope: Effect.Effect< Scope.Scope, never, WorkflowInstance > = Effect.map(InstanceTag, (instance) => instance.scope as Scope.Scope) /** * Provides the workflow scope to the given effect. * * The workflow scope is only closed when the workflow execution fully * completes. * * @since 1.0.0 * @category Scope */ export const provideScope = ( effect: Effect.Effect ): Effect.Effect | WorkflowInstance> => Effect.flatMap(scope, (scope) => Scope.extend(effect, scope)) /** * @since 1.0.0 * @category Scope */ export const addFinalizer: ( f: (exit: Exit.Exit) => Effect.Effect ) => Effect.Effect< void, never, WorkflowInstance | R > = Effect.fnUntraced(function*( f: (exit: Exit.Exit) => Effect.Effect ) { const scope = (yield* InstanceTag).scope const runtime = yield* Effect.runtime() yield* Scope.addFinalizerExit(scope, (exit) => Effect.provide(f(exit), runtime)) }) /** * Add compensation logic to an effect inside a Workflow. The compensation finalizer will be * called if the entire workflow fails, allowing you to perform cleanup or * other actions based on the success value and the cause of the workflow failure. * * NOTE: Compensation will not work for nested activities. Compensation * finalizers are only registered for top-level effects in the workflow. * * @since 1.0.0 * @category Compensation */ export const withCompensation: { /** * Add compensation logic to an effect inside a Workflow. The compensation finalizer will be * called if the entire workflow fails, allowing you to perform cleanup or * other actions based on the success value and the cause of the workflow failure. * * NOTE: Compensation will not work for nested activities. Compensation * finalizers are only registered for top-level effects in the workflow. * * @since 1.0.0 * @category Compensation */ ( compensation: (value: A, cause: Cause.Cause) => Effect.Effect ): ( effect: Effect.Effect ) => Effect.Effect /** * Add compensation logic to an effect inside a Workflow. The compensation finalizer will be * called if the entire workflow fails, allowing you to perform cleanup or * other actions based on the success value and the cause of the workflow failure. * * NOTE: Compensation will not work for nested activities. Compensation * finalizers are only registered for top-level effects in the workflow. * * @since 1.0.0 * @category Compensation */ ( effect: Effect.Effect, compensation: (value: A, cause: Cause.Cause) => Effect.Effect ): Effect.Effect } = dual(2, ( effect: Effect.Effect, compensation: (value: A, cause: Cause.Cause) => Effect.Effect ): Effect.Effect => Effect.uninterruptibleMask((restore) => Effect.tap( restore(effect), (value) => addFinalizer((exit) => Exit.isSuccess(exit) ? Effect.void : compensation(value, exit.cause)) ) )) /** * @since 1.0.0 */ export const suspend = (instance: WorkflowInstance["Type"]): Effect.Effect => Effect.interruptible(Effect.async(() => { instance.suspended = true const fiber = Option.getOrThrow(Fiber.getCurrentFiber()) fiber.unsafeInterruptAsFork(fiber.id()) })) /** * If you set this annotation to `true` for a workflow, it will capture defects * and include them in the result of the workflow or it's activities. * * By default, this is set to `true`, meaning that defects will be captured. * * @since 1.0.0 * @category Annotations */ export class CaptureDefects extends Context.Reference()("@effect/workflow/Workflow/CaptureDefects", { defaultValue: constTrue }) {} /** * If you set this annotation to `true` for a workflow, it will suspend if it * encounters any kind of error. * * You can then manually resume the workflow later with * `Workflow.resume(executionId)`. * * @since 1.0.0 * @category Annotations */ export class SuspendOnFailure extends Context.Reference()("@effect/workflow/Workflow/SuspendOnFailure", { defaultValue: constFalse }) {}