@saga-bus/store-redis 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Dean Foran
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,218 @@
1
+ # @saga-bus/store-redis
2
+
3
+ Redis-backed saga store for saga-bus with optimistic concurrency.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @saga-bus/store-redis ioredis
9
+ # or
10
+ pnpm add @saga-bus/store-redis ioredis
11
+ ```
12
+
13
+ ## Features
14
+
15
+ - **Fast**: Sub-millisecond read/write operations
16
+ - **Optimistic Concurrency**: WATCH/MULTI for conflict detection
17
+ - **TTL Support**: Automatic cleanup of completed sagas
18
+ - **Index Lookup**: Find sagas by ID or correlation ID
19
+ - **Clustering**: Works with Redis Cluster
20
+
21
+ ## Quick Start
22
+
23
+ ```typescript
24
+ import { createBus } from "@saga-bus/core";
25
+ import { RedisSagaStore } from "@saga-bus/store-redis";
26
+
27
+ const store = new RedisSagaStore({
28
+ connection: { host: "localhost", port: 6379 },
29
+ completedTtlSeconds: 86400, // Auto-cleanup after 1 day
30
+ });
31
+
32
+ await store.initialize();
33
+
34
+ const bus = createBus({
35
+ store,
36
+ // ... other config
37
+ });
38
+
39
+ await bus.start();
40
+ ```
41
+
42
+ ## Configuration
43
+
44
+ ```typescript
45
+ interface RedisSagaStoreOptions {
46
+ /** Existing Redis client */
47
+ redis?: Redis;
48
+
49
+ /** Connection options for creating new client */
50
+ connection?: RedisOptions;
51
+
52
+ /** Key prefix for all saga keys (default: "saga-bus:") */
53
+ keyPrefix?: string;
54
+
55
+ /** TTL in seconds for completed sagas (0 = no expiry) */
56
+ completedTtlSeconds?: number;
57
+
58
+ /** TTL in seconds for all sagas (0 = no expiry) */
59
+ defaultTtlSeconds?: number;
60
+
61
+ /** Maximum retries for optimistic locking conflicts (default: 3) */
62
+ maxRetries?: number;
63
+
64
+ /** Delay between retries in milliseconds (default: 100) */
65
+ retryDelayMs?: number;
66
+ }
67
+ ```
68
+
69
+ ## Examples
70
+
71
+ ### Basic Usage
72
+
73
+ ```typescript
74
+ import { RedisSagaStore } from "@saga-bus/store-redis";
75
+
76
+ const store = new RedisSagaStore({
77
+ connection: { host: "localhost", port: 6379 },
78
+ });
79
+
80
+ await store.initialize();
81
+
82
+ // Find by correlation ID
83
+ const state = await store.findByCorrelationId("OrderSaga", "order-123");
84
+
85
+ // Find by saga ID
86
+ const stateById = await store.findById("OrderSaga", "saga-456");
87
+
88
+ // Save state
89
+ await store.save("OrderSaga", {
90
+ id: "saga-456",
91
+ correlationId: "order-123",
92
+ status: "running",
93
+ data: { orderId: "order-123" },
94
+ metadata: { /* ... */ },
95
+ });
96
+
97
+ // Delete
98
+ await store.delete("OrderSaga", "order-123");
99
+ ```
100
+
101
+ ### With Existing Redis Client
102
+
103
+ ```typescript
104
+ import { Redis } from "ioredis";
105
+
106
+ const redis = new Redis({
107
+ host: "localhost",
108
+ port: 6379,
109
+ password: "secret",
110
+ db: 1,
111
+ });
112
+
113
+ const store = new RedisSagaStore({
114
+ redis,
115
+ keyPrefix: "myapp:",
116
+ });
117
+ ```
118
+
119
+ ### With TTL for Auto-Cleanup
120
+
121
+ ```typescript
122
+ const store = new RedisSagaStore({
123
+ connection: { host: "localhost", port: 6379 },
124
+ completedTtlSeconds: 86400, // Delete completed sagas after 24 hours
125
+ defaultTtlSeconds: 604800, // Delete all sagas after 7 days
126
+ });
127
+ ```
128
+
129
+ ### Redis Cluster
130
+
131
+ ```typescript
132
+ import { Cluster } from "ioredis";
133
+
134
+ const cluster = new Cluster([
135
+ { host: "redis-1", port: 6379 },
136
+ { host: "redis-2", port: 6379 },
137
+ { host: "redis-3", port: 6379 },
138
+ ]);
139
+
140
+ const store = new RedisSagaStore({
141
+ redis: cluster as any,
142
+ });
143
+ ```
144
+
145
+ ## Key Structure
146
+
147
+ The store uses the following key structure:
148
+
149
+ ```
150
+ {prefix}saga:{sagaName}:{correlationId} -> JSON serialized state
151
+ {prefix}saga:{sagaName}:idx:id:{sagaId} -> correlation ID (index)
152
+ ```
153
+
154
+ Example:
155
+ ```
156
+ saga-bus:saga:OrderSaga:order-123 -> {"id":"saga-456",...}
157
+ saga-bus:saga:OrderSaga:idx:id:saga-456 -> "order-123"
158
+ ```
159
+
160
+ ## Optimistic Concurrency
161
+
162
+ The store uses Redis WATCH/MULTI for optimistic locking:
163
+
164
+ 1. `WATCH` the key before reading
165
+ 2. Read current state and check version
166
+ 3. `MULTI` to start transaction
167
+ 4. `SET` new state
168
+ 5. `EXEC` - fails if key was modified
169
+
170
+ If a conflict is detected, the operation is retried up to `maxRetries` times.
171
+
172
+ ```typescript
173
+ const store = new RedisSagaStore({
174
+ connection: { host: "localhost", port: 6379 },
175
+ maxRetries: 5, // More retries for high-contention scenarios
176
+ retryDelayMs: 50, // Shorter delay between retries
177
+ });
178
+ ```
179
+
180
+ ## Performance Considerations
181
+
182
+ 1. **Use Key Prefixes**: Helps with Redis SCAN operations and debugging
183
+ 2. **Set TTLs**: Prevents unbounded growth of saga data
184
+ 3. **Connection Pooling**: Reuse Redis connections across stores
185
+ 4. **Clustering**: Use Redis Cluster for horizontal scaling
186
+
187
+ ## Error Handling
188
+
189
+ ```typescript
190
+ try {
191
+ await store.save("OrderSaga", state);
192
+ } catch (error) {
193
+ if (error.message.includes("Optimistic concurrency conflict")) {
194
+ // State was modified by another process
195
+ // Reload and retry
196
+ }
197
+ }
198
+ ```
199
+
200
+ ## Testing
201
+
202
+ For testing, you can run Redis locally:
203
+
204
+ ```bash
205
+ docker run -p 6379:6379 redis:latest
206
+ ```
207
+
208
+ Or use an in-memory store for unit tests:
209
+
210
+ ```typescript
211
+ import { InMemorySagaStore } from "@saga-bus/core";
212
+
213
+ const testStore = new InMemorySagaStore();
214
+ ```
215
+
216
+ ## License
217
+
218
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,212 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ RedisSagaStore: () => RedisSagaStore
24
+ });
25
+ module.exports = __toCommonJS(index_exports);
26
+
27
+ // src/RedisSagaStore.ts
28
+ var import_ioredis = require("ioredis");
29
+ var import_core = require("@saga-bus/core");
30
+ var RedisSagaStore = class {
31
+ redis = null;
32
+ options;
33
+ ownsRedis;
34
+ constructor(options) {
35
+ if (!options.redis && !options.connection) {
36
+ throw new Error("Either redis or connection must be provided");
37
+ }
38
+ this.options = {
39
+ keyPrefix: "saga-bus:",
40
+ completedTtlSeconds: 0,
41
+ defaultTtlSeconds: 0,
42
+ maxRetries: 3,
43
+ retryDelayMs: 100,
44
+ ...options
45
+ };
46
+ this.ownsRedis = !options.redis;
47
+ }
48
+ async initialize() {
49
+ if (this.options.redis) {
50
+ this.redis = this.options.redis;
51
+ } else {
52
+ this.redis = new import_ioredis.Redis(this.options.connection);
53
+ }
54
+ }
55
+ async close() {
56
+ if (this.ownsRedis && this.redis) {
57
+ await this.redis.quit();
58
+ }
59
+ this.redis = null;
60
+ }
61
+ async getById(sagaName, sagaId) {
62
+ if (!this.redis) throw new Error("Store not initialized");
63
+ const indexKey = this.buildIdIndexKey(sagaName, sagaId);
64
+ const correlationId = await this.redis.get(indexKey);
65
+ if (!correlationId) return null;
66
+ return this.getByCorrelationId(sagaName, correlationId);
67
+ }
68
+ async getByCorrelationId(sagaName, correlationId) {
69
+ if (!this.redis) throw new Error("Store not initialized");
70
+ const key = this.buildKey(sagaName, correlationId);
71
+ const data = await this.redis.get(key);
72
+ if (!data) return null;
73
+ return this.deserializeState(data);
74
+ }
75
+ async insert(sagaName, correlationId, state) {
76
+ if (!this.redis) throw new Error("Store not initialized");
77
+ const key = this.buildKey(sagaName, correlationId);
78
+ const indexKey = this.buildIdIndexKey(sagaName, state.metadata.sagaId);
79
+ const existing = await this.redis.get(key);
80
+ if (existing) {
81
+ throw new Error(
82
+ `Saga ${sagaName} with correlation ID ${correlationId} already exists`
83
+ );
84
+ }
85
+ const serialized = this.serializeState(state);
86
+ let ttl = this.options.defaultTtlSeconds;
87
+ if (state.metadata.isCompleted && this.options.completedTtlSeconds > 0) {
88
+ ttl = this.options.completedTtlSeconds;
89
+ }
90
+ const multi = this.redis.multi();
91
+ if (ttl > 0) {
92
+ multi.setex(key, ttl, serialized);
93
+ multi.setex(indexKey, ttl, correlationId);
94
+ } else {
95
+ multi.set(key, serialized);
96
+ multi.set(indexKey, correlationId);
97
+ }
98
+ await multi.exec();
99
+ }
100
+ async update(sagaName, state, expectedVersion) {
101
+ if (!this.redis) throw new Error("Store not initialized");
102
+ const indexKey = this.buildIdIndexKey(sagaName, state.metadata.sagaId);
103
+ const correlationId = await this.redis.get(indexKey);
104
+ if (!correlationId) {
105
+ throw new Error(`Saga ${state.metadata.sagaId} not found`);
106
+ }
107
+ const key = this.buildKey(sagaName, correlationId);
108
+ for (let attempt = 0; attempt < this.options.maxRetries; attempt++) {
109
+ await this.redis.watch(key);
110
+ try {
111
+ const existing = await this.redis.get(key);
112
+ if (!existing) {
113
+ await this.redis.unwatch();
114
+ throw new Error(`Saga ${state.metadata.sagaId} not found`);
115
+ }
116
+ const currentState = this.deserializeState(existing);
117
+ if (currentState.metadata.version !== expectedVersion) {
118
+ await this.redis.unwatch();
119
+ throw new import_core.ConcurrencyError(
120
+ state.metadata.sagaId,
121
+ expectedVersion,
122
+ currentState.metadata.version
123
+ );
124
+ }
125
+ const multi = this.redis.multi();
126
+ const serialized = this.serializeState(state);
127
+ let ttl = this.options.defaultTtlSeconds;
128
+ if (state.metadata.isCompleted && this.options.completedTtlSeconds > 0) {
129
+ ttl = this.options.completedTtlSeconds;
130
+ }
131
+ if (ttl > 0) {
132
+ multi.setex(key, ttl, serialized);
133
+ multi.setex(indexKey, ttl, correlationId);
134
+ } else {
135
+ multi.set(key, serialized);
136
+ multi.set(indexKey, correlationId);
137
+ }
138
+ const result = await multi.exec();
139
+ if (result === null) {
140
+ if (attempt < this.options.maxRetries - 1) {
141
+ await this.delay(this.options.retryDelayMs);
142
+ continue;
143
+ }
144
+ throw new import_core.ConcurrencyError(
145
+ state.metadata.sagaId,
146
+ expectedVersion,
147
+ -1
148
+ // Unknown current version due to race
149
+ );
150
+ }
151
+ return;
152
+ } catch (error) {
153
+ await this.redis.unwatch();
154
+ throw error;
155
+ }
156
+ }
157
+ }
158
+ async delete(sagaName, sagaId) {
159
+ if (!this.redis) throw new Error("Store not initialized");
160
+ const indexKey = this.buildIdIndexKey(sagaName, sagaId);
161
+ const correlationId = await this.redis.get(indexKey);
162
+ if (!correlationId) return;
163
+ const key = this.buildKey(sagaName, correlationId);
164
+ await this.redis.del(key, indexKey);
165
+ }
166
+ buildKey(sagaName, correlationId) {
167
+ return `${this.options.keyPrefix}saga:${sagaName}:${correlationId}`;
168
+ }
169
+ buildIdIndexKey(sagaName, sagaId) {
170
+ return `${this.options.keyPrefix}saga:${sagaName}:idx:id:${sagaId}`;
171
+ }
172
+ serializeState(state) {
173
+ return JSON.stringify({
174
+ ...state,
175
+ metadata: {
176
+ ...state.metadata,
177
+ createdAt: state.metadata.createdAt.toISOString(),
178
+ updatedAt: state.metadata.updatedAt.toISOString(),
179
+ archivedAt: state.metadata.archivedAt?.toISOString() ?? null,
180
+ timeoutExpiresAt: state.metadata.timeoutExpiresAt?.toISOString() ?? null
181
+ }
182
+ });
183
+ }
184
+ deserializeState(data) {
185
+ const parsed = JSON.parse(data);
186
+ return {
187
+ ...parsed,
188
+ metadata: {
189
+ ...parsed.metadata,
190
+ createdAt: new Date(parsed.metadata.createdAt),
191
+ updatedAt: new Date(parsed.metadata.updatedAt),
192
+ archivedAt: parsed.metadata.archivedAt ? new Date(parsed.metadata.archivedAt) : null,
193
+ timeoutExpiresAt: parsed.metadata.timeoutExpiresAt ? new Date(parsed.metadata.timeoutExpiresAt) : null
194
+ }
195
+ };
196
+ }
197
+ delay(ms) {
198
+ return new Promise((resolve) => setTimeout(resolve, ms));
199
+ }
200
+ // ============ Query Helpers ============
201
+ /**
202
+ * Get the underlying Redis client for advanced operations.
203
+ */
204
+ getRedis() {
205
+ return this.redis;
206
+ }
207
+ };
208
+ // Annotate the CommonJS export names for ESM import in node:
209
+ 0 && (module.exports = {
210
+ RedisSagaStore
211
+ });
212
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/RedisSagaStore.ts"],"sourcesContent":["export { RedisSagaStore } from \"./RedisSagaStore.js\";\nexport type { RedisSagaStoreOptions } from \"./types.js\";\n","import { Redis } from \"ioredis\";\nimport type { SagaStore, SagaState } from \"@saga-bus/core\";\nimport { ConcurrencyError } from \"@saga-bus/core\";\nimport type { RedisSagaStoreOptions } from \"./types.js\";\n\n/**\n * Redis-backed saga store for saga-bus.\n *\n * Uses WATCH/MULTI for optimistic concurrency control.\n *\n * @example\n * ```typescript\n * import { RedisSagaStore } from \"@saga-bus/store-redis\";\n *\n * const store = new RedisSagaStore<OrderState>({\n * connection: { host: \"localhost\", port: 6379 },\n * completedTtlSeconds: 86400, // 1 day\n * });\n *\n * await store.initialize();\n * ```\n */\nexport class RedisSagaStore<TState extends SagaState>\n implements SagaStore<TState>\n{\n private redis: Redis | null = null;\n private readonly options: Required<\n Pick<\n RedisSagaStoreOptions,\n | \"keyPrefix\"\n | \"completedTtlSeconds\"\n | \"defaultTtlSeconds\"\n | \"maxRetries\"\n | \"retryDelayMs\"\n >\n > &\n RedisSagaStoreOptions;\n private readonly ownsRedis: boolean;\n\n constructor(options: RedisSagaStoreOptions) {\n if (!options.redis && !options.connection) {\n throw new Error(\"Either redis or connection must be provided\");\n }\n\n this.options = {\n keyPrefix: \"saga-bus:\",\n completedTtlSeconds: 0,\n defaultTtlSeconds: 0,\n maxRetries: 3,\n retryDelayMs: 100,\n ...options,\n };\n\n this.ownsRedis = !options.redis;\n }\n\n async initialize(): Promise<void> {\n if (this.options.redis) {\n this.redis = this.options.redis;\n } else {\n this.redis = new Redis(this.options.connection!);\n }\n }\n\n async close(): Promise<void> {\n if (this.ownsRedis && this.redis) {\n await this.redis.quit();\n }\n this.redis = null;\n }\n\n async getById(sagaName: string, sagaId: string): Promise<TState | null> {\n if (!this.redis) throw new Error(\"Store not initialized\");\n\n // Lookup correlation ID from index\n const indexKey = this.buildIdIndexKey(sagaName, sagaId);\n const correlationId = await this.redis.get(indexKey);\n\n if (!correlationId) return null;\n\n return this.getByCorrelationId(sagaName, correlationId);\n }\n\n async getByCorrelationId(\n sagaName: string,\n correlationId: string\n ): Promise<TState | null> {\n if (!this.redis) throw new Error(\"Store not initialized\");\n\n const key = this.buildKey(sagaName, correlationId);\n const data = await this.redis.get(key);\n\n if (!data) return null;\n\n return this.deserializeState(data);\n }\n\n async insert(\n sagaName: string,\n correlationId: string,\n state: TState\n ): Promise<void> {\n if (!this.redis) throw new Error(\"Store not initialized\");\n\n const key = this.buildKey(sagaName, correlationId);\n const indexKey = this.buildIdIndexKey(sagaName, state.metadata.sagaId);\n\n // Check if saga already exists\n const existing = await this.redis.get(key);\n if (existing) {\n throw new Error(\n `Saga ${sagaName} with correlation ID ${correlationId} already exists`\n );\n }\n\n // Serialize and save\n const serialized = this.serializeState(state);\n\n // Determine TTL\n let ttl = this.options.defaultTtlSeconds;\n if (state.metadata.isCompleted && this.options.completedTtlSeconds > 0) {\n ttl = this.options.completedTtlSeconds;\n }\n\n const multi = this.redis.multi();\n\n if (ttl > 0) {\n multi.setex(key, ttl, serialized);\n multi.setex(indexKey, ttl, correlationId);\n } else {\n multi.set(key, serialized);\n multi.set(indexKey, correlationId);\n }\n\n await multi.exec();\n }\n\n async update(\n sagaName: string,\n state: TState,\n expectedVersion: number\n ): Promise<void> {\n if (!this.redis) throw new Error(\"Store not initialized\");\n\n // We need to find the correlation ID for this saga\n const indexKey = this.buildIdIndexKey(sagaName, state.metadata.sagaId);\n const correlationId = await this.redis.get(indexKey);\n\n if (!correlationId) {\n throw new Error(`Saga ${state.metadata.sagaId} not found`);\n }\n\n const key = this.buildKey(sagaName, correlationId);\n\n for (let attempt = 0; attempt < this.options.maxRetries; attempt++) {\n // Watch the key for changes\n await this.redis.watch(key);\n\n try {\n // Check current version\n const existing = await this.redis.get(key);\n if (!existing) {\n await this.redis.unwatch();\n throw new Error(`Saga ${state.metadata.sagaId} not found`);\n }\n\n const currentState = this.deserializeState(existing);\n if (currentState.metadata.version !== expectedVersion) {\n await this.redis.unwatch();\n throw new ConcurrencyError(\n state.metadata.sagaId,\n expectedVersion,\n currentState.metadata.version\n );\n }\n\n // Start transaction\n const multi = this.redis.multi();\n\n // Serialize and save\n const serialized = this.serializeState(state);\n\n // Determine TTL\n let ttl = this.options.defaultTtlSeconds;\n if (state.metadata.isCompleted && this.options.completedTtlSeconds > 0) {\n ttl = this.options.completedTtlSeconds;\n }\n\n if (ttl > 0) {\n multi.setex(key, ttl, serialized);\n multi.setex(indexKey, ttl, correlationId);\n } else {\n multi.set(key, serialized);\n multi.set(indexKey, correlationId);\n }\n\n // Execute transaction\n const result = await multi.exec();\n\n if (result === null) {\n // Transaction aborted due to WATCH - retry\n if (attempt < this.options.maxRetries - 1) {\n await this.delay(this.options.retryDelayMs);\n continue;\n }\n throw new ConcurrencyError(\n state.metadata.sagaId,\n expectedVersion,\n -1 // Unknown current version due to race\n );\n }\n\n return; // Success\n } catch (error) {\n await this.redis.unwatch();\n throw error;\n }\n }\n }\n\n async delete(sagaName: string, sagaId: string): Promise<void> {\n if (!this.redis) throw new Error(\"Store not initialized\");\n\n // Lookup correlation ID from index\n const indexKey = this.buildIdIndexKey(sagaName, sagaId);\n const correlationId = await this.redis.get(indexKey);\n\n if (!correlationId) return;\n\n const key = this.buildKey(sagaName, correlationId);\n await this.redis.del(key, indexKey);\n }\n\n private buildKey(sagaName: string, correlationId: string): string {\n return `${this.options.keyPrefix}saga:${sagaName}:${correlationId}`;\n }\n\n private buildIdIndexKey(sagaName: string, sagaId: string): string {\n return `${this.options.keyPrefix}saga:${sagaName}:idx:id:${sagaId}`;\n }\n\n private serializeState(state: TState): string {\n return JSON.stringify({\n ...state,\n metadata: {\n ...state.metadata,\n createdAt: state.metadata.createdAt.toISOString(),\n updatedAt: state.metadata.updatedAt.toISOString(),\n archivedAt: state.metadata.archivedAt?.toISOString() ?? null,\n timeoutExpiresAt: state.metadata.timeoutExpiresAt?.toISOString() ?? null,\n },\n });\n }\n\n private deserializeState(data: string): TState {\n const parsed = JSON.parse(data);\n return {\n ...parsed,\n metadata: {\n ...parsed.metadata,\n createdAt: new Date(parsed.metadata.createdAt),\n updatedAt: new Date(parsed.metadata.updatedAt),\n archivedAt: parsed.metadata.archivedAt\n ? new Date(parsed.metadata.archivedAt)\n : null,\n timeoutExpiresAt: parsed.metadata.timeoutExpiresAt\n ? new Date(parsed.metadata.timeoutExpiresAt)\n : null,\n },\n } as TState;\n }\n\n private delay(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n }\n\n // ============ Query Helpers ============\n\n /**\n * Get the underlying Redis client for advanced operations.\n */\n getRedis(): Redis | null {\n return this.redis;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,qBAAsB;AAEtB,kBAAiC;AAoB1B,IAAM,iBAAN,MAEP;AAAA,EACU,QAAsB;AAAA,EACb;AAAA,EAWA;AAAA,EAEjB,YAAY,SAAgC;AAC1C,QAAI,CAAC,QAAQ,SAAS,CAAC,QAAQ,YAAY;AACzC,YAAM,IAAI,MAAM,6CAA6C;AAAA,IAC/D;AAEA,SAAK,UAAU;AAAA,MACb,WAAW;AAAA,MACX,qBAAqB;AAAA,MACrB,mBAAmB;AAAA,MACnB,YAAY;AAAA,MACZ,cAAc;AAAA,MACd,GAAG;AAAA,IACL;AAEA,SAAK,YAAY,CAAC,QAAQ;AAAA,EAC5B;AAAA,EAEA,MAAM,aAA4B;AAChC,QAAI,KAAK,QAAQ,OAAO;AACtB,WAAK,QAAQ,KAAK,QAAQ;AAAA,IAC5B,OAAO;AACL,WAAK,QAAQ,IAAI,qBAAM,KAAK,QAAQ,UAAW;AAAA,IACjD;AAAA,EACF;AAAA,EAEA,MAAM,QAAuB;AAC3B,QAAI,KAAK,aAAa,KAAK,OAAO;AAChC,YAAM,KAAK,MAAM,KAAK;AAAA,IACxB;AACA,SAAK,QAAQ;AAAA,EACf;AAAA,EAEA,MAAM,QAAQ,UAAkB,QAAwC;AACtE,QAAI,CAAC,KAAK,MAAO,OAAM,IAAI,MAAM,uBAAuB;AAGxD,UAAM,WAAW,KAAK,gBAAgB,UAAU,MAAM;AACtD,UAAM,gBAAgB,MAAM,KAAK,MAAM,IAAI,QAAQ;AAEnD,QAAI,CAAC,cAAe,QAAO;AAE3B,WAAO,KAAK,mBAAmB,UAAU,aAAa;AAAA,EACxD;AAAA,EAEA,MAAM,mBACJ,UACA,eACwB;AACxB,QAAI,CAAC,KAAK,MAAO,OAAM,IAAI,MAAM,uBAAuB;AAExD,UAAM,MAAM,KAAK,SAAS,UAAU,aAAa;AACjD,UAAM,OAAO,MAAM,KAAK,MAAM,IAAI,GAAG;AAErC,QAAI,CAAC,KAAM,QAAO;AAElB,WAAO,KAAK,iBAAiB,IAAI;AAAA,EACnC;AAAA,EAEA,MAAM,OACJ,UACA,eACA,OACe;AACf,QAAI,CAAC,KAAK,MAAO,OAAM,IAAI,MAAM,uBAAuB;AAExD,UAAM,MAAM,KAAK,SAAS,UAAU,aAAa;AACjD,UAAM,WAAW,KAAK,gBAAgB,UAAU,MAAM,SAAS,MAAM;AAGrE,UAAM,WAAW,MAAM,KAAK,MAAM,IAAI,GAAG;AACzC,QAAI,UAAU;AACZ,YAAM,IAAI;AAAA,QACR,QAAQ,QAAQ,wBAAwB,aAAa;AAAA,MACvD;AAAA,IACF;AAGA,UAAM,aAAa,KAAK,eAAe,KAAK;AAG5C,QAAI,MAAM,KAAK,QAAQ;AACvB,QAAI,MAAM,SAAS,eAAe,KAAK,QAAQ,sBAAsB,GAAG;AACtE,YAAM,KAAK,QAAQ;AAAA,IACrB;AAEA,UAAM,QAAQ,KAAK,MAAM,MAAM;AAE/B,QAAI,MAAM,GAAG;AACX,YAAM,MAAM,KAAK,KAAK,UAAU;AAChC,YAAM,MAAM,UAAU,KAAK,aAAa;AAAA,IAC1C,OAAO;AACL,YAAM,IAAI,KAAK,UAAU;AACzB,YAAM,IAAI,UAAU,aAAa;AAAA,IACnC;AAEA,UAAM,MAAM,KAAK;AAAA,EACnB;AAAA,EAEA,MAAM,OACJ,UACA,OACA,iBACe;AACf,QAAI,CAAC,KAAK,MAAO,OAAM,IAAI,MAAM,uBAAuB;AAGxD,UAAM,WAAW,KAAK,gBAAgB,UAAU,MAAM,SAAS,MAAM;AACrE,UAAM,gBAAgB,MAAM,KAAK,MAAM,IAAI,QAAQ;AAEnD,QAAI,CAAC,eAAe;AAClB,YAAM,IAAI,MAAM,QAAQ,MAAM,SAAS,MAAM,YAAY;AAAA,IAC3D;AAEA,UAAM,MAAM,KAAK,SAAS,UAAU,aAAa;AAEjD,aAAS,UAAU,GAAG,UAAU,KAAK,QAAQ,YAAY,WAAW;AAElE,YAAM,KAAK,MAAM,MAAM,GAAG;AAE1B,UAAI;AAEF,cAAM,WAAW,MAAM,KAAK,MAAM,IAAI,GAAG;AACzC,YAAI,CAAC,UAAU;AACb,gBAAM,KAAK,MAAM,QAAQ;AACzB,gBAAM,IAAI,MAAM,QAAQ,MAAM,SAAS,MAAM,YAAY;AAAA,QAC3D;AAEA,cAAM,eAAe,KAAK,iBAAiB,QAAQ;AACnD,YAAI,aAAa,SAAS,YAAY,iBAAiB;AACrD,gBAAM,KAAK,MAAM,QAAQ;AACzB,gBAAM,IAAI;AAAA,YACR,MAAM,SAAS;AAAA,YACf;AAAA,YACA,aAAa,SAAS;AAAA,UACxB;AAAA,QACF;AAGA,cAAM,QAAQ,KAAK,MAAM,MAAM;AAG/B,cAAM,aAAa,KAAK,eAAe,KAAK;AAG5C,YAAI,MAAM,KAAK,QAAQ;AACvB,YAAI,MAAM,SAAS,eAAe,KAAK,QAAQ,sBAAsB,GAAG;AACtE,gBAAM,KAAK,QAAQ;AAAA,QACrB;AAEA,YAAI,MAAM,GAAG;AACX,gBAAM,MAAM,KAAK,KAAK,UAAU;AAChC,gBAAM,MAAM,UAAU,KAAK,aAAa;AAAA,QAC1C,OAAO;AACL,gBAAM,IAAI,KAAK,UAAU;AACzB,gBAAM,IAAI,UAAU,aAAa;AAAA,QACnC;AAGA,cAAM,SAAS,MAAM,MAAM,KAAK;AAEhC,YAAI,WAAW,MAAM;AAEnB,cAAI,UAAU,KAAK,QAAQ,aAAa,GAAG;AACzC,kBAAM,KAAK,MAAM,KAAK,QAAQ,YAAY;AAC1C;AAAA,UACF;AACA,gBAAM,IAAI;AAAA,YACR,MAAM,SAAS;AAAA,YACf;AAAA,YACA;AAAA;AAAA,UACF;AAAA,QACF;AAEA;AAAA,MACF,SAAS,OAAO;AACd,cAAM,KAAK,MAAM,QAAQ;AACzB,cAAM;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAM,OAAO,UAAkB,QAA+B;AAC5D,QAAI,CAAC,KAAK,MAAO,OAAM,IAAI,MAAM,uBAAuB;AAGxD,UAAM,WAAW,KAAK,gBAAgB,UAAU,MAAM;AACtD,UAAM,gBAAgB,MAAM,KAAK,MAAM,IAAI,QAAQ;AAEnD,QAAI,CAAC,cAAe;AAEpB,UAAM,MAAM,KAAK,SAAS,UAAU,aAAa;AACjD,UAAM,KAAK,MAAM,IAAI,KAAK,QAAQ;AAAA,EACpC;AAAA,EAEQ,SAAS,UAAkB,eAA+B;AAChE,WAAO,GAAG,KAAK,QAAQ,SAAS,QAAQ,QAAQ,IAAI,aAAa;AAAA,EACnE;AAAA,EAEQ,gBAAgB,UAAkB,QAAwB;AAChE,WAAO,GAAG,KAAK,QAAQ,SAAS,QAAQ,QAAQ,WAAW,MAAM;AAAA,EACnE;AAAA,EAEQ,eAAe,OAAuB;AAC5C,WAAO,KAAK,UAAU;AAAA,MACpB,GAAG;AAAA,MACH,UAAU;AAAA,QACR,GAAG,MAAM;AAAA,QACT,WAAW,MAAM,SAAS,UAAU,YAAY;AAAA,QAChD,WAAW,MAAM,SAAS,UAAU,YAAY;AAAA,QAChD,YAAY,MAAM,SAAS,YAAY,YAAY,KAAK;AAAA,QACxD,kBAAkB,MAAM,SAAS,kBAAkB,YAAY,KAAK;AAAA,MACtE;AAAA,IACF,CAAC;AAAA,EACH;AAAA,EAEQ,iBAAiB,MAAsB;AAC7C,UAAM,SAAS,KAAK,MAAM,IAAI;AAC9B,WAAO;AAAA,MACL,GAAG;AAAA,MACH,UAAU;AAAA,QACR,GAAG,OAAO;AAAA,QACV,WAAW,IAAI,KAAK,OAAO,SAAS,SAAS;AAAA,QAC7C,WAAW,IAAI,KAAK,OAAO,SAAS,SAAS;AAAA,QAC7C,YAAY,OAAO,SAAS,aACxB,IAAI,KAAK,OAAO,SAAS,UAAU,IACnC;AAAA,QACJ,kBAAkB,OAAO,SAAS,mBAC9B,IAAI,KAAK,OAAO,SAAS,gBAAgB,IACzC;AAAA,MACN;AAAA,IACF;AAAA,EACF;AAAA,EAEQ,MAAM,IAA2B;AACvC,WAAO,IAAI,QAAQ,CAAC,YAAY,WAAW,SAAS,EAAE,CAAC;AAAA,EACzD;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,WAAyB;AACvB,WAAO,KAAK;AAAA,EACd;AACF;","names":[]}
@@ -0,0 +1,64 @@
1
+ import { Redis, RedisOptions } from 'ioredis';
2
+ import { SagaState, SagaStore } from '@saga-bus/core';
3
+
4
+ /**
5
+ * Configuration options for the Redis saga store.
6
+ */
7
+ interface RedisSagaStoreOptions {
8
+ /** Existing Redis client */
9
+ redis?: Redis;
10
+ /** Connection options for creating new client */
11
+ connection?: RedisOptions;
12
+ /** Key prefix for all saga keys (default: "saga-bus:") */
13
+ keyPrefix?: string;
14
+ /** TTL in seconds for completed sagas (0 = no expiry, default: 0) */
15
+ completedTtlSeconds?: number;
16
+ /** TTL in seconds for all sagas (0 = no expiry, default: 0) */
17
+ defaultTtlSeconds?: number;
18
+ /** Maximum retries for optimistic locking conflicts (default: 3) */
19
+ maxRetries?: number;
20
+ /** Delay between retries in milliseconds (default: 100) */
21
+ retryDelayMs?: number;
22
+ }
23
+
24
+ /**
25
+ * Redis-backed saga store for saga-bus.
26
+ *
27
+ * Uses WATCH/MULTI for optimistic concurrency control.
28
+ *
29
+ * @example
30
+ * ```typescript
31
+ * import { RedisSagaStore } from "@saga-bus/store-redis";
32
+ *
33
+ * const store = new RedisSagaStore<OrderState>({
34
+ * connection: { host: "localhost", port: 6379 },
35
+ * completedTtlSeconds: 86400, // 1 day
36
+ * });
37
+ *
38
+ * await store.initialize();
39
+ * ```
40
+ */
41
+ declare class RedisSagaStore<TState extends SagaState> implements SagaStore<TState> {
42
+ private redis;
43
+ private readonly options;
44
+ private readonly ownsRedis;
45
+ constructor(options: RedisSagaStoreOptions);
46
+ initialize(): Promise<void>;
47
+ close(): Promise<void>;
48
+ getById(sagaName: string, sagaId: string): Promise<TState | null>;
49
+ getByCorrelationId(sagaName: string, correlationId: string): Promise<TState | null>;
50
+ insert(sagaName: string, correlationId: string, state: TState): Promise<void>;
51
+ update(sagaName: string, state: TState, expectedVersion: number): Promise<void>;
52
+ delete(sagaName: string, sagaId: string): Promise<void>;
53
+ private buildKey;
54
+ private buildIdIndexKey;
55
+ private serializeState;
56
+ private deserializeState;
57
+ private delay;
58
+ /**
59
+ * Get the underlying Redis client for advanced operations.
60
+ */
61
+ getRedis(): Redis | null;
62
+ }
63
+
64
+ export { RedisSagaStore, type RedisSagaStoreOptions };
@@ -0,0 +1,64 @@
1
+ import { Redis, RedisOptions } from 'ioredis';
2
+ import { SagaState, SagaStore } from '@saga-bus/core';
3
+
4
+ /**
5
+ * Configuration options for the Redis saga store.
6
+ */
7
+ interface RedisSagaStoreOptions {
8
+ /** Existing Redis client */
9
+ redis?: Redis;
10
+ /** Connection options for creating new client */
11
+ connection?: RedisOptions;
12
+ /** Key prefix for all saga keys (default: "saga-bus:") */
13
+ keyPrefix?: string;
14
+ /** TTL in seconds for completed sagas (0 = no expiry, default: 0) */
15
+ completedTtlSeconds?: number;
16
+ /** TTL in seconds for all sagas (0 = no expiry, default: 0) */
17
+ defaultTtlSeconds?: number;
18
+ /** Maximum retries for optimistic locking conflicts (default: 3) */
19
+ maxRetries?: number;
20
+ /** Delay between retries in milliseconds (default: 100) */
21
+ retryDelayMs?: number;
22
+ }
23
+
24
+ /**
25
+ * Redis-backed saga store for saga-bus.
26
+ *
27
+ * Uses WATCH/MULTI for optimistic concurrency control.
28
+ *
29
+ * @example
30
+ * ```typescript
31
+ * import { RedisSagaStore } from "@saga-bus/store-redis";
32
+ *
33
+ * const store = new RedisSagaStore<OrderState>({
34
+ * connection: { host: "localhost", port: 6379 },
35
+ * completedTtlSeconds: 86400, // 1 day
36
+ * });
37
+ *
38
+ * await store.initialize();
39
+ * ```
40
+ */
41
+ declare class RedisSagaStore<TState extends SagaState> implements SagaStore<TState> {
42
+ private redis;
43
+ private readonly options;
44
+ private readonly ownsRedis;
45
+ constructor(options: RedisSagaStoreOptions);
46
+ initialize(): Promise<void>;
47
+ close(): Promise<void>;
48
+ getById(sagaName: string, sagaId: string): Promise<TState | null>;
49
+ getByCorrelationId(sagaName: string, correlationId: string): Promise<TState | null>;
50
+ insert(sagaName: string, correlationId: string, state: TState): Promise<void>;
51
+ update(sagaName: string, state: TState, expectedVersion: number): Promise<void>;
52
+ delete(sagaName: string, sagaId: string): Promise<void>;
53
+ private buildKey;
54
+ private buildIdIndexKey;
55
+ private serializeState;
56
+ private deserializeState;
57
+ private delay;
58
+ /**
59
+ * Get the underlying Redis client for advanced operations.
60
+ */
61
+ getRedis(): Redis | null;
62
+ }
63
+
64
+ export { RedisSagaStore, type RedisSagaStoreOptions };
package/dist/index.js ADDED
@@ -0,0 +1,185 @@
1
+ // src/RedisSagaStore.ts
2
+ import { Redis } from "ioredis";
3
+ import { ConcurrencyError } from "@saga-bus/core";
4
+ var RedisSagaStore = class {
5
+ redis = null;
6
+ options;
7
+ ownsRedis;
8
+ constructor(options) {
9
+ if (!options.redis && !options.connection) {
10
+ throw new Error("Either redis or connection must be provided");
11
+ }
12
+ this.options = {
13
+ keyPrefix: "saga-bus:",
14
+ completedTtlSeconds: 0,
15
+ defaultTtlSeconds: 0,
16
+ maxRetries: 3,
17
+ retryDelayMs: 100,
18
+ ...options
19
+ };
20
+ this.ownsRedis = !options.redis;
21
+ }
22
+ async initialize() {
23
+ if (this.options.redis) {
24
+ this.redis = this.options.redis;
25
+ } else {
26
+ this.redis = new Redis(this.options.connection);
27
+ }
28
+ }
29
+ async close() {
30
+ if (this.ownsRedis && this.redis) {
31
+ await this.redis.quit();
32
+ }
33
+ this.redis = null;
34
+ }
35
+ async getById(sagaName, sagaId) {
36
+ if (!this.redis) throw new Error("Store not initialized");
37
+ const indexKey = this.buildIdIndexKey(sagaName, sagaId);
38
+ const correlationId = await this.redis.get(indexKey);
39
+ if (!correlationId) return null;
40
+ return this.getByCorrelationId(sagaName, correlationId);
41
+ }
42
+ async getByCorrelationId(sagaName, correlationId) {
43
+ if (!this.redis) throw new Error("Store not initialized");
44
+ const key = this.buildKey(sagaName, correlationId);
45
+ const data = await this.redis.get(key);
46
+ if (!data) return null;
47
+ return this.deserializeState(data);
48
+ }
49
+ async insert(sagaName, correlationId, state) {
50
+ if (!this.redis) throw new Error("Store not initialized");
51
+ const key = this.buildKey(sagaName, correlationId);
52
+ const indexKey = this.buildIdIndexKey(sagaName, state.metadata.sagaId);
53
+ const existing = await this.redis.get(key);
54
+ if (existing) {
55
+ throw new Error(
56
+ `Saga ${sagaName} with correlation ID ${correlationId} already exists`
57
+ );
58
+ }
59
+ const serialized = this.serializeState(state);
60
+ let ttl = this.options.defaultTtlSeconds;
61
+ if (state.metadata.isCompleted && this.options.completedTtlSeconds > 0) {
62
+ ttl = this.options.completedTtlSeconds;
63
+ }
64
+ const multi = this.redis.multi();
65
+ if (ttl > 0) {
66
+ multi.setex(key, ttl, serialized);
67
+ multi.setex(indexKey, ttl, correlationId);
68
+ } else {
69
+ multi.set(key, serialized);
70
+ multi.set(indexKey, correlationId);
71
+ }
72
+ await multi.exec();
73
+ }
74
+ async update(sagaName, state, expectedVersion) {
75
+ if (!this.redis) throw new Error("Store not initialized");
76
+ const indexKey = this.buildIdIndexKey(sagaName, state.metadata.sagaId);
77
+ const correlationId = await this.redis.get(indexKey);
78
+ if (!correlationId) {
79
+ throw new Error(`Saga ${state.metadata.sagaId} not found`);
80
+ }
81
+ const key = this.buildKey(sagaName, correlationId);
82
+ for (let attempt = 0; attempt < this.options.maxRetries; attempt++) {
83
+ await this.redis.watch(key);
84
+ try {
85
+ const existing = await this.redis.get(key);
86
+ if (!existing) {
87
+ await this.redis.unwatch();
88
+ throw new Error(`Saga ${state.metadata.sagaId} not found`);
89
+ }
90
+ const currentState = this.deserializeState(existing);
91
+ if (currentState.metadata.version !== expectedVersion) {
92
+ await this.redis.unwatch();
93
+ throw new ConcurrencyError(
94
+ state.metadata.sagaId,
95
+ expectedVersion,
96
+ currentState.metadata.version
97
+ );
98
+ }
99
+ const multi = this.redis.multi();
100
+ const serialized = this.serializeState(state);
101
+ let ttl = this.options.defaultTtlSeconds;
102
+ if (state.metadata.isCompleted && this.options.completedTtlSeconds > 0) {
103
+ ttl = this.options.completedTtlSeconds;
104
+ }
105
+ if (ttl > 0) {
106
+ multi.setex(key, ttl, serialized);
107
+ multi.setex(indexKey, ttl, correlationId);
108
+ } else {
109
+ multi.set(key, serialized);
110
+ multi.set(indexKey, correlationId);
111
+ }
112
+ const result = await multi.exec();
113
+ if (result === null) {
114
+ if (attempt < this.options.maxRetries - 1) {
115
+ await this.delay(this.options.retryDelayMs);
116
+ continue;
117
+ }
118
+ throw new ConcurrencyError(
119
+ state.metadata.sagaId,
120
+ expectedVersion,
121
+ -1
122
+ // Unknown current version due to race
123
+ );
124
+ }
125
+ return;
126
+ } catch (error) {
127
+ await this.redis.unwatch();
128
+ throw error;
129
+ }
130
+ }
131
+ }
132
+ async delete(sagaName, sagaId) {
133
+ if (!this.redis) throw new Error("Store not initialized");
134
+ const indexKey = this.buildIdIndexKey(sagaName, sagaId);
135
+ const correlationId = await this.redis.get(indexKey);
136
+ if (!correlationId) return;
137
+ const key = this.buildKey(sagaName, correlationId);
138
+ await this.redis.del(key, indexKey);
139
+ }
140
+ buildKey(sagaName, correlationId) {
141
+ return `${this.options.keyPrefix}saga:${sagaName}:${correlationId}`;
142
+ }
143
+ buildIdIndexKey(sagaName, sagaId) {
144
+ return `${this.options.keyPrefix}saga:${sagaName}:idx:id:${sagaId}`;
145
+ }
146
+ serializeState(state) {
147
+ return JSON.stringify({
148
+ ...state,
149
+ metadata: {
150
+ ...state.metadata,
151
+ createdAt: state.metadata.createdAt.toISOString(),
152
+ updatedAt: state.metadata.updatedAt.toISOString(),
153
+ archivedAt: state.metadata.archivedAt?.toISOString() ?? null,
154
+ timeoutExpiresAt: state.metadata.timeoutExpiresAt?.toISOString() ?? null
155
+ }
156
+ });
157
+ }
158
+ deserializeState(data) {
159
+ const parsed = JSON.parse(data);
160
+ return {
161
+ ...parsed,
162
+ metadata: {
163
+ ...parsed.metadata,
164
+ createdAt: new Date(parsed.metadata.createdAt),
165
+ updatedAt: new Date(parsed.metadata.updatedAt),
166
+ archivedAt: parsed.metadata.archivedAt ? new Date(parsed.metadata.archivedAt) : null,
167
+ timeoutExpiresAt: parsed.metadata.timeoutExpiresAt ? new Date(parsed.metadata.timeoutExpiresAt) : null
168
+ }
169
+ };
170
+ }
171
+ delay(ms) {
172
+ return new Promise((resolve) => setTimeout(resolve, ms));
173
+ }
174
+ // ============ Query Helpers ============
175
+ /**
176
+ * Get the underlying Redis client for advanced operations.
177
+ */
178
+ getRedis() {
179
+ return this.redis;
180
+ }
181
+ };
182
+ export {
183
+ RedisSagaStore
184
+ };
185
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/RedisSagaStore.ts"],"sourcesContent":["import { Redis } from \"ioredis\";\nimport type { SagaStore, SagaState } from \"@saga-bus/core\";\nimport { ConcurrencyError } from \"@saga-bus/core\";\nimport type { RedisSagaStoreOptions } from \"./types.js\";\n\n/**\n * Redis-backed saga store for saga-bus.\n *\n * Uses WATCH/MULTI for optimistic concurrency control.\n *\n * @example\n * ```typescript\n * import { RedisSagaStore } from \"@saga-bus/store-redis\";\n *\n * const store = new RedisSagaStore<OrderState>({\n * connection: { host: \"localhost\", port: 6379 },\n * completedTtlSeconds: 86400, // 1 day\n * });\n *\n * await store.initialize();\n * ```\n */\nexport class RedisSagaStore<TState extends SagaState>\n implements SagaStore<TState>\n{\n private redis: Redis | null = null;\n private readonly options: Required<\n Pick<\n RedisSagaStoreOptions,\n | \"keyPrefix\"\n | \"completedTtlSeconds\"\n | \"defaultTtlSeconds\"\n | \"maxRetries\"\n | \"retryDelayMs\"\n >\n > &\n RedisSagaStoreOptions;\n private readonly ownsRedis: boolean;\n\n constructor(options: RedisSagaStoreOptions) {\n if (!options.redis && !options.connection) {\n throw new Error(\"Either redis or connection must be provided\");\n }\n\n this.options = {\n keyPrefix: \"saga-bus:\",\n completedTtlSeconds: 0,\n defaultTtlSeconds: 0,\n maxRetries: 3,\n retryDelayMs: 100,\n ...options,\n };\n\n this.ownsRedis = !options.redis;\n }\n\n async initialize(): Promise<void> {\n if (this.options.redis) {\n this.redis = this.options.redis;\n } else {\n this.redis = new Redis(this.options.connection!);\n }\n }\n\n async close(): Promise<void> {\n if (this.ownsRedis && this.redis) {\n await this.redis.quit();\n }\n this.redis = null;\n }\n\n async getById(sagaName: string, sagaId: string): Promise<TState | null> {\n if (!this.redis) throw new Error(\"Store not initialized\");\n\n // Lookup correlation ID from index\n const indexKey = this.buildIdIndexKey(sagaName, sagaId);\n const correlationId = await this.redis.get(indexKey);\n\n if (!correlationId) return null;\n\n return this.getByCorrelationId(sagaName, correlationId);\n }\n\n async getByCorrelationId(\n sagaName: string,\n correlationId: string\n ): Promise<TState | null> {\n if (!this.redis) throw new Error(\"Store not initialized\");\n\n const key = this.buildKey(sagaName, correlationId);\n const data = await this.redis.get(key);\n\n if (!data) return null;\n\n return this.deserializeState(data);\n }\n\n async insert(\n sagaName: string,\n correlationId: string,\n state: TState\n ): Promise<void> {\n if (!this.redis) throw new Error(\"Store not initialized\");\n\n const key = this.buildKey(sagaName, correlationId);\n const indexKey = this.buildIdIndexKey(sagaName, state.metadata.sagaId);\n\n // Check if saga already exists\n const existing = await this.redis.get(key);\n if (existing) {\n throw new Error(\n `Saga ${sagaName} with correlation ID ${correlationId} already exists`\n );\n }\n\n // Serialize and save\n const serialized = this.serializeState(state);\n\n // Determine TTL\n let ttl = this.options.defaultTtlSeconds;\n if (state.metadata.isCompleted && this.options.completedTtlSeconds > 0) {\n ttl = this.options.completedTtlSeconds;\n }\n\n const multi = this.redis.multi();\n\n if (ttl > 0) {\n multi.setex(key, ttl, serialized);\n multi.setex(indexKey, ttl, correlationId);\n } else {\n multi.set(key, serialized);\n multi.set(indexKey, correlationId);\n }\n\n await multi.exec();\n }\n\n async update(\n sagaName: string,\n state: TState,\n expectedVersion: number\n ): Promise<void> {\n if (!this.redis) throw new Error(\"Store not initialized\");\n\n // We need to find the correlation ID for this saga\n const indexKey = this.buildIdIndexKey(sagaName, state.metadata.sagaId);\n const correlationId = await this.redis.get(indexKey);\n\n if (!correlationId) {\n throw new Error(`Saga ${state.metadata.sagaId} not found`);\n }\n\n const key = this.buildKey(sagaName, correlationId);\n\n for (let attempt = 0; attempt < this.options.maxRetries; attempt++) {\n // Watch the key for changes\n await this.redis.watch(key);\n\n try {\n // Check current version\n const existing = await this.redis.get(key);\n if (!existing) {\n await this.redis.unwatch();\n throw new Error(`Saga ${state.metadata.sagaId} not found`);\n }\n\n const currentState = this.deserializeState(existing);\n if (currentState.metadata.version !== expectedVersion) {\n await this.redis.unwatch();\n throw new ConcurrencyError(\n state.metadata.sagaId,\n expectedVersion,\n currentState.metadata.version\n );\n }\n\n // Start transaction\n const multi = this.redis.multi();\n\n // Serialize and save\n const serialized = this.serializeState(state);\n\n // Determine TTL\n let ttl = this.options.defaultTtlSeconds;\n if (state.metadata.isCompleted && this.options.completedTtlSeconds > 0) {\n ttl = this.options.completedTtlSeconds;\n }\n\n if (ttl > 0) {\n multi.setex(key, ttl, serialized);\n multi.setex(indexKey, ttl, correlationId);\n } else {\n multi.set(key, serialized);\n multi.set(indexKey, correlationId);\n }\n\n // Execute transaction\n const result = await multi.exec();\n\n if (result === null) {\n // Transaction aborted due to WATCH - retry\n if (attempt < this.options.maxRetries - 1) {\n await this.delay(this.options.retryDelayMs);\n continue;\n }\n throw new ConcurrencyError(\n state.metadata.sagaId,\n expectedVersion,\n -1 // Unknown current version due to race\n );\n }\n\n return; // Success\n } catch (error) {\n await this.redis.unwatch();\n throw error;\n }\n }\n }\n\n async delete(sagaName: string, sagaId: string): Promise<void> {\n if (!this.redis) throw new Error(\"Store not initialized\");\n\n // Lookup correlation ID from index\n const indexKey = this.buildIdIndexKey(sagaName, sagaId);\n const correlationId = await this.redis.get(indexKey);\n\n if (!correlationId) return;\n\n const key = this.buildKey(sagaName, correlationId);\n await this.redis.del(key, indexKey);\n }\n\n private buildKey(sagaName: string, correlationId: string): string {\n return `${this.options.keyPrefix}saga:${sagaName}:${correlationId}`;\n }\n\n private buildIdIndexKey(sagaName: string, sagaId: string): string {\n return `${this.options.keyPrefix}saga:${sagaName}:idx:id:${sagaId}`;\n }\n\n private serializeState(state: TState): string {\n return JSON.stringify({\n ...state,\n metadata: {\n ...state.metadata,\n createdAt: state.metadata.createdAt.toISOString(),\n updatedAt: state.metadata.updatedAt.toISOString(),\n archivedAt: state.metadata.archivedAt?.toISOString() ?? null,\n timeoutExpiresAt: state.metadata.timeoutExpiresAt?.toISOString() ?? null,\n },\n });\n }\n\n private deserializeState(data: string): TState {\n const parsed = JSON.parse(data);\n return {\n ...parsed,\n metadata: {\n ...parsed.metadata,\n createdAt: new Date(parsed.metadata.createdAt),\n updatedAt: new Date(parsed.metadata.updatedAt),\n archivedAt: parsed.metadata.archivedAt\n ? new Date(parsed.metadata.archivedAt)\n : null,\n timeoutExpiresAt: parsed.metadata.timeoutExpiresAt\n ? new Date(parsed.metadata.timeoutExpiresAt)\n : null,\n },\n } as TState;\n }\n\n private delay(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n }\n\n // ============ Query Helpers ============\n\n /**\n * Get the underlying Redis client for advanced operations.\n */\n getRedis(): Redis | null {\n return this.redis;\n }\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@saga-bus/store-redis",
3
+ "version": "0.1.0",
4
+ "description": "Redis saga store for saga-bus",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "dependencies": {
21
+ "@saga-bus/core": "0.1.0"
22
+ },
23
+ "peerDependencies": {
24
+ "ioredis": ">=5.0.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^22.10.1",
28
+ "eslint": "^9.16.0",
29
+ "tsup": "^8.3.5",
30
+ "typescript": "^5.7.2",
31
+ "vitest": "^2.1.8",
32
+ "@repo/eslint-config": "0.0.0",
33
+ "@repo/typescript-config": "0.0.0"
34
+ },
35
+ "scripts": {
36
+ "build": "tsup",
37
+ "dev": "tsup --watch",
38
+ "lint": "eslint src/",
39
+ "check-types": "tsc --noEmit",
40
+ "test": "vitest run",
41
+ "test:watch": "vitest"
42
+ }
43
+ }