/** * CheckpointService - Effect-based checkpoint operations. * * Provides Effect-based API for checkpoint management with proper error typing * and dependency injection. Wraps CheckpointStorage with TTL handling. */ import { Context, Effect, Layer, Ref } from 'effect'; import type { CheckpointStorage, Checkpoint, CheckpointStatus } from './types'; import type { PipelineContext } from '../context'; import type { PauseMetadata } from '../pause/types'; import { CheckpointNotFoundError, CheckpointExpiredError, } from '../errors'; /** * Options for saving a checkpoint. */ export interface SaveCheckpointOptions { /** Unique run identifier */ runId: string; /** Pipeline identifier */ pipelineId: string; /** Step number (0-indexed) */ step: number; /** Checkpoint status */ status: CheckpointStatus; /** Full pipeline context at this step */ context: PipelineContext; /** Optional custom expiration time (overrides default TTL) */ expiresAt?: Date; /** Step name for resilient resume (optional) */ stepName?: string; /** Pause metadata (only set when status is 'paused') */ pauseMetadata?: PauseMetadata; } /** * CheckpointService interface. * * Provides Effect-based checkpoint operations with typed errors. */ export interface CheckpointService { /** * Generate a unique run ID using crypto.randomUUID(). * @returns A UUID string suitable for run identification */ generateRunId(): Effect.Effect; /** * Save a checkpoint with automatic TTL handling. * * @param options - Checkpoint data including runId, pipelineId, step, status, context */ saveCheckpoint(options: SaveCheckpointOptions): Effect.Effect; /** * Get the latest checkpoint for a run (highest step number). * * @param runId - The run identifier * @returns The latest checkpoint or fails with CheckpointNotFoundError */ getLatestCheckpoint(runId: string): Effect.Effect; /** * Get the latest checkpoint for a pipeline (highest step, timestamp tie-break). * Used by resume to find the most recent resumable checkpoint for a pipeline. * * @param pipelineId - The pipeline identifier * @returns The latest checkpoint for the pipeline or null if none exists */ getLatestByPipelineId(pipelineId: string): Effect.Effect; /** * Get a specific checkpoint by run ID and step. * * @param runId - The run identifier * @param step - The step number * @returns The checkpoint or fails with CheckpointNotFoundError */ getCheckpoint(runId: string, step: number): Effect.Effect; /** * Update the status of a checkpoint. * * @param runId - The run identifier * @param step - The step number * @param status - The new status */ updateStatus(runId: string, step: number, status: CheckpointStatus): Effect.Effect; /** * Mark a run as completed. * Convenience method for updateStatus with 'completed' status. * * @param runId - The run identifier * @param step - The step number */ markCompleted(runId: string, step: number): Effect.Effect; /** * Mark a run as failed. * Convenience method for updateStatus with 'failed' status. * * @param runId - The run identifier * @param step - The step number */ markFailed(runId: string, step: number): Effect.Effect; /** * Delete all checkpoints for a run. * Useful for cleanup after successful completion. * * @param runId - The run identifier */ deleteRun(runId: string): Effect.Effect; /** * Delete expired checkpoints. * Call this periodically for automatic cleanup. * * @returns The number of deleted checkpoints */ deleteExpired(): Effect.Effect; /** * Get the underlying storage (for PauseManager access). * @internal */ getStorage(): Effect.Effect; } export const CheckpointService = Context.GenericTag( 'CheckpointService' ); /** * Implementation of CheckpointService. */ class CheckpointServiceImpl implements CheckpointService { /** Default TTL: 7 days in milliseconds */ static readonly DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; constructor( private storage: CheckpointStorage, private defaultTtlMs: Ref.Ref ) {} generateRunId(): Effect.Effect { return Effect.sync(() => crypto.randomUUID()); } saveCheckpoint(options: SaveCheckpointOptions): Effect.Effect { const self = this; return Effect.gen(function* () { const now = new Date(); const ttl = yield* Ref.get(self.defaultTtlMs); const expiresAt = options.expiresAt ?? new Date(now.getTime() + ttl); yield* self.storageSave({ runId: options.runId, pipelineId: options.pipelineId, step: options.step, status: options.status, context: options.context, createdAt: now, updatedAt: now, expiresAt, stepName: options.stepName, pauseMetadata: options.pauseMetadata, }); }); } getLatestCheckpoint(runId: string): Effect.Effect { const self = this; return Effect.gen(function* () { const checkpoint = yield* self.storageGetLatest(runId); if (!checkpoint) { return yield* Effect.fail(new CheckpointNotFoundError({ runId })); } return checkpoint; }); } getLatestByPipelineId(pipelineId: string): Effect.Effect { const self = this; return Effect.gen(function* () { const checkpoint = yield* self.storageGetLatestByPipelineId(pipelineId); return checkpoint; }); } getCheckpoint(runId: string, step: number): Effect.Effect { const self = this; return Effect.gen(function* () { const checkpoint = yield* self.storageGet(runId, step); if (!checkpoint) { return yield* Effect.fail(new CheckpointNotFoundError({ runId, step })); } return checkpoint; }); } updateStatus(runId: string, step: number, status: CheckpointStatus): Effect.Effect { return this.storageUpdateStatus(runId, step, status); } markCompleted(runId: string, step: number): Effect.Effect { return this.updateStatus(runId, step, 'completed'); } markFailed(runId: string, step: number): Effect.Effect { return this.updateStatus(runId, step, 'failed'); } deleteRun(runId: string): Effect.Effect { return this.storageDeleteRun(runId); } deleteExpired(): Effect.Effect { return this.storageDeleteExpired(); } // --- Effect-wrapped storage operations --- private storageSave(checkpoint: Checkpoint): Effect.Effect { const self = this; return Effect.async((resume) => { self.storage.save(checkpoint) .then(() => resume(Effect.succeed(void 0))) .catch((error) => resume(Effect.die(error))); // Storage errors are defects }); } private storageGetLatest(runId: string): Effect.Effect { const self = this; return Effect.async((resume) => { self.storage.getLatest(runId) .then((result) => resume(Effect.succeed(result))) .catch((error) => resume(Effect.die(error))); }); } private storageGetLatestByPipelineId(pipelineId: string): Effect.Effect { const self = this; return Effect.async((resume) => { self.storage.getLatestByPipelineId(pipelineId) .then((result) => resume(Effect.succeed(result))) .catch((error) => resume(Effect.die(error))); }); } private storageGet(runId: string, step: number): Effect.Effect { const self = this; return Effect.async((resume) => { self.storage.get(runId, step) .then((result) => resume(Effect.succeed(result))) .catch((error) => resume(Effect.die(error))); }); } private storageUpdateStatus(runId: string, step: number, status: CheckpointStatus): Effect.Effect { const self = this; return Effect.async((resume) => { self.storage.updateStatus(runId, step, status) .then(() => resume(Effect.succeed(void 0))) .catch((error) => resume(Effect.die(error))); }); } private storageDeleteRun(runId: string): Effect.Effect { const self = this; return Effect.async((resume) => { self.storage.deleteRun(runId) .then(() => resume(Effect.succeed(void 0))) .catch((error) => resume(Effect.die(error))); }); } private storageDeleteExpired(): Effect.Effect { const self = this; return Effect.async((resume) => { self.storage.deleteExpired() .then((count) => resume(Effect.succeed(count))) .catch((error) => resume(Effect.die(error))); }); } getStorage(): Effect.Effect { return Effect.succeed(this.storage); } } /** * Options for creating CheckpointServiceLive layer. */ export interface CheckpointServiceLiveOptions { /** Underlying storage adapter (Postgres, SQLite, etc.) */ storage: CheckpointStorage; /** Default TTL in milliseconds. Default: 7 days */ defaultTtlMs?: number; } /** * Create a Live layer for CheckpointService with provided storage. * * @param options - Configuration including storage and optional TTL */ export const CheckpointServiceLive = (options: CheckpointServiceLiveOptions) => Layer.effect( CheckpointService, Effect.gen(function* () { const ttl = options.defaultTtlMs ?? CheckpointServiceImpl.DEFAULT_TTL_MS; const defaultTtlMs = yield* Ref.make(ttl); return new CheckpointServiceImpl(options.storage, defaultTtlMs); }) );