@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 +21 -0
- package/README.md +282 -0
- package/dist/index.cjs +198 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +180 -0
- package/dist/index.d.ts +180 -0
- package/dist/index.js +168 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -0
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":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|