@saga-bus/middleware-idempotency 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,282 @@
1
+ # @saga-bus/middleware-idempotency
2
+
3
+ Idempotency middleware for saga-bus that prevents duplicate message processing.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @saga-bus/middleware-idempotency
9
+ # or
10
+ pnpm add @saga-bus/middleware-idempotency
11
+ ```
12
+
13
+ For Redis support:
14
+
15
+ ```bash
16
+ npm install ioredis
17
+ ```
18
+
19
+ ## Features
20
+
21
+ - **Message Deduplication**: Prevent duplicate message processing within a configurable time window
22
+ - **Multiple Storage Backends**: In-memory (development) and Redis (production)
23
+ - **Flexible ID Extraction**: Custom message ID extraction strategies
24
+ - **Configurable Behavior**: Skip, log, or throw on duplicates
25
+ - **Delivery Guarantees**: Choose between at-most-once or at-least-once semantics
26
+
27
+ ## Quick Start
28
+
29
+ ```typescript
30
+ import { createBus } from "@saga-bus/core";
31
+ import {
32
+ createIdempotencyMiddleware,
33
+ InMemoryIdempotencyStore,
34
+ } from "@saga-bus/middleware-idempotency";
35
+
36
+ const idempotencyMiddleware = createIdempotencyMiddleware({
37
+ store: new InMemoryIdempotencyStore(),
38
+ windowMs: 60000, // 1 minute deduplication window
39
+ });
40
+
41
+ const bus = createBus({
42
+ transport,
43
+ store,
44
+ sagas: [OrderSaga],
45
+ middleware: [idempotencyMiddleware],
46
+ });
47
+ ```
48
+
49
+ ## API Reference
50
+
51
+ ### createIdempotencyMiddleware(options)
52
+
53
+ Creates middleware that prevents duplicate message processing.
54
+
55
+ ```typescript
56
+ interface IdempotencyMiddlewareOptions {
57
+ /** Store for tracking processed message IDs */
58
+ store: IdempotencyStore;
59
+
60
+ /** Time window for deduplication in milliseconds (default: 60000) */
61
+ windowMs?: number;
62
+
63
+ /** Function to extract message ID from envelope (default: envelope.id) */
64
+ getMessageId?: (envelope: MessageEnvelope) => string;
65
+
66
+ /** Action on duplicate: "skip" | "log" | "throw" (default: "skip") */
67
+ onDuplicate?: "skip" | "log" | "throw";
68
+
69
+ /** Logger for duplicate detection messages */
70
+ logger?: { warn(message: string, meta?: Record<string, unknown>): void };
71
+
72
+ /** Message types to exclude from idempotency checks */
73
+ excludeTypes?: string[];
74
+
75
+ /** When to mark message as processed: "before" | "after" (default: "after") */
76
+ markTiming?: "before" | "after";
77
+ }
78
+ ```
79
+
80
+ ### InMemoryIdempotencyStore
81
+
82
+ In-memory store for development and testing. Not suitable for distributed systems.
83
+
84
+ ```typescript
85
+ import { InMemoryIdempotencyStore } from "@saga-bus/middleware-idempotency";
86
+
87
+ const store = new InMemoryIdempotencyStore();
88
+
89
+ // Optional: specify cleanup interval (default: 60000ms)
90
+ const store = new InMemoryIdempotencyStore(30000);
91
+
92
+ // Stop cleanup interval when done
93
+ store.stop();
94
+ ```
95
+
96
+ ### RedisIdempotencyStore
97
+
98
+ Redis-backed store for production distributed systems.
99
+
100
+ ```typescript
101
+ import Redis from "ioredis";
102
+ import { RedisIdempotencyStore } from "@saga-bus/middleware-idempotency";
103
+
104
+ const redis = new Redis();
105
+
106
+ const store = new RedisIdempotencyStore({
107
+ redis,
108
+ keyPrefix: "idempotency:", // default
109
+ });
110
+ ```
111
+
112
+ ### DuplicateMessageError
113
+
114
+ Error thrown when `onDuplicate: "throw"` and a duplicate is detected.
115
+
116
+ ```typescript
117
+ import { DuplicateMessageError } from "@saga-bus/middleware-idempotency";
118
+
119
+ try {
120
+ await bus.publish(message);
121
+ } catch (error) {
122
+ if (error instanceof DuplicateMessageError) {
123
+ console.log(`Duplicate: ${error.messageId} (${error.messageType})`);
124
+ }
125
+ }
126
+ ```
127
+
128
+ ## Examples
129
+
130
+ ### Basic Usage
131
+
132
+ ```typescript
133
+ import {
134
+ createIdempotencyMiddleware,
135
+ InMemoryIdempotencyStore,
136
+ } from "@saga-bus/middleware-idempotency";
137
+
138
+ const middleware = createIdempotencyMiddleware({
139
+ store: new InMemoryIdempotencyStore(),
140
+ windowMs: 300000, // 5 minutes
141
+ });
142
+ ```
143
+
144
+ ### With Redis (Production)
145
+
146
+ ```typescript
147
+ import Redis from "ioredis";
148
+ import {
149
+ createIdempotencyMiddleware,
150
+ RedisIdempotencyStore,
151
+ } from "@saga-bus/middleware-idempotency";
152
+
153
+ const redis = new Redis(process.env.REDIS_URL);
154
+
155
+ const middleware = createIdempotencyMiddleware({
156
+ store: new RedisIdempotencyStore({ redis }),
157
+ windowMs: 300000,
158
+ });
159
+ ```
160
+
161
+ ### Custom Message ID Extraction
162
+
163
+ ```typescript
164
+ // Use a combination of type and correlation ID for deduplication
165
+ const middleware = createIdempotencyMiddleware({
166
+ store,
167
+ getMessageId: (envelope) =>
168
+ `${envelope.type}:${envelope.headers["x-correlation-id"]}`,
169
+ });
170
+ ```
171
+
172
+ ### Logging Duplicates
173
+
174
+ ```typescript
175
+ import { logger } from "./logger";
176
+
177
+ const middleware = createIdempotencyMiddleware({
178
+ store,
179
+ onDuplicate: "log",
180
+ logger: {
181
+ warn: (message, meta) => logger.warn(message, meta),
182
+ },
183
+ });
184
+ ```
185
+
186
+ ### Throwing on Duplicates
187
+
188
+ ```typescript
189
+ import { createIdempotencyMiddleware, DuplicateMessageError } from "@saga-bus/middleware-idempotency";
190
+
191
+ const middleware = createIdempotencyMiddleware({
192
+ store,
193
+ onDuplicate: "throw",
194
+ });
195
+
196
+ // In your error handler
197
+ app.onError((error, c) => {
198
+ if (error instanceof DuplicateMessageError) {
199
+ return c.json({ error: "Duplicate request" }, 409);
200
+ }
201
+ throw error;
202
+ });
203
+ ```
204
+
205
+ ### Excluding Message Types
206
+
207
+ ```typescript
208
+ // Don't deduplicate heartbeat or ping messages
209
+ const middleware = createIdempotencyMiddleware({
210
+ store,
211
+ excludeTypes: ["Heartbeat", "Ping", "HealthCheck"],
212
+ });
213
+ ```
214
+
215
+ ### At-Most-Once vs At-Least-Once
216
+
217
+ ```typescript
218
+ // At-most-once: Mark before processing
219
+ // If processing fails, message won't be retried
220
+ const atMostOnce = createIdempotencyMiddleware({
221
+ store,
222
+ markTiming: "before",
223
+ });
224
+
225
+ // At-least-once: Mark after processing (default)
226
+ // If processing fails, message can be retried
227
+ const atLeastOnce = createIdempotencyMiddleware({
228
+ store,
229
+ markTiming: "after",
230
+ });
231
+ ```
232
+
233
+ ## Custom Store Implementation
234
+
235
+ Implement the `IdempotencyStore` interface for custom storage:
236
+
237
+ ```typescript
238
+ import type { IdempotencyStore } from "@saga-bus/middleware-idempotency";
239
+
240
+ class MyCustomStore implements IdempotencyStore {
241
+ async has(messageId: string): Promise<boolean> {
242
+ // Check if messageId exists
243
+ }
244
+
245
+ async set(messageId: string, ttlMs?: number): Promise<void> {
246
+ // Store messageId with optional TTL
247
+ }
248
+
249
+ async delete(messageId: string): Promise<void> {
250
+ // Remove messageId
251
+ }
252
+
253
+ async clear(): Promise<void> {
254
+ // Clear all entries
255
+ }
256
+ }
257
+ ```
258
+
259
+ ## How It Works
260
+
261
+ 1. When a message arrives, the middleware extracts its ID
262
+ 2. It checks if the ID exists in the store
263
+ 3. If found (duplicate):
264
+ - `skip`: Silently skip processing
265
+ - `log`: Log warning and skip
266
+ - `throw`: Throw `DuplicateMessageError`
267
+ 4. If not found (new message):
268
+ - With `markTiming: "before"`: Mark as processed, then run handler
269
+ - With `markTiming: "after"`: Run handler, then mark as processed
270
+ 5. The ID expires after `windowMs` milliseconds
271
+
272
+ ## Best Practices
273
+
274
+ 1. **Use Redis in production** for distributed systems with multiple instances
275
+ 2. **Set appropriate window sizes** based on your retry policies
276
+ 3. **Use `markTiming: "after"`** (default) for at-least-once delivery with retries
277
+ 4. **Use `markTiming: "before"`** for at-most-once delivery when idempotency is critical
278
+ 5. **Exclude naturally idempotent messages** like heartbeats and health checks
279
+
280
+ ## License
281
+
282
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,198 @@
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
+ DuplicateMessageError: () => DuplicateMessageError,
24
+ InMemoryIdempotencyStore: () => InMemoryIdempotencyStore,
25
+ RedisIdempotencyStore: () => RedisIdempotencyStore,
26
+ createIdempotencyMiddleware: () => createIdempotencyMiddleware
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+
30
+ // src/types.ts
31
+ var DuplicateMessageError = class extends Error {
32
+ messageId;
33
+ messageType;
34
+ constructor(messageId, messageType) {
35
+ super(`Duplicate message detected: ${messageId} (type: ${messageType})`);
36
+ this.name = "DuplicateMessageError";
37
+ this.messageId = messageId;
38
+ this.messageType = messageType;
39
+ }
40
+ };
41
+
42
+ // src/IdempotencyMiddleware.ts
43
+ var defaultGetMessageId = (envelope) => envelope.id;
44
+ function createIdempotencyMiddleware(options) {
45
+ const {
46
+ store,
47
+ windowMs = 6e4,
48
+ getMessageId = defaultGetMessageId,
49
+ onDuplicate = "skip",
50
+ logger,
51
+ excludeTypes = [],
52
+ markTiming = "after"
53
+ } = options;
54
+ const excludeSet = new Set(excludeTypes);
55
+ return async (ctx, next) => {
56
+ const { envelope } = ctx;
57
+ if (excludeSet.has(envelope.type)) {
58
+ await next();
59
+ return;
60
+ }
61
+ const messageId = getMessageId(envelope);
62
+ const isDuplicate = await store.has(messageId);
63
+ if (isDuplicate) {
64
+ switch (onDuplicate) {
65
+ case "throw":
66
+ throw new DuplicateMessageError(messageId, envelope.type);
67
+ case "log":
68
+ logger?.warn("Duplicate message detected, skipping", {
69
+ messageId,
70
+ messageType: envelope.type,
71
+ correlationId: ctx.correlationId,
72
+ sagaName: ctx.sagaName
73
+ });
74
+ // Fall through to skip
75
+ case "skip":
76
+ default:
77
+ return;
78
+ }
79
+ }
80
+ if (markTiming === "before") {
81
+ await store.set(messageId, windowMs);
82
+ }
83
+ await next();
84
+ if (markTiming === "after") {
85
+ await store.set(messageId, windowMs);
86
+ }
87
+ };
88
+ }
89
+
90
+ // src/stores/InMemoryIdempotencyStore.ts
91
+ var InMemoryIdempotencyStore = class {
92
+ store = /* @__PURE__ */ new Map();
93
+ cleanupInterval = null;
94
+ /**
95
+ * Create an in-memory idempotency store.
96
+ * @param cleanupIntervalMs - How often to clean up expired entries (default: 60000ms)
97
+ */
98
+ constructor(cleanupIntervalMs = 6e4) {
99
+ if (cleanupIntervalMs > 0) {
100
+ this.cleanupInterval = setInterval(() => {
101
+ this.cleanup();
102
+ }, cleanupIntervalMs);
103
+ this.cleanupInterval.unref?.();
104
+ }
105
+ }
106
+ async has(messageId) {
107
+ const entry = this.store.get(messageId);
108
+ if (!entry) {
109
+ return false;
110
+ }
111
+ if (entry.expiresAt !== null && Date.now() > entry.expiresAt) {
112
+ this.store.delete(messageId);
113
+ return false;
114
+ }
115
+ return true;
116
+ }
117
+ async set(messageId, ttlMs) {
118
+ const expiresAt = ttlMs != null ? Date.now() + ttlMs : null;
119
+ this.store.set(messageId, { expiresAt });
120
+ }
121
+ async delete(messageId) {
122
+ this.store.delete(messageId);
123
+ }
124
+ async clear() {
125
+ this.store.clear();
126
+ }
127
+ /**
128
+ * Get the number of entries in the store (for testing).
129
+ */
130
+ get size() {
131
+ return this.store.size;
132
+ }
133
+ /**
134
+ * Stop the cleanup interval.
135
+ */
136
+ stop() {
137
+ if (this.cleanupInterval) {
138
+ clearInterval(this.cleanupInterval);
139
+ this.cleanupInterval = null;
140
+ }
141
+ }
142
+ /**
143
+ * Remove expired entries.
144
+ */
145
+ cleanup() {
146
+ const now = Date.now();
147
+ for (const [key, entry] of this.store) {
148
+ if (entry.expiresAt !== null && now > entry.expiresAt) {
149
+ this.store.delete(key);
150
+ }
151
+ }
152
+ }
153
+ };
154
+
155
+ // src/stores/RedisIdempotencyStore.ts
156
+ var RedisIdempotencyStore = class {
157
+ redis;
158
+ keyPrefix;
159
+ constructor(options) {
160
+ this.redis = options.redis;
161
+ this.keyPrefix = options.keyPrefix ?? "idempotency:";
162
+ }
163
+ key(messageId) {
164
+ return `${this.keyPrefix}${messageId}`;
165
+ }
166
+ async has(messageId) {
167
+ const result = await this.redis.get(this.key(messageId));
168
+ return result !== null;
169
+ }
170
+ async set(messageId, ttlMs) {
171
+ const key = this.key(messageId);
172
+ if (ttlMs != null) {
173
+ const ttlSeconds = Math.ceil(ttlMs / 1e3);
174
+ await this.redis.setex(key, ttlSeconds, "1");
175
+ } else {
176
+ await this.redis.set(key, "1");
177
+ }
178
+ }
179
+ async delete(messageId) {
180
+ await this.redis.del(this.key(messageId));
181
+ }
182
+ async clear() {
183
+ const keys = await this.redis.keys(`${this.keyPrefix}*`);
184
+ if (keys.length > 0) {
185
+ for (const key of keys) {
186
+ await this.redis.del(key);
187
+ }
188
+ }
189
+ }
190
+ };
191
+ // Annotate the CommonJS export names for ESM import in node:
192
+ 0 && (module.exports = {
193
+ DuplicateMessageError,
194
+ InMemoryIdempotencyStore,
195
+ RedisIdempotencyStore,
196
+ createIdempotencyMiddleware
197
+ });
198
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/types.ts","../src/IdempotencyMiddleware.ts","../src/stores/InMemoryIdempotencyStore.ts","../src/stores/RedisIdempotencyStore.ts"],"sourcesContent":["export { createIdempotencyMiddleware } from \"./IdempotencyMiddleware.js\";\nexport { InMemoryIdempotencyStore } from \"./stores/InMemoryIdempotencyStore.js\";\nexport { RedisIdempotencyStore } from \"./stores/RedisIdempotencyStore.js\";\nexport type { RedisIdempotencyStoreOptions } from \"./stores/RedisIdempotencyStore.js\";\nexport {\n DuplicateMessageError,\n type IdempotencyStore,\n type IdempotencyMiddlewareOptions,\n type MessageIdExtractor,\n type DuplicateAction,\n} from \"./types.js\";\n","import type { MessageEnvelope } from \"@saga-bus/core\";\n\n/**\n * Store interface for tracking processed message IDs.\n */\nexport interface IdempotencyStore {\n /**\n * Check if a message ID has been processed.\n * @param messageId - The message ID to check\n * @returns true if the message was already processed\n */\n has(messageId: string): Promise<boolean>;\n\n /**\n * Mark a message ID as processed.\n * @param messageId - The message ID to mark\n * @param ttlMs - Time to live in milliseconds (optional)\n */\n set(messageId: string, ttlMs?: number): Promise<void>;\n\n /**\n * Remove a message ID from the store.\n * @param messageId - The message ID to remove\n */\n delete(messageId: string): Promise<void>;\n\n /**\n * Clear all entries (useful for testing).\n */\n clear(): Promise<void>;\n}\n\n/**\n * Function to extract message ID from an envelope.\n */\nexport type MessageIdExtractor = (envelope: MessageEnvelope) => string;\n\n/**\n * Action to take when a duplicate message is detected.\n */\nexport type DuplicateAction = \"skip\" | \"log\" | \"throw\";\n\n/**\n * Options for the idempotency middleware.\n */\nexport interface IdempotencyMiddlewareOptions {\n /**\n * Store for tracking processed message IDs.\n */\n store: IdempotencyStore;\n\n /**\n * Time window for deduplication in milliseconds.\n * Messages with the same ID within this window will be considered duplicates.\n * @default 60000 (1 minute)\n */\n windowMs?: number;\n\n /**\n * Function to extract the message ID from an envelope.\n * Defaults to using envelope.id.\n */\n getMessageId?: MessageIdExtractor;\n\n /**\n * Action to take when a duplicate is detected.\n * - \"skip\": Silently skip processing (default)\n * - \"log\": Skip but log a warning\n * - \"throw\": Throw a DuplicateMessageError\n * @default \"skip\"\n */\n onDuplicate?: DuplicateAction;\n\n /**\n * Custom logger for duplicate detection messages.\n */\n logger?: {\n warn(message: string, meta?: Record<string, unknown>): void;\n };\n\n /**\n * Message types to exclude from idempotency checks.\n * Useful for messages that are naturally idempotent or should always be processed.\n */\n excludeTypes?: string[];\n\n /**\n * Whether to mark message as processed before or after handler execution.\n * - \"before\": Mark before processing (at-most-once delivery)\n * - \"after\": Mark after processing (at-least-once delivery, default)\n * @default \"after\"\n */\n markTiming?: \"before\" | \"after\";\n}\n\n/**\n * Error thrown when a duplicate message is detected and onDuplicate is \"throw\".\n */\nexport class DuplicateMessageError extends Error {\n public readonly messageId: string;\n public readonly messageType: string;\n\n constructor(messageId: string, messageType: string) {\n super(`Duplicate message detected: ${messageId} (type: ${messageType})`);\n this.name = \"DuplicateMessageError\";\n this.messageId = messageId;\n this.messageType = messageType;\n }\n}\n","import type { SagaMiddleware, SagaPipelineContext } from \"@saga-bus/core\";\nimport type { IdempotencyMiddlewareOptions, MessageIdExtractor } from \"./types.js\";\nimport { DuplicateMessageError } from \"./types.js\";\n\n/**\n * Default message ID extractor - uses the envelope ID.\n */\nconst defaultGetMessageId: MessageIdExtractor = (envelope) => envelope.id;\n\n/**\n * Creates idempotency middleware that prevents duplicate message processing.\n *\n * @example\n * ```typescript\n * import { createIdempotencyMiddleware, InMemoryIdempotencyStore } from \"@saga-bus/middleware-idempotency\";\n *\n * const idempotencyMiddleware = createIdempotencyMiddleware({\n * store: new InMemoryIdempotencyStore(),\n * windowMs: 60000, // 1 minute deduplication window\n * });\n *\n * const bus = createBus({\n * transport,\n * store,\n * sagas: [MySaga],\n * middleware: [idempotencyMiddleware],\n * });\n * ```\n */\nexport function createIdempotencyMiddleware(\n options: IdempotencyMiddlewareOptions\n): SagaMiddleware {\n const {\n store,\n windowMs = 60000,\n getMessageId = defaultGetMessageId,\n onDuplicate = \"skip\",\n logger,\n excludeTypes = [],\n markTiming = \"after\",\n } = options;\n\n const excludeSet = new Set(excludeTypes);\n\n return async (ctx: SagaPipelineContext, next: () => Promise<void>) => {\n const { envelope } = ctx;\n\n // Check if this message type should be excluded from idempotency checks\n if (excludeSet.has(envelope.type)) {\n await next();\n return;\n }\n\n // Extract message ID\n const messageId = getMessageId(envelope);\n\n // Check if message was already processed\n const isDuplicate = await store.has(messageId);\n\n if (isDuplicate) {\n switch (onDuplicate) {\n case \"throw\":\n throw new DuplicateMessageError(messageId, envelope.type);\n\n case \"log\":\n logger?.warn(\"Duplicate message detected, skipping\", {\n messageId,\n messageType: envelope.type,\n correlationId: ctx.correlationId,\n sagaName: ctx.sagaName,\n });\n // Fall through to skip\n\n case \"skip\":\n default:\n // Skip processing - don't call next()\n return;\n }\n }\n\n // Mark as processed before handler (at-most-once)\n if (markTiming === \"before\") {\n await store.set(messageId, windowMs);\n }\n\n // Process the message\n await next();\n\n // Mark as processed after handler (at-least-once, default)\n if (markTiming === \"after\") {\n await store.set(messageId, windowMs);\n }\n };\n}\n","import type { IdempotencyStore } from \"../types.js\";\n\ninterface StoreEntry {\n expiresAt: number | null;\n}\n\n/**\n * In-memory idempotency store for development and testing.\n * Not suitable for distributed systems with multiple instances.\n */\nexport class InMemoryIdempotencyStore implements IdempotencyStore {\n private readonly store = new Map<string, StoreEntry>();\n private cleanupInterval: ReturnType<typeof setInterval> | null = null;\n\n /**\n * Create an in-memory idempotency store.\n * @param cleanupIntervalMs - How often to clean up expired entries (default: 60000ms)\n */\n constructor(cleanupIntervalMs: number = 60000) {\n if (cleanupIntervalMs > 0) {\n this.cleanupInterval = setInterval(() => {\n this.cleanup();\n }, cleanupIntervalMs);\n // Don't keep the process alive just for cleanup\n this.cleanupInterval.unref?.();\n }\n }\n\n async has(messageId: string): Promise<boolean> {\n const entry = this.store.get(messageId);\n if (!entry) {\n return false;\n }\n // Check if expired\n if (entry.expiresAt !== null && Date.now() > entry.expiresAt) {\n this.store.delete(messageId);\n return false;\n }\n return true;\n }\n\n async set(messageId: string, ttlMs?: number): Promise<void> {\n const expiresAt = ttlMs != null ? Date.now() + ttlMs : null;\n this.store.set(messageId, { expiresAt });\n }\n\n async delete(messageId: string): Promise<void> {\n this.store.delete(messageId);\n }\n\n async clear(): Promise<void> {\n this.store.clear();\n }\n\n /**\n * Get the number of entries in the store (for testing).\n */\n get size(): number {\n return this.store.size;\n }\n\n /**\n * Stop the cleanup interval.\n */\n stop(): void {\n if (this.cleanupInterval) {\n clearInterval(this.cleanupInterval);\n this.cleanupInterval = null;\n }\n }\n\n /**\n * Remove expired entries.\n */\n private cleanup(): void {\n const now = Date.now();\n for (const [key, entry] of this.store) {\n if (entry.expiresAt !== null && now > entry.expiresAt) {\n this.store.delete(key);\n }\n }\n }\n}\n","import type { IdempotencyStore } from \"../types.js\";\n\n// Use a type-only import for Redis to make it optional\ntype Redis = {\n get(key: string): Promise<string | null>;\n set(key: string, value: string, mode?: string, duration?: number): Promise<string | null>;\n setex(key: string, seconds: number, value: string): Promise<string>;\n del(key: string): Promise<number>;\n keys(pattern: string): Promise<string[]>;\n};\n\n/**\n * Options for the Redis idempotency store.\n */\nexport interface RedisIdempotencyStoreOptions {\n /**\n * Redis client instance (ioredis).\n */\n redis: Redis;\n\n /**\n * Key prefix for all idempotency keys.\n * @default \"idempotency:\"\n */\n keyPrefix?: string;\n}\n\n/**\n * Redis-backed idempotency store for distributed systems.\n * Requires ioredis as a peer dependency.\n */\nexport class RedisIdempotencyStore implements IdempotencyStore {\n private readonly redis: Redis;\n private readonly keyPrefix: string;\n\n constructor(options: RedisIdempotencyStoreOptions) {\n this.redis = options.redis;\n this.keyPrefix = options.keyPrefix ?? \"idempotency:\";\n }\n\n private key(messageId: string): string {\n return `${this.keyPrefix}${messageId}`;\n }\n\n async has(messageId: string): Promise<boolean> {\n const result = await this.redis.get(this.key(messageId));\n return result !== null;\n }\n\n async set(messageId: string, ttlMs?: number): Promise<void> {\n const key = this.key(messageId);\n if (ttlMs != null) {\n // Convert ms to seconds (Redis SETEX uses seconds)\n const ttlSeconds = Math.ceil(ttlMs / 1000);\n await this.redis.setex(key, ttlSeconds, \"1\");\n } else {\n await this.redis.set(key, \"1\");\n }\n }\n\n async delete(messageId: string): Promise<void> {\n await this.redis.del(this.key(messageId));\n }\n\n async clear(): Promise<void> {\n // Get all keys with our prefix and delete them\n const keys = await this.redis.keys(`${this.keyPrefix}*`);\n if (keys.length > 0) {\n for (const key of keys) {\n await this.redis.del(key);\n }\n }\n }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,180 @@
1
+ import { MessageEnvelope, SagaMiddleware } from '@saga-bus/core';
2
+
3
+ /**
4
+ * Store interface for tracking processed message IDs.
5
+ */
6
+ interface IdempotencyStore {
7
+ /**
8
+ * Check if a message ID has been processed.
9
+ * @param messageId - The message ID to check
10
+ * @returns true if the message was already processed
11
+ */
12
+ has(messageId: string): Promise<boolean>;
13
+ /**
14
+ * Mark a message ID as processed.
15
+ * @param messageId - The message ID to mark
16
+ * @param ttlMs - Time to live in milliseconds (optional)
17
+ */
18
+ set(messageId: string, ttlMs?: number): Promise<void>;
19
+ /**
20
+ * Remove a message ID from the store.
21
+ * @param messageId - The message ID to remove
22
+ */
23
+ delete(messageId: string): Promise<void>;
24
+ /**
25
+ * Clear all entries (useful for testing).
26
+ */
27
+ clear(): Promise<void>;
28
+ }
29
+ /**
30
+ * Function to extract message ID from an envelope.
31
+ */
32
+ type MessageIdExtractor = (envelope: MessageEnvelope) => string;
33
+ /**
34
+ * Action to take when a duplicate message is detected.
35
+ */
36
+ type DuplicateAction = "skip" | "log" | "throw";
37
+ /**
38
+ * Options for the idempotency middleware.
39
+ */
40
+ interface IdempotencyMiddlewareOptions {
41
+ /**
42
+ * Store for tracking processed message IDs.
43
+ */
44
+ store: IdempotencyStore;
45
+ /**
46
+ * Time window for deduplication in milliseconds.
47
+ * Messages with the same ID within this window will be considered duplicates.
48
+ * @default 60000 (1 minute)
49
+ */
50
+ windowMs?: number;
51
+ /**
52
+ * Function to extract the message ID from an envelope.
53
+ * Defaults to using envelope.id.
54
+ */
55
+ getMessageId?: MessageIdExtractor;
56
+ /**
57
+ * Action to take when a duplicate is detected.
58
+ * - "skip": Silently skip processing (default)
59
+ * - "log": Skip but log a warning
60
+ * - "throw": Throw a DuplicateMessageError
61
+ * @default "skip"
62
+ */
63
+ onDuplicate?: DuplicateAction;
64
+ /**
65
+ * Custom logger for duplicate detection messages.
66
+ */
67
+ logger?: {
68
+ warn(message: string, meta?: Record<string, unknown>): void;
69
+ };
70
+ /**
71
+ * Message types to exclude from idempotency checks.
72
+ * Useful for messages that are naturally idempotent or should always be processed.
73
+ */
74
+ excludeTypes?: string[];
75
+ /**
76
+ * Whether to mark message as processed before or after handler execution.
77
+ * - "before": Mark before processing (at-most-once delivery)
78
+ * - "after": Mark after processing (at-least-once delivery, default)
79
+ * @default "after"
80
+ */
81
+ markTiming?: "before" | "after";
82
+ }
83
+ /**
84
+ * Error thrown when a duplicate message is detected and onDuplicate is "throw".
85
+ */
86
+ declare class DuplicateMessageError extends Error {
87
+ readonly messageId: string;
88
+ readonly messageType: string;
89
+ constructor(messageId: string, messageType: string);
90
+ }
91
+
92
+ /**
93
+ * Creates idempotency middleware that prevents duplicate message processing.
94
+ *
95
+ * @example
96
+ * ```typescript
97
+ * import { createIdempotencyMiddleware, InMemoryIdempotencyStore } from "@saga-bus/middleware-idempotency";
98
+ *
99
+ * const idempotencyMiddleware = createIdempotencyMiddleware({
100
+ * store: new InMemoryIdempotencyStore(),
101
+ * windowMs: 60000, // 1 minute deduplication window
102
+ * });
103
+ *
104
+ * const bus = createBus({
105
+ * transport,
106
+ * store,
107
+ * sagas: [MySaga],
108
+ * middleware: [idempotencyMiddleware],
109
+ * });
110
+ * ```
111
+ */
112
+ declare function createIdempotencyMiddleware(options: IdempotencyMiddlewareOptions): SagaMiddleware;
113
+
114
+ /**
115
+ * In-memory idempotency store for development and testing.
116
+ * Not suitable for distributed systems with multiple instances.
117
+ */
118
+ declare class InMemoryIdempotencyStore implements IdempotencyStore {
119
+ private readonly store;
120
+ private cleanupInterval;
121
+ /**
122
+ * Create an in-memory idempotency store.
123
+ * @param cleanupIntervalMs - How often to clean up expired entries (default: 60000ms)
124
+ */
125
+ constructor(cleanupIntervalMs?: number);
126
+ has(messageId: string): Promise<boolean>;
127
+ set(messageId: string, ttlMs?: number): Promise<void>;
128
+ delete(messageId: string): Promise<void>;
129
+ clear(): Promise<void>;
130
+ /**
131
+ * Get the number of entries in the store (for testing).
132
+ */
133
+ get size(): number;
134
+ /**
135
+ * Stop the cleanup interval.
136
+ */
137
+ stop(): void;
138
+ /**
139
+ * Remove expired entries.
140
+ */
141
+ private cleanup;
142
+ }
143
+
144
+ type Redis = {
145
+ get(key: string): Promise<string | null>;
146
+ set(key: string, value: string, mode?: string, duration?: number): Promise<string | null>;
147
+ setex(key: string, seconds: number, value: string): Promise<string>;
148
+ del(key: string): Promise<number>;
149
+ keys(pattern: string): Promise<string[]>;
150
+ };
151
+ /**
152
+ * Options for the Redis idempotency store.
153
+ */
154
+ interface RedisIdempotencyStoreOptions {
155
+ /**
156
+ * Redis client instance (ioredis).
157
+ */
158
+ redis: Redis;
159
+ /**
160
+ * Key prefix for all idempotency keys.
161
+ * @default "idempotency:"
162
+ */
163
+ keyPrefix?: string;
164
+ }
165
+ /**
166
+ * Redis-backed idempotency store for distributed systems.
167
+ * Requires ioredis as a peer dependency.
168
+ */
169
+ declare class RedisIdempotencyStore implements IdempotencyStore {
170
+ private readonly redis;
171
+ private readonly keyPrefix;
172
+ constructor(options: RedisIdempotencyStoreOptions);
173
+ private key;
174
+ has(messageId: string): Promise<boolean>;
175
+ set(messageId: string, ttlMs?: number): Promise<void>;
176
+ delete(messageId: string): Promise<void>;
177
+ clear(): Promise<void>;
178
+ }
179
+
180
+ export { type DuplicateAction, DuplicateMessageError, type IdempotencyMiddlewareOptions, type IdempotencyStore, InMemoryIdempotencyStore, type MessageIdExtractor, RedisIdempotencyStore, type RedisIdempotencyStoreOptions, createIdempotencyMiddleware };
@@ -0,0 +1,180 @@
1
+ import { MessageEnvelope, SagaMiddleware } from '@saga-bus/core';
2
+
3
+ /**
4
+ * Store interface for tracking processed message IDs.
5
+ */
6
+ interface IdempotencyStore {
7
+ /**
8
+ * Check if a message ID has been processed.
9
+ * @param messageId - The message ID to check
10
+ * @returns true if the message was already processed
11
+ */
12
+ has(messageId: string): Promise<boolean>;
13
+ /**
14
+ * Mark a message ID as processed.
15
+ * @param messageId - The message ID to mark
16
+ * @param ttlMs - Time to live in milliseconds (optional)
17
+ */
18
+ set(messageId: string, ttlMs?: number): Promise<void>;
19
+ /**
20
+ * Remove a message ID from the store.
21
+ * @param messageId - The message ID to remove
22
+ */
23
+ delete(messageId: string): Promise<void>;
24
+ /**
25
+ * Clear all entries (useful for testing).
26
+ */
27
+ clear(): Promise<void>;
28
+ }
29
+ /**
30
+ * Function to extract message ID from an envelope.
31
+ */
32
+ type MessageIdExtractor = (envelope: MessageEnvelope) => string;
33
+ /**
34
+ * Action to take when a duplicate message is detected.
35
+ */
36
+ type DuplicateAction = "skip" | "log" | "throw";
37
+ /**
38
+ * Options for the idempotency middleware.
39
+ */
40
+ interface IdempotencyMiddlewareOptions {
41
+ /**
42
+ * Store for tracking processed message IDs.
43
+ */
44
+ store: IdempotencyStore;
45
+ /**
46
+ * Time window for deduplication in milliseconds.
47
+ * Messages with the same ID within this window will be considered duplicates.
48
+ * @default 60000 (1 minute)
49
+ */
50
+ windowMs?: number;
51
+ /**
52
+ * Function to extract the message ID from an envelope.
53
+ * Defaults to using envelope.id.
54
+ */
55
+ getMessageId?: MessageIdExtractor;
56
+ /**
57
+ * Action to take when a duplicate is detected.
58
+ * - "skip": Silently skip processing (default)
59
+ * - "log": Skip but log a warning
60
+ * - "throw": Throw a DuplicateMessageError
61
+ * @default "skip"
62
+ */
63
+ onDuplicate?: DuplicateAction;
64
+ /**
65
+ * Custom logger for duplicate detection messages.
66
+ */
67
+ logger?: {
68
+ warn(message: string, meta?: Record<string, unknown>): void;
69
+ };
70
+ /**
71
+ * Message types to exclude from idempotency checks.
72
+ * Useful for messages that are naturally idempotent or should always be processed.
73
+ */
74
+ excludeTypes?: string[];
75
+ /**
76
+ * Whether to mark message as processed before or after handler execution.
77
+ * - "before": Mark before processing (at-most-once delivery)
78
+ * - "after": Mark after processing (at-least-once delivery, default)
79
+ * @default "after"
80
+ */
81
+ markTiming?: "before" | "after";
82
+ }
83
+ /**
84
+ * Error thrown when a duplicate message is detected and onDuplicate is "throw".
85
+ */
86
+ declare class DuplicateMessageError extends Error {
87
+ readonly messageId: string;
88
+ readonly messageType: string;
89
+ constructor(messageId: string, messageType: string);
90
+ }
91
+
92
+ /**
93
+ * Creates idempotency middleware that prevents duplicate message processing.
94
+ *
95
+ * @example
96
+ * ```typescript
97
+ * import { createIdempotencyMiddleware, InMemoryIdempotencyStore } from "@saga-bus/middleware-idempotency";
98
+ *
99
+ * const idempotencyMiddleware = createIdempotencyMiddleware({
100
+ * store: new InMemoryIdempotencyStore(),
101
+ * windowMs: 60000, // 1 minute deduplication window
102
+ * });
103
+ *
104
+ * const bus = createBus({
105
+ * transport,
106
+ * store,
107
+ * sagas: [MySaga],
108
+ * middleware: [idempotencyMiddleware],
109
+ * });
110
+ * ```
111
+ */
112
+ declare function createIdempotencyMiddleware(options: IdempotencyMiddlewareOptions): SagaMiddleware;
113
+
114
+ /**
115
+ * In-memory idempotency store for development and testing.
116
+ * Not suitable for distributed systems with multiple instances.
117
+ */
118
+ declare class InMemoryIdempotencyStore implements IdempotencyStore {
119
+ private readonly store;
120
+ private cleanupInterval;
121
+ /**
122
+ * Create an in-memory idempotency store.
123
+ * @param cleanupIntervalMs - How often to clean up expired entries (default: 60000ms)
124
+ */
125
+ constructor(cleanupIntervalMs?: number);
126
+ has(messageId: string): Promise<boolean>;
127
+ set(messageId: string, ttlMs?: number): Promise<void>;
128
+ delete(messageId: string): Promise<void>;
129
+ clear(): Promise<void>;
130
+ /**
131
+ * Get the number of entries in the store (for testing).
132
+ */
133
+ get size(): number;
134
+ /**
135
+ * Stop the cleanup interval.
136
+ */
137
+ stop(): void;
138
+ /**
139
+ * Remove expired entries.
140
+ */
141
+ private cleanup;
142
+ }
143
+
144
+ type Redis = {
145
+ get(key: string): Promise<string | null>;
146
+ set(key: string, value: string, mode?: string, duration?: number): Promise<string | null>;
147
+ setex(key: string, seconds: number, value: string): Promise<string>;
148
+ del(key: string): Promise<number>;
149
+ keys(pattern: string): Promise<string[]>;
150
+ };
151
+ /**
152
+ * Options for the Redis idempotency store.
153
+ */
154
+ interface RedisIdempotencyStoreOptions {
155
+ /**
156
+ * Redis client instance (ioredis).
157
+ */
158
+ redis: Redis;
159
+ /**
160
+ * Key prefix for all idempotency keys.
161
+ * @default "idempotency:"
162
+ */
163
+ keyPrefix?: string;
164
+ }
165
+ /**
166
+ * Redis-backed idempotency store for distributed systems.
167
+ * Requires ioredis as a peer dependency.
168
+ */
169
+ declare class RedisIdempotencyStore implements IdempotencyStore {
170
+ private readonly redis;
171
+ private readonly keyPrefix;
172
+ constructor(options: RedisIdempotencyStoreOptions);
173
+ private key;
174
+ has(messageId: string): Promise<boolean>;
175
+ set(messageId: string, ttlMs?: number): Promise<void>;
176
+ delete(messageId: string): Promise<void>;
177
+ clear(): Promise<void>;
178
+ }
179
+
180
+ export { type DuplicateAction, DuplicateMessageError, type IdempotencyMiddlewareOptions, type IdempotencyStore, InMemoryIdempotencyStore, type MessageIdExtractor, RedisIdempotencyStore, type RedisIdempotencyStoreOptions, createIdempotencyMiddleware };
package/dist/index.js ADDED
@@ -0,0 +1,168 @@
1
+ // src/types.ts
2
+ var DuplicateMessageError = class extends Error {
3
+ messageId;
4
+ messageType;
5
+ constructor(messageId, messageType) {
6
+ super(`Duplicate message detected: ${messageId} (type: ${messageType})`);
7
+ this.name = "DuplicateMessageError";
8
+ this.messageId = messageId;
9
+ this.messageType = messageType;
10
+ }
11
+ };
12
+
13
+ // src/IdempotencyMiddleware.ts
14
+ var defaultGetMessageId = (envelope) => envelope.id;
15
+ function createIdempotencyMiddleware(options) {
16
+ const {
17
+ store,
18
+ windowMs = 6e4,
19
+ getMessageId = defaultGetMessageId,
20
+ onDuplicate = "skip",
21
+ logger,
22
+ excludeTypes = [],
23
+ markTiming = "after"
24
+ } = options;
25
+ const excludeSet = new Set(excludeTypes);
26
+ return async (ctx, next) => {
27
+ const { envelope } = ctx;
28
+ if (excludeSet.has(envelope.type)) {
29
+ await next();
30
+ return;
31
+ }
32
+ const messageId = getMessageId(envelope);
33
+ const isDuplicate = await store.has(messageId);
34
+ if (isDuplicate) {
35
+ switch (onDuplicate) {
36
+ case "throw":
37
+ throw new DuplicateMessageError(messageId, envelope.type);
38
+ case "log":
39
+ logger?.warn("Duplicate message detected, skipping", {
40
+ messageId,
41
+ messageType: envelope.type,
42
+ correlationId: ctx.correlationId,
43
+ sagaName: ctx.sagaName
44
+ });
45
+ // Fall through to skip
46
+ case "skip":
47
+ default:
48
+ return;
49
+ }
50
+ }
51
+ if (markTiming === "before") {
52
+ await store.set(messageId, windowMs);
53
+ }
54
+ await next();
55
+ if (markTiming === "after") {
56
+ await store.set(messageId, windowMs);
57
+ }
58
+ };
59
+ }
60
+
61
+ // src/stores/InMemoryIdempotencyStore.ts
62
+ var InMemoryIdempotencyStore = class {
63
+ store = /* @__PURE__ */ new Map();
64
+ cleanupInterval = null;
65
+ /**
66
+ * Create an in-memory idempotency store.
67
+ * @param cleanupIntervalMs - How often to clean up expired entries (default: 60000ms)
68
+ */
69
+ constructor(cleanupIntervalMs = 6e4) {
70
+ if (cleanupIntervalMs > 0) {
71
+ this.cleanupInterval = setInterval(() => {
72
+ this.cleanup();
73
+ }, cleanupIntervalMs);
74
+ this.cleanupInterval.unref?.();
75
+ }
76
+ }
77
+ async has(messageId) {
78
+ const entry = this.store.get(messageId);
79
+ if (!entry) {
80
+ return false;
81
+ }
82
+ if (entry.expiresAt !== null && Date.now() > entry.expiresAt) {
83
+ this.store.delete(messageId);
84
+ return false;
85
+ }
86
+ return true;
87
+ }
88
+ async set(messageId, ttlMs) {
89
+ const expiresAt = ttlMs != null ? Date.now() + ttlMs : null;
90
+ this.store.set(messageId, { expiresAt });
91
+ }
92
+ async delete(messageId) {
93
+ this.store.delete(messageId);
94
+ }
95
+ async clear() {
96
+ this.store.clear();
97
+ }
98
+ /**
99
+ * Get the number of entries in the store (for testing).
100
+ */
101
+ get size() {
102
+ return this.store.size;
103
+ }
104
+ /**
105
+ * Stop the cleanup interval.
106
+ */
107
+ stop() {
108
+ if (this.cleanupInterval) {
109
+ clearInterval(this.cleanupInterval);
110
+ this.cleanupInterval = null;
111
+ }
112
+ }
113
+ /**
114
+ * Remove expired entries.
115
+ */
116
+ cleanup() {
117
+ const now = Date.now();
118
+ for (const [key, entry] of this.store) {
119
+ if (entry.expiresAt !== null && now > entry.expiresAt) {
120
+ this.store.delete(key);
121
+ }
122
+ }
123
+ }
124
+ };
125
+
126
+ // src/stores/RedisIdempotencyStore.ts
127
+ var RedisIdempotencyStore = class {
128
+ redis;
129
+ keyPrefix;
130
+ constructor(options) {
131
+ this.redis = options.redis;
132
+ this.keyPrefix = options.keyPrefix ?? "idempotency:";
133
+ }
134
+ key(messageId) {
135
+ return `${this.keyPrefix}${messageId}`;
136
+ }
137
+ async has(messageId) {
138
+ const result = await this.redis.get(this.key(messageId));
139
+ return result !== null;
140
+ }
141
+ async set(messageId, ttlMs) {
142
+ const key = this.key(messageId);
143
+ if (ttlMs != null) {
144
+ const ttlSeconds = Math.ceil(ttlMs / 1e3);
145
+ await this.redis.setex(key, ttlSeconds, "1");
146
+ } else {
147
+ await this.redis.set(key, "1");
148
+ }
149
+ }
150
+ async delete(messageId) {
151
+ await this.redis.del(this.key(messageId));
152
+ }
153
+ async clear() {
154
+ const keys = await this.redis.keys(`${this.keyPrefix}*`);
155
+ if (keys.length > 0) {
156
+ for (const key of keys) {
157
+ await this.redis.del(key);
158
+ }
159
+ }
160
+ }
161
+ };
162
+ export {
163
+ DuplicateMessageError,
164
+ InMemoryIdempotencyStore,
165
+ RedisIdempotencyStore,
166
+ createIdempotencyMiddleware
167
+ };
168
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/types.ts","../src/IdempotencyMiddleware.ts","../src/stores/InMemoryIdempotencyStore.ts","../src/stores/RedisIdempotencyStore.ts"],"sourcesContent":["import type { MessageEnvelope } from \"@saga-bus/core\";\n\n/**\n * Store interface for tracking processed message IDs.\n */\nexport interface IdempotencyStore {\n /**\n * Check if a message ID has been processed.\n * @param messageId - The message ID to check\n * @returns true if the message was already processed\n */\n has(messageId: string): Promise<boolean>;\n\n /**\n * Mark a message ID as processed.\n * @param messageId - The message ID to mark\n * @param ttlMs - Time to live in milliseconds (optional)\n */\n set(messageId: string, ttlMs?: number): Promise<void>;\n\n /**\n * Remove a message ID from the store.\n * @param messageId - The message ID to remove\n */\n delete(messageId: string): Promise<void>;\n\n /**\n * Clear all entries (useful for testing).\n */\n clear(): Promise<void>;\n}\n\n/**\n * Function to extract message ID from an envelope.\n */\nexport type MessageIdExtractor = (envelope: MessageEnvelope) => string;\n\n/**\n * Action to take when a duplicate message is detected.\n */\nexport type DuplicateAction = \"skip\" | \"log\" | \"throw\";\n\n/**\n * Options for the idempotency middleware.\n */\nexport interface IdempotencyMiddlewareOptions {\n /**\n * Store for tracking processed message IDs.\n */\n store: IdempotencyStore;\n\n /**\n * Time window for deduplication in milliseconds.\n * Messages with the same ID within this window will be considered duplicates.\n * @default 60000 (1 minute)\n */\n windowMs?: number;\n\n /**\n * Function to extract the message ID from an envelope.\n * Defaults to using envelope.id.\n */\n getMessageId?: MessageIdExtractor;\n\n /**\n * Action to take when a duplicate is detected.\n * - \"skip\": Silently skip processing (default)\n * - \"log\": Skip but log a warning\n * - \"throw\": Throw a DuplicateMessageError\n * @default \"skip\"\n */\n onDuplicate?: DuplicateAction;\n\n /**\n * Custom logger for duplicate detection messages.\n */\n logger?: {\n warn(message: string, meta?: Record<string, unknown>): void;\n };\n\n /**\n * Message types to exclude from idempotency checks.\n * Useful for messages that are naturally idempotent or should always be processed.\n */\n excludeTypes?: string[];\n\n /**\n * Whether to mark message as processed before or after handler execution.\n * - \"before\": Mark before processing (at-most-once delivery)\n * - \"after\": Mark after processing (at-least-once delivery, default)\n * @default \"after\"\n */\n markTiming?: \"before\" | \"after\";\n}\n\n/**\n * Error thrown when a duplicate message is detected and onDuplicate is \"throw\".\n */\nexport class DuplicateMessageError extends Error {\n public readonly messageId: string;\n public readonly messageType: string;\n\n constructor(messageId: string, messageType: string) {\n super(`Duplicate message detected: ${messageId} (type: ${messageType})`);\n this.name = \"DuplicateMessageError\";\n this.messageId = messageId;\n this.messageType = messageType;\n }\n}\n","import type { SagaMiddleware, SagaPipelineContext } from \"@saga-bus/core\";\nimport type { IdempotencyMiddlewareOptions, MessageIdExtractor } from \"./types.js\";\nimport { DuplicateMessageError } from \"./types.js\";\n\n/**\n * Default message ID extractor - uses the envelope ID.\n */\nconst defaultGetMessageId: MessageIdExtractor = (envelope) => envelope.id;\n\n/**\n * Creates idempotency middleware that prevents duplicate message processing.\n *\n * @example\n * ```typescript\n * import { createIdempotencyMiddleware, InMemoryIdempotencyStore } from \"@saga-bus/middleware-idempotency\";\n *\n * const idempotencyMiddleware = createIdempotencyMiddleware({\n * store: new InMemoryIdempotencyStore(),\n * windowMs: 60000, // 1 minute deduplication window\n * });\n *\n * const bus = createBus({\n * transport,\n * store,\n * sagas: [MySaga],\n * middleware: [idempotencyMiddleware],\n * });\n * ```\n */\nexport function createIdempotencyMiddleware(\n options: IdempotencyMiddlewareOptions\n): SagaMiddleware {\n const {\n store,\n windowMs = 60000,\n getMessageId = defaultGetMessageId,\n onDuplicate = \"skip\",\n logger,\n excludeTypes = [],\n markTiming = \"after\",\n } = options;\n\n const excludeSet = new Set(excludeTypes);\n\n return async (ctx: SagaPipelineContext, next: () => Promise<void>) => {\n const { envelope } = ctx;\n\n // Check if this message type should be excluded from idempotency checks\n if (excludeSet.has(envelope.type)) {\n await next();\n return;\n }\n\n // Extract message ID\n const messageId = getMessageId(envelope);\n\n // Check if message was already processed\n const isDuplicate = await store.has(messageId);\n\n if (isDuplicate) {\n switch (onDuplicate) {\n case \"throw\":\n throw new DuplicateMessageError(messageId, envelope.type);\n\n case \"log\":\n logger?.warn(\"Duplicate message detected, skipping\", {\n messageId,\n messageType: envelope.type,\n correlationId: ctx.correlationId,\n sagaName: ctx.sagaName,\n });\n // Fall through to skip\n\n case \"skip\":\n default:\n // Skip processing - don't call next()\n return;\n }\n }\n\n // Mark as processed before handler (at-most-once)\n if (markTiming === \"before\") {\n await store.set(messageId, windowMs);\n }\n\n // Process the message\n await next();\n\n // Mark as processed after handler (at-least-once, default)\n if (markTiming === \"after\") {\n await store.set(messageId, windowMs);\n }\n };\n}\n","import type { IdempotencyStore } from \"../types.js\";\n\ninterface StoreEntry {\n expiresAt: number | null;\n}\n\n/**\n * In-memory idempotency store for development and testing.\n * Not suitable for distributed systems with multiple instances.\n */\nexport class InMemoryIdempotencyStore implements IdempotencyStore {\n private readonly store = new Map<string, StoreEntry>();\n private cleanupInterval: ReturnType<typeof setInterval> | null = null;\n\n /**\n * Create an in-memory idempotency store.\n * @param cleanupIntervalMs - How often to clean up expired entries (default: 60000ms)\n */\n constructor(cleanupIntervalMs: number = 60000) {\n if (cleanupIntervalMs > 0) {\n this.cleanupInterval = setInterval(() => {\n this.cleanup();\n }, cleanupIntervalMs);\n // Don't keep the process alive just for cleanup\n this.cleanupInterval.unref?.();\n }\n }\n\n async has(messageId: string): Promise<boolean> {\n const entry = this.store.get(messageId);\n if (!entry) {\n return false;\n }\n // Check if expired\n if (entry.expiresAt !== null && Date.now() > entry.expiresAt) {\n this.store.delete(messageId);\n return false;\n }\n return true;\n }\n\n async set(messageId: string, ttlMs?: number): Promise<void> {\n const expiresAt = ttlMs != null ? Date.now() + ttlMs : null;\n this.store.set(messageId, { expiresAt });\n }\n\n async delete(messageId: string): Promise<void> {\n this.store.delete(messageId);\n }\n\n async clear(): Promise<void> {\n this.store.clear();\n }\n\n /**\n * Get the number of entries in the store (for testing).\n */\n get size(): number {\n return this.store.size;\n }\n\n /**\n * Stop the cleanup interval.\n */\n stop(): void {\n if (this.cleanupInterval) {\n clearInterval(this.cleanupInterval);\n this.cleanupInterval = null;\n }\n }\n\n /**\n * Remove expired entries.\n */\n private cleanup(): void {\n const now = Date.now();\n for (const [key, entry] of this.store) {\n if (entry.expiresAt !== null && now > entry.expiresAt) {\n this.store.delete(key);\n }\n }\n }\n}\n","import type { IdempotencyStore } from \"../types.js\";\n\n// Use a type-only import for Redis to make it optional\ntype Redis = {\n get(key: string): Promise<string | null>;\n set(key: string, value: string, mode?: string, duration?: number): Promise<string | null>;\n setex(key: string, seconds: number, value: string): Promise<string>;\n del(key: string): Promise<number>;\n keys(pattern: string): Promise<string[]>;\n};\n\n/**\n * Options for the Redis idempotency store.\n */\nexport interface RedisIdempotencyStoreOptions {\n /**\n * Redis client instance (ioredis).\n */\n redis: Redis;\n\n /**\n * Key prefix for all idempotency keys.\n * @default \"idempotency:\"\n */\n keyPrefix?: string;\n}\n\n/**\n * Redis-backed idempotency store for distributed systems.\n * Requires ioredis as a peer dependency.\n */\nexport class RedisIdempotencyStore implements IdempotencyStore {\n private readonly redis: Redis;\n private readonly keyPrefix: string;\n\n constructor(options: RedisIdempotencyStoreOptions) {\n this.redis = options.redis;\n this.keyPrefix = options.keyPrefix ?? \"idempotency:\";\n }\n\n private key(messageId: string): string {\n return `${this.keyPrefix}${messageId}`;\n }\n\n async has(messageId: string): Promise<boolean> {\n const result = await this.redis.get(this.key(messageId));\n return result !== null;\n }\n\n async set(messageId: string, ttlMs?: number): Promise<void> {\n const key = this.key(messageId);\n if (ttlMs != null) {\n // Convert ms to seconds (Redis SETEX uses seconds)\n const ttlSeconds = Math.ceil(ttlMs / 1000);\n await this.redis.setex(key, ttlSeconds, \"1\");\n } else {\n await this.redis.set(key, \"1\");\n }\n }\n\n async delete(messageId: string): Promise<void> {\n await this.redis.del(this.key(messageId));\n }\n\n async clear(): Promise<void> {\n // Get all keys with our prefix and delete them\n const keys = await this.redis.keys(`${this.keyPrefix}*`);\n if (keys.length > 0) {\n for (const key of keys) {\n await this.redis.del(key);\n }\n }\n }\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@saga-bus/middleware-idempotency",
3
+ "version": "0.1.0",
4
+ "description": "Idempotency middleware for saga-bus message deduplication",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
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
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "dependencies": {
24
+ "@saga-bus/core": "0.1.0"
25
+ },
26
+ "peerDependencies": {
27
+ "ioredis": ">=5.0.0"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "ioredis": {
31
+ "optional": true
32
+ }
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^22.15.21",
36
+ "ioredis": "^5.6.1",
37
+ "tsup": "^8.0.0",
38
+ "typescript": "^5.9.2",
39
+ "vitest": "^3.0.0",
40
+ "@repo/eslint-config": "0.0.0",
41
+ "@repo/typescript-config": "0.0.0"
42
+ },
43
+ "keywords": [
44
+ "saga",
45
+ "saga-bus",
46
+ "middleware",
47
+ "idempotency",
48
+ "deduplication",
49
+ "event-sourcing"
50
+ ],
51
+ "license": "MIT",
52
+ "scripts": {
53
+ "build": "tsup",
54
+ "dev": "tsup --watch",
55
+ "lint": "eslint src/",
56
+ "check-types": "tsc --noEmit",
57
+ "test": "vitest run",
58
+ "test:watch": "vitest"
59
+ }
60
+ }