@signaltree/events 7.3.5 → 7.4.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/dist/angular/handlers.cjs +38 -0
- package/dist/angular/handlers.js +35 -0
- package/dist/angular/index.cjs +15 -0
- package/dist/angular/index.js +3 -0
- package/dist/angular/optimistic-updates.cjs +161 -0
- package/dist/angular/optimistic-updates.js +159 -0
- package/dist/angular/websocket.service.cjs +357 -0
- package/{angular.esm.js → dist/angular/websocket.service.js} +1 -191
- package/dist/core/error-classification.cjs +282 -0
- package/dist/core/error-classification.js +276 -0
- package/dist/core/factory.cjs +148 -0
- package/{factory.esm.js → dist/core/factory.js} +2 -37
- package/dist/core/idempotency.cjs +252 -0
- package/dist/core/idempotency.js +247 -0
- package/dist/core/registry.cjs +183 -0
- package/dist/core/registry.js +180 -0
- package/dist/core/types.cjs +41 -0
- package/dist/core/types.js +38 -0
- package/dist/core/validation.cjs +185 -0
- package/{index.esm.js → dist/core/validation.js} +1 -4
- package/dist/index.cjs +43 -0
- package/dist/index.js +7 -0
- package/dist/nestjs/base.subscriber.cjs +287 -0
- package/dist/nestjs/base.subscriber.js +287 -0
- package/dist/nestjs/decorators.cjs +35 -0
- package/dist/nestjs/decorators.js +32 -0
- package/dist/nestjs/dlq.service.cjs +249 -0
- package/dist/nestjs/dlq.service.js +249 -0
- package/dist/nestjs/event-bus.module.cjs +152 -0
- package/dist/nestjs/event-bus.module.js +152 -0
- package/dist/nestjs/event-bus.service.cjs +243 -0
- package/dist/nestjs/event-bus.service.js +243 -0
- package/dist/nestjs/index.cjs +33 -0
- package/dist/nestjs/index.js +6 -0
- package/dist/nestjs/tokens.cjs +14 -0
- package/dist/nestjs/tokens.js +9 -0
- package/dist/testing/assertions.cjs +172 -0
- package/dist/testing/assertions.js +169 -0
- package/dist/testing/factories.cjs +122 -0
- package/dist/testing/factories.js +119 -0
- package/dist/testing/helpers.cjs +233 -0
- package/dist/testing/helpers.js +227 -0
- package/dist/testing/index.cjs +20 -0
- package/dist/testing/index.js +4 -0
- package/dist/testing/mock-event-bus.cjs +237 -0
- package/dist/testing/mock-event-bus.js +234 -0
- package/package.json +22 -22
- package/angular.d.ts +0 -1
- package/idempotency.esm.js +0 -701
- package/index.d.ts +0 -1
- package/nestjs.d.ts +0 -1
- package/nestjs.esm.js +0 -944
- package/testing.d.ts +0 -1
- package/testing.esm.js +0 -743
|
@@ -1,39 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Core event types - framework-agnostic definitions
|
|
3
|
-
*/
|
|
4
|
-
/**
|
|
5
|
-
* Priority configuration with SLA targets
|
|
6
|
-
*/
|
|
7
|
-
const EVENT_PRIORITIES = {
|
|
8
|
-
critical: {
|
|
9
|
-
sla: 100,
|
|
10
|
-
weight: 10
|
|
11
|
-
},
|
|
12
|
-
// < 100ms
|
|
13
|
-
high: {
|
|
14
|
-
sla: 500,
|
|
15
|
-
weight: 7
|
|
16
|
-
},
|
|
17
|
-
// < 500ms
|
|
18
|
-
normal: {
|
|
19
|
-
sla: 2000,
|
|
20
|
-
weight: 5
|
|
21
|
-
},
|
|
22
|
-
// < 2s
|
|
23
|
-
low: {
|
|
24
|
-
sla: 30000,
|
|
25
|
-
weight: 3
|
|
26
|
-
},
|
|
27
|
-
// < 30s
|
|
28
|
-
bulk: {
|
|
29
|
-
sla: 300000,
|
|
30
|
-
weight: 1
|
|
31
|
-
} // < 5min
|
|
32
|
-
};
|
|
33
|
-
const DEFAULT_EVENT_VERSION = {
|
|
34
|
-
major: 1,
|
|
35
|
-
minor: 0
|
|
36
|
-
};
|
|
1
|
+
import { DEFAULT_EVENT_VERSION } from './types.js';
|
|
37
2
|
|
|
38
3
|
/**
|
|
39
4
|
* Generate a UUID v7 (time-sortable)
|
|
@@ -175,4 +140,4 @@ function createEventFactory(config) {
|
|
|
175
140
|
};
|
|
176
141
|
}
|
|
177
142
|
|
|
178
|
-
export {
|
|
143
|
+
export { createEvent, createEventFactory, generateCorrelationId, generateEventId };
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory idempotency store
|
|
5
|
+
*
|
|
6
|
+
* Best for:
|
|
7
|
+
* - Development and testing
|
|
8
|
+
* - Single-instance deployments
|
|
9
|
+
* - Short-lived processes
|
|
10
|
+
*
|
|
11
|
+
* NOT recommended for:
|
|
12
|
+
* - Production multi-instance deployments
|
|
13
|
+
* - Long-running processes with many events
|
|
14
|
+
*/
|
|
15
|
+
class InMemoryIdempotencyStore {
|
|
16
|
+
records = new Map();
|
|
17
|
+
cleanupTimer;
|
|
18
|
+
defaultTtlMs;
|
|
19
|
+
defaultLockTtlMs;
|
|
20
|
+
maxRecords;
|
|
21
|
+
constructor(config = {}) {
|
|
22
|
+
this.defaultTtlMs = config.defaultTtlMs ?? 24 * 60 * 60 * 1000; // 24 hours
|
|
23
|
+
this.defaultLockTtlMs = config.defaultLockTtlMs ?? 30 * 1000; // 30 seconds
|
|
24
|
+
this.maxRecords = config.maxRecords ?? 100000;
|
|
25
|
+
if (config.cleanupIntervalMs !== 0) {
|
|
26
|
+
const interval = config.cleanupIntervalMs ?? 60 * 1000;
|
|
27
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), interval);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
makeKey(eventId, consumer) {
|
|
31
|
+
return `${consumer}:${eventId}`;
|
|
32
|
+
}
|
|
33
|
+
async check(event, consumer, options = {}) {
|
|
34
|
+
const key = this.makeKey(event.id, consumer);
|
|
35
|
+
const record = this.records.get(key);
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
// Check for existing record
|
|
38
|
+
if (record && record.expiresAt > now) {
|
|
39
|
+
// If processing and lock expired, treat as stale and allow reprocessing
|
|
40
|
+
if (record.status === 'processing') {
|
|
41
|
+
const lockExpired = record.startedAt.getTime() + this.defaultLockTtlMs < now;
|
|
42
|
+
if (lockExpired) {
|
|
43
|
+
// Lock expired, allow new processing attempt
|
|
44
|
+
if (options.acquireLock) {
|
|
45
|
+
const updated = {
|
|
46
|
+
...record,
|
|
47
|
+
startedAt: new Date(),
|
|
48
|
+
attempts: record.attempts + 1
|
|
49
|
+
};
|
|
50
|
+
this.records.set(key, updated);
|
|
51
|
+
return {
|
|
52
|
+
isDuplicate: false,
|
|
53
|
+
lockAcquired: true
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
isDuplicate: false
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// Still processing within lock period - treat as duplicate
|
|
61
|
+
return {
|
|
62
|
+
isDuplicate: true,
|
|
63
|
+
processedAt: record.startedAt
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// Completed or failed record exists
|
|
67
|
+
return {
|
|
68
|
+
isDuplicate: true,
|
|
69
|
+
processedAt: record.completedAt ?? record.startedAt,
|
|
70
|
+
result: record.result
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// No existing record or expired
|
|
74
|
+
if (options.acquireLock !== false) {
|
|
75
|
+
const ttlMs = options.lockTtlMs ?? this.defaultTtlMs;
|
|
76
|
+
const newRecord = {
|
|
77
|
+
eventId: event.id,
|
|
78
|
+
eventType: event.type,
|
|
79
|
+
startedAt: new Date(),
|
|
80
|
+
status: 'processing',
|
|
81
|
+
consumer,
|
|
82
|
+
attempts: 1,
|
|
83
|
+
expiresAt: now + ttlMs
|
|
84
|
+
};
|
|
85
|
+
this.records.set(key, newRecord);
|
|
86
|
+
this.enforceMaxRecords();
|
|
87
|
+
return {
|
|
88
|
+
isDuplicate: false,
|
|
89
|
+
lockAcquired: true
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
isDuplicate: false
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
async markProcessing(event, consumer, ttlMs) {
|
|
97
|
+
const result = await this.check(event, consumer, {
|
|
98
|
+
acquireLock: true,
|
|
99
|
+
lockTtlMs: ttlMs ?? this.defaultLockTtlMs
|
|
100
|
+
});
|
|
101
|
+
return result.lockAcquired ?? false;
|
|
102
|
+
}
|
|
103
|
+
async markCompleted(event, consumer, result, ttlMs) {
|
|
104
|
+
const key = this.makeKey(event.id, consumer);
|
|
105
|
+
const record = this.records.get(key);
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
const updated = {
|
|
108
|
+
eventId: event.id,
|
|
109
|
+
eventType: event.type,
|
|
110
|
+
startedAt: record?.startedAt ?? new Date(),
|
|
111
|
+
completedAt: new Date(),
|
|
112
|
+
status: 'completed',
|
|
113
|
+
result,
|
|
114
|
+
consumer,
|
|
115
|
+
attempts: record?.attempts ?? 1,
|
|
116
|
+
expiresAt: now + (ttlMs ?? this.defaultTtlMs)
|
|
117
|
+
};
|
|
118
|
+
this.records.set(key, updated);
|
|
119
|
+
}
|
|
120
|
+
async markFailed(event, consumer, error) {
|
|
121
|
+
const key = this.makeKey(event.id, consumer);
|
|
122
|
+
const record = this.records.get(key);
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
const errorStr = error instanceof Error ? error.message : String(error);
|
|
125
|
+
const updated = {
|
|
126
|
+
eventId: event.id,
|
|
127
|
+
eventType: event.type,
|
|
128
|
+
startedAt: record?.startedAt ?? new Date(),
|
|
129
|
+
completedAt: new Date(),
|
|
130
|
+
status: 'failed',
|
|
131
|
+
error: errorStr,
|
|
132
|
+
consumer,
|
|
133
|
+
attempts: record?.attempts ?? 1,
|
|
134
|
+
expiresAt: now + this.defaultTtlMs
|
|
135
|
+
};
|
|
136
|
+
this.records.set(key, updated);
|
|
137
|
+
}
|
|
138
|
+
async releaseLock(event, consumer) {
|
|
139
|
+
const key = this.makeKey(event.id, consumer);
|
|
140
|
+
this.records.delete(key);
|
|
141
|
+
}
|
|
142
|
+
async getRecord(eventId, consumer) {
|
|
143
|
+
const key = this.makeKey(eventId, consumer);
|
|
144
|
+
const record = this.records.get(key);
|
|
145
|
+
if (!record || record.expiresAt < Date.now()) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
// Return without internal expiresAt field
|
|
149
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
150
|
+
const {
|
|
151
|
+
expiresAt: _,
|
|
152
|
+
...publicRecord
|
|
153
|
+
} = record;
|
|
154
|
+
return publicRecord;
|
|
155
|
+
}
|
|
156
|
+
async cleanup() {
|
|
157
|
+
const now = Date.now();
|
|
158
|
+
let cleaned = 0;
|
|
159
|
+
for (const [key, record] of Array.from(this.records.entries())) {
|
|
160
|
+
if (record.expiresAt < now) {
|
|
161
|
+
this.records.delete(key);
|
|
162
|
+
cleaned++;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return cleaned;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Enforce max records limit using LRU-like eviction
|
|
169
|
+
*/
|
|
170
|
+
enforceMaxRecords() {
|
|
171
|
+
if (this.records.size <= this.maxRecords) return;
|
|
172
|
+
// Sort by expires time (oldest first)
|
|
173
|
+
const entries = Array.from(this.records.entries()).sort((a, b) => a[1].expiresAt - b[1].expiresAt);
|
|
174
|
+
// Remove oldest 10%
|
|
175
|
+
const toRemove = Math.ceil(this.maxRecords * 0.1);
|
|
176
|
+
for (let i = 0; i < toRemove && i < entries.length; i++) {
|
|
177
|
+
this.records.delete(entries[i][0]);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Stop cleanup timer (for graceful shutdown)
|
|
182
|
+
*/
|
|
183
|
+
dispose() {
|
|
184
|
+
if (this.cleanupTimer) {
|
|
185
|
+
clearInterval(this.cleanupTimer);
|
|
186
|
+
this.cleanupTimer = undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Clear all records (for testing)
|
|
191
|
+
*/
|
|
192
|
+
clear() {
|
|
193
|
+
this.records.clear();
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Get stats (for monitoring)
|
|
197
|
+
*/
|
|
198
|
+
getStats() {
|
|
199
|
+
return {
|
|
200
|
+
size: this.records.size,
|
|
201
|
+
maxRecords: this.maxRecords
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Create an in-memory idempotency store
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* ```typescript
|
|
210
|
+
* const store = createInMemoryIdempotencyStore({
|
|
211
|
+
* defaultTtlMs: 60 * 60 * 1000, // 1 hour
|
|
212
|
+
* maxRecords: 50000,
|
|
213
|
+
* });
|
|
214
|
+
*
|
|
215
|
+
* // In your subscriber
|
|
216
|
+
* const result = await store.check(event, 'my-subscriber');
|
|
217
|
+
* if (result.isDuplicate) {
|
|
218
|
+
* return result.result; // Return cached result
|
|
219
|
+
* }
|
|
220
|
+
*
|
|
221
|
+
* try {
|
|
222
|
+
* const processResult = await processEvent(event);
|
|
223
|
+
* await store.markCompleted(event, 'my-subscriber', processResult);
|
|
224
|
+
* return processResult;
|
|
225
|
+
* } catch (error) {
|
|
226
|
+
* await store.markFailed(event, 'my-subscriber', error);
|
|
227
|
+
* throw error;
|
|
228
|
+
* }
|
|
229
|
+
* ```
|
|
230
|
+
*/
|
|
231
|
+
function createInMemoryIdempotencyStore(config) {
|
|
232
|
+
return new InMemoryIdempotencyStore(config);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Generate an idempotency key from event
|
|
236
|
+
* Useful for custom implementations
|
|
237
|
+
*/
|
|
238
|
+
function generateIdempotencyKey(event, consumer) {
|
|
239
|
+
return `idempotency:${consumer}:${event.id}`;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Generate an idempotency key from correlation ID
|
|
243
|
+
* Useful for request-level idempotency
|
|
244
|
+
*/
|
|
245
|
+
function generateCorrelationKey(correlationId, operation) {
|
|
246
|
+
return `idempotency:correlation:${operation}:${correlationId}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
exports.InMemoryIdempotencyStore = InMemoryIdempotencyStore;
|
|
250
|
+
exports.createInMemoryIdempotencyStore = createInMemoryIdempotencyStore;
|
|
251
|
+
exports.generateCorrelationKey = generateCorrelationKey;
|
|
252
|
+
exports.generateIdempotencyKey = generateIdempotencyKey;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory idempotency store
|
|
3
|
+
*
|
|
4
|
+
* Best for:
|
|
5
|
+
* - Development and testing
|
|
6
|
+
* - Single-instance deployments
|
|
7
|
+
* - Short-lived processes
|
|
8
|
+
*
|
|
9
|
+
* NOT recommended for:
|
|
10
|
+
* - Production multi-instance deployments
|
|
11
|
+
* - Long-running processes with many events
|
|
12
|
+
*/
|
|
13
|
+
class InMemoryIdempotencyStore {
|
|
14
|
+
records = new Map();
|
|
15
|
+
cleanupTimer;
|
|
16
|
+
defaultTtlMs;
|
|
17
|
+
defaultLockTtlMs;
|
|
18
|
+
maxRecords;
|
|
19
|
+
constructor(config = {}) {
|
|
20
|
+
this.defaultTtlMs = config.defaultTtlMs ?? 24 * 60 * 60 * 1000; // 24 hours
|
|
21
|
+
this.defaultLockTtlMs = config.defaultLockTtlMs ?? 30 * 1000; // 30 seconds
|
|
22
|
+
this.maxRecords = config.maxRecords ?? 100000;
|
|
23
|
+
if (config.cleanupIntervalMs !== 0) {
|
|
24
|
+
const interval = config.cleanupIntervalMs ?? 60 * 1000;
|
|
25
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), interval);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
makeKey(eventId, consumer) {
|
|
29
|
+
return `${consumer}:${eventId}`;
|
|
30
|
+
}
|
|
31
|
+
async check(event, consumer, options = {}) {
|
|
32
|
+
const key = this.makeKey(event.id, consumer);
|
|
33
|
+
const record = this.records.get(key);
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
// Check for existing record
|
|
36
|
+
if (record && record.expiresAt > now) {
|
|
37
|
+
// If processing and lock expired, treat as stale and allow reprocessing
|
|
38
|
+
if (record.status === 'processing') {
|
|
39
|
+
const lockExpired = record.startedAt.getTime() + this.defaultLockTtlMs < now;
|
|
40
|
+
if (lockExpired) {
|
|
41
|
+
// Lock expired, allow new processing attempt
|
|
42
|
+
if (options.acquireLock) {
|
|
43
|
+
const updated = {
|
|
44
|
+
...record,
|
|
45
|
+
startedAt: new Date(),
|
|
46
|
+
attempts: record.attempts + 1
|
|
47
|
+
};
|
|
48
|
+
this.records.set(key, updated);
|
|
49
|
+
return {
|
|
50
|
+
isDuplicate: false,
|
|
51
|
+
lockAcquired: true
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
isDuplicate: false
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// Still processing within lock period - treat as duplicate
|
|
59
|
+
return {
|
|
60
|
+
isDuplicate: true,
|
|
61
|
+
processedAt: record.startedAt
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// Completed or failed record exists
|
|
65
|
+
return {
|
|
66
|
+
isDuplicate: true,
|
|
67
|
+
processedAt: record.completedAt ?? record.startedAt,
|
|
68
|
+
result: record.result
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// No existing record or expired
|
|
72
|
+
if (options.acquireLock !== false) {
|
|
73
|
+
const ttlMs = options.lockTtlMs ?? this.defaultTtlMs;
|
|
74
|
+
const newRecord = {
|
|
75
|
+
eventId: event.id,
|
|
76
|
+
eventType: event.type,
|
|
77
|
+
startedAt: new Date(),
|
|
78
|
+
status: 'processing',
|
|
79
|
+
consumer,
|
|
80
|
+
attempts: 1,
|
|
81
|
+
expiresAt: now + ttlMs
|
|
82
|
+
};
|
|
83
|
+
this.records.set(key, newRecord);
|
|
84
|
+
this.enforceMaxRecords();
|
|
85
|
+
return {
|
|
86
|
+
isDuplicate: false,
|
|
87
|
+
lockAcquired: true
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
isDuplicate: false
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
async markProcessing(event, consumer, ttlMs) {
|
|
95
|
+
const result = await this.check(event, consumer, {
|
|
96
|
+
acquireLock: true,
|
|
97
|
+
lockTtlMs: ttlMs ?? this.defaultLockTtlMs
|
|
98
|
+
});
|
|
99
|
+
return result.lockAcquired ?? false;
|
|
100
|
+
}
|
|
101
|
+
async markCompleted(event, consumer, result, ttlMs) {
|
|
102
|
+
const key = this.makeKey(event.id, consumer);
|
|
103
|
+
const record = this.records.get(key);
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
const updated = {
|
|
106
|
+
eventId: event.id,
|
|
107
|
+
eventType: event.type,
|
|
108
|
+
startedAt: record?.startedAt ?? new Date(),
|
|
109
|
+
completedAt: new Date(),
|
|
110
|
+
status: 'completed',
|
|
111
|
+
result,
|
|
112
|
+
consumer,
|
|
113
|
+
attempts: record?.attempts ?? 1,
|
|
114
|
+
expiresAt: now + (ttlMs ?? this.defaultTtlMs)
|
|
115
|
+
};
|
|
116
|
+
this.records.set(key, updated);
|
|
117
|
+
}
|
|
118
|
+
async markFailed(event, consumer, error) {
|
|
119
|
+
const key = this.makeKey(event.id, consumer);
|
|
120
|
+
const record = this.records.get(key);
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
const errorStr = error instanceof Error ? error.message : String(error);
|
|
123
|
+
const updated = {
|
|
124
|
+
eventId: event.id,
|
|
125
|
+
eventType: event.type,
|
|
126
|
+
startedAt: record?.startedAt ?? new Date(),
|
|
127
|
+
completedAt: new Date(),
|
|
128
|
+
status: 'failed',
|
|
129
|
+
error: errorStr,
|
|
130
|
+
consumer,
|
|
131
|
+
attempts: record?.attempts ?? 1,
|
|
132
|
+
expiresAt: now + this.defaultTtlMs
|
|
133
|
+
};
|
|
134
|
+
this.records.set(key, updated);
|
|
135
|
+
}
|
|
136
|
+
async releaseLock(event, consumer) {
|
|
137
|
+
const key = this.makeKey(event.id, consumer);
|
|
138
|
+
this.records.delete(key);
|
|
139
|
+
}
|
|
140
|
+
async getRecord(eventId, consumer) {
|
|
141
|
+
const key = this.makeKey(eventId, consumer);
|
|
142
|
+
const record = this.records.get(key);
|
|
143
|
+
if (!record || record.expiresAt < Date.now()) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
// Return without internal expiresAt field
|
|
147
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
148
|
+
const {
|
|
149
|
+
expiresAt: _,
|
|
150
|
+
...publicRecord
|
|
151
|
+
} = record;
|
|
152
|
+
return publicRecord;
|
|
153
|
+
}
|
|
154
|
+
async cleanup() {
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
let cleaned = 0;
|
|
157
|
+
for (const [key, record] of Array.from(this.records.entries())) {
|
|
158
|
+
if (record.expiresAt < now) {
|
|
159
|
+
this.records.delete(key);
|
|
160
|
+
cleaned++;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return cleaned;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Enforce max records limit using LRU-like eviction
|
|
167
|
+
*/
|
|
168
|
+
enforceMaxRecords() {
|
|
169
|
+
if (this.records.size <= this.maxRecords) return;
|
|
170
|
+
// Sort by expires time (oldest first)
|
|
171
|
+
const entries = Array.from(this.records.entries()).sort((a, b) => a[1].expiresAt - b[1].expiresAt);
|
|
172
|
+
// Remove oldest 10%
|
|
173
|
+
const toRemove = Math.ceil(this.maxRecords * 0.1);
|
|
174
|
+
for (let i = 0; i < toRemove && i < entries.length; i++) {
|
|
175
|
+
this.records.delete(entries[i][0]);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Stop cleanup timer (for graceful shutdown)
|
|
180
|
+
*/
|
|
181
|
+
dispose() {
|
|
182
|
+
if (this.cleanupTimer) {
|
|
183
|
+
clearInterval(this.cleanupTimer);
|
|
184
|
+
this.cleanupTimer = undefined;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Clear all records (for testing)
|
|
189
|
+
*/
|
|
190
|
+
clear() {
|
|
191
|
+
this.records.clear();
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Get stats (for monitoring)
|
|
195
|
+
*/
|
|
196
|
+
getStats() {
|
|
197
|
+
return {
|
|
198
|
+
size: this.records.size,
|
|
199
|
+
maxRecords: this.maxRecords
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Create an in-memory idempotency store
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* ```typescript
|
|
208
|
+
* const store = createInMemoryIdempotencyStore({
|
|
209
|
+
* defaultTtlMs: 60 * 60 * 1000, // 1 hour
|
|
210
|
+
* maxRecords: 50000,
|
|
211
|
+
* });
|
|
212
|
+
*
|
|
213
|
+
* // In your subscriber
|
|
214
|
+
* const result = await store.check(event, 'my-subscriber');
|
|
215
|
+
* if (result.isDuplicate) {
|
|
216
|
+
* return result.result; // Return cached result
|
|
217
|
+
* }
|
|
218
|
+
*
|
|
219
|
+
* try {
|
|
220
|
+
* const processResult = await processEvent(event);
|
|
221
|
+
* await store.markCompleted(event, 'my-subscriber', processResult);
|
|
222
|
+
* return processResult;
|
|
223
|
+
* } catch (error) {
|
|
224
|
+
* await store.markFailed(event, 'my-subscriber', error);
|
|
225
|
+
* throw error;
|
|
226
|
+
* }
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
function createInMemoryIdempotencyStore(config) {
|
|
230
|
+
return new InMemoryIdempotencyStore(config);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Generate an idempotency key from event
|
|
234
|
+
* Useful for custom implementations
|
|
235
|
+
*/
|
|
236
|
+
function generateIdempotencyKey(event, consumer) {
|
|
237
|
+
return `idempotency:${consumer}:${event.id}`;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Generate an idempotency key from correlation ID
|
|
241
|
+
* Useful for request-level idempotency
|
|
242
|
+
*/
|
|
243
|
+
function generateCorrelationKey(correlationId, operation) {
|
|
244
|
+
return `idempotency:correlation:${operation}:${correlationId}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export { InMemoryIdempotencyStore, createInMemoryIdempotencyStore, generateCorrelationKey, generateIdempotencyKey };
|