UNPKG

smart-saga-pattern

Version:

A library implementing the Saga pattern for microservice architecture

237 lines (236 loc) 7.79 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.FileSystemStateStore = void 0; const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); /** * File system implementation of StateStore * @template T Type of the data in the context */ class FileSystemStateStore { /** * Creates a new FileSystemStateStore * @param options Options for the store */ constructor(options) { // Set default options this.options = { createDirIfNotExists: true, fileExtension: ".json", prettyPrint: false, sync: false, ttl: 86400000, // 24 hours maxFiles: 1000, autoCleanup: true, cleanupProbability: 0.1, // 10% chance ...options, }; // Create storage directory if it doesn't exist if (this.options.createDirIfNotExists) { this.ensureDirectoryExists(); } } /** * Saves a Saga context * @param sagaId ID of the Saga * @param context Saga context to save */ async save(sagaId, context) { const filePath = this.getFilePath(sagaId); const data = this.serializeContext(context); if (this.options.sync) { fs_1.default.writeFileSync(filePath, data, "utf8"); } else { await fs_1.default.promises.writeFile(filePath, data, "utf8"); } // Perform auto-cleanup if enabled if (this.options.autoCleanup && Math.random() < this.options.cleanupProbability) { // Run cleanup in the background, don't await it this.cleanup().catch((err) => { console.error("Error during auto-cleanup:", err); }); } } /** * Loads a Saga context * @param sagaId ID of the Saga */ async load(sagaId) { const filePath = this.getFilePath(sagaId); try { let data; if (this.options.sync) { data = fs_1.default.readFileSync(filePath, "utf8"); } else { data = await fs_1.default.promises.readFile(filePath, "utf8"); } return this.deserializeContext(data); } catch (error) { // File doesn't exist or can't be read if (error.code === "ENOENT") { return null; } throw error; } } /** * Updates a Saga context * @param sagaId ID of the Saga * @param context Partial Saga context to update */ async update(sagaId, context) { const existingContext = await this.load(sagaId); if (existingContext) { const updatedContext = { ...existingContext, ...context, data: { ...existingContext.data, ...(context.data || {}), }, }; await this.save(sagaId, updatedContext); } } /** * Deletes a Saga context * @param sagaId ID of the Saga */ async delete(sagaId) { const filePath = this.getFilePath(sagaId); try { if (this.options.sync) { fs_1.default.unlinkSync(filePath); } else { await fs_1.default.promises.unlink(filePath); } } catch (error) { // Ignore if file doesn't exist if (error.code !== "ENOENT") { throw error; } } } /** * Gets the file path for a saga ID * @param sagaId ID of the Saga */ getFilePath(sagaId) { return path_1.default.join(this.options.storageDir, `${sagaId}${this.options.fileExtension}`); } /** * Ensures the storage directory exists */ ensureDirectoryExists() { if (!fs_1.default.existsSync(this.options.storageDir)) { fs_1.default.mkdirSync(this.options.storageDir, { recursive: true }); } } /** * Serializes a context to a string * @param context Context to serialize */ serializeContext(context) { return JSON.stringify(context, null, this.options.prettyPrint ? 2 : undefined); } /** * Deserializes a string to a context * @param data String to deserialize */ deserializeContext(data) { return JSON.parse(data); } /** * Cleans up old saga state files * Removes files that are: * 1. Older than the TTL * 2. Exceed the maximum number of files (keeping the newest ones) */ async cleanup() { // Get all files in the storage directory const files = await this.getStateFiles(); if (files.length === 0) { return 0; } const now = Date.now(); const ttlExpired = now - this.options.ttl; let deletedCount = 0; // First, delete files that are older than TTL for (const file of files) { if (file.stats.mtimeMs < ttlExpired) { await this.deleteFile(file.path); deletedCount++; } } // Then, if we still have more files than maxFiles, delete the oldest ones if (files.length - deletedCount > this.options.maxFiles) { // Sort files by modification time (oldest first) const remainingFiles = files .filter((file) => file.stats.mtimeMs >= ttlExpired) .sort((a, b) => a.stats.mtimeMs - b.stats.mtimeMs); // Calculate how many files to delete const excessFiles = files.length - deletedCount - this.options.maxFiles; // Delete the oldest files for (let i = 0; i < excessFiles && i < remainingFiles.length; i++) { await this.deleteFile(remainingFiles[i].path); deletedCount++; } } return deletedCount; } /** * Gets all saga state files in the storage directory */ async getStateFiles() { try { const files = await fs_1.default.promises.readdir(this.options.storageDir); const result = []; // Filter files by extension and get stats for (const file of files) { if (file.endsWith(this.options.fileExtension)) { const filePath = path_1.default.join(this.options.storageDir, file); try { const stats = await fs_1.default.promises.stat(filePath); if (stats.isFile()) { result.push({ path: filePath, stats }); } } catch (error) { // Ignore errors for individual files console.warn(`Error getting stats for file ${filePath}:`, error); } } } return result; } catch (error) { console.error(`Error reading directory ${this.options.storageDir}:`, error); return []; } } /** * Deletes a file * @param filePath Path to the file to delete */ async deleteFile(filePath) { try { await fs_1.default.promises.unlink(filePath); } catch (error) { // Ignore if file doesn't exist if (error.code !== "ENOENT") { throw error; } } } } exports.FileSystemStateStore = FileSystemStateStore;