smart-saga-pattern
Version:
A library implementing the Saga pattern for microservice architecture
237 lines (236 loc) • 7.79 kB
JavaScript
"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;