@oncely/core 0.2.1 → 0.2.2
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/README.md +74 -589
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# @oncely/core
|
|
2
2
|
|
|
3
|
-
Core idempotency library with memory storage
|
|
3
|
+
Core idempotency library with memory storage. The foundation for all oncely packages.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@oncely/core)
|
|
4
6
|
|
|
5
7
|
## Installation
|
|
6
8
|
|
|
@@ -11,650 +13,133 @@ npm install @oncely/core
|
|
|
11
13
|
## Quick Start
|
|
12
14
|
|
|
13
15
|
```typescript
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
// Configure globally
|
|
17
|
-
oncely.configure({ ttl: '1h' });
|
|
18
|
-
|
|
19
|
-
// Create an instance
|
|
20
|
-
const idempotency = oncely.createInstance({
|
|
21
|
-
storage: new MemoryStorage(),
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
// Run with idempotency
|
|
25
|
-
const result = await idempotency.run({
|
|
26
|
-
key: 'unique-request-id',
|
|
27
|
-
handler: async () => {
|
|
28
|
-
return await createOrder({ amount: 100 });
|
|
29
|
-
},
|
|
30
|
-
});
|
|
16
|
+
import { MemoryStorage, hashObject, parseTtl } from '@oncely/core';
|
|
31
17
|
|
|
32
|
-
|
|
33
|
-
console.log(result.cached); // false (first request)
|
|
34
|
-
console.log(result.createdAt); // 1704888000000
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
## API Reference
|
|
38
|
-
|
|
39
|
-
### `oncely` Namespace
|
|
40
|
-
|
|
41
|
-
Global namespace for configuration and instance creation.
|
|
42
|
-
|
|
43
|
-
```typescript
|
|
44
|
-
// Configure global defaults
|
|
45
|
-
oncely.configure({
|
|
46
|
-
storage: new MemoryStorage(),
|
|
47
|
-
ttl: '24h',
|
|
48
|
-
debug: false,
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
// Get current configuration
|
|
52
|
-
const config = oncely.getConfig();
|
|
53
|
-
|
|
54
|
-
// Reset configuration
|
|
55
|
-
oncely.resetConfig();
|
|
56
|
-
|
|
57
|
-
// Get default storage
|
|
58
|
-
const storage = oncely.getDefaultStorage();
|
|
59
|
-
|
|
60
|
-
// Create instance with merged config
|
|
61
|
-
const instance = oncely.createInstance({
|
|
62
|
-
ttl: '1h', // Override global ttl
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
// Constants
|
|
66
|
-
oncely.HEADER; // 'Idempotency-Key'
|
|
67
|
-
oncely.HEADER_REPLAY; // 'Idempotency-Replay'
|
|
68
|
-
```
|
|
18
|
+
const storage = new MemoryStorage();
|
|
69
19
|
|
|
70
|
-
|
|
20
|
+
// Acquire a lock for a request
|
|
21
|
+
const result = await storage.acquire('request-key', hashObject(body), parseTtl('1h'));
|
|
71
22
|
|
|
72
|
-
|
|
23
|
+
if (result.status === 'acquired') {
|
|
24
|
+
// Execute the operation
|
|
25
|
+
const response = await processRequest(body);
|
|
73
26
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
27
|
+
// Save the response
|
|
28
|
+
await storage.save('request-key', {
|
|
29
|
+
data: response,
|
|
30
|
+
createdAt: Date.now(),
|
|
31
|
+
hash: hashObject(body),
|
|
32
|
+
});
|
|
80
33
|
}
|
|
81
34
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
status: 'created' | 'hit'; // Result status
|
|
86
|
-
createdAt: number; // Timestamp of original request
|
|
35
|
+
if (result.status === 'hit') {
|
|
36
|
+
// Return cached response
|
|
37
|
+
return result.response.data;
|
|
87
38
|
}
|
|
88
|
-
|
|
89
|
-
const result = await idempotency.run({
|
|
90
|
-
key: 'order-123',
|
|
91
|
-
hash: hashObject(req.body),
|
|
92
|
-
handler: async () => {
|
|
93
|
-
const order = await createOrder(req.body);
|
|
94
|
-
return order;
|
|
95
|
-
},
|
|
96
|
-
ttl: '2h',
|
|
97
|
-
});
|
|
98
39
|
```
|
|
99
40
|
|
|
100
|
-
##
|
|
101
|
-
|
|
102
|
-
### Global Configuration
|
|
41
|
+
## Storage Adapter
|
|
103
42
|
|
|
104
|
-
|
|
105
|
-
oncely.configure({
|
|
106
|
-
// Storage adapter (required)
|
|
107
|
-
storage: new MemoryStorage(),
|
|
108
|
-
|
|
109
|
-
// Time-to-live for cached responses
|
|
110
|
-
// Accepts: milliseconds (number) or string ('1h', '30m', '5s')
|
|
111
|
-
ttl: '24h',
|
|
112
|
-
|
|
113
|
-
// Enable debug logging
|
|
114
|
-
debug: false,
|
|
115
|
-
|
|
116
|
-
// Callbacks
|
|
117
|
-
onHit: (key, response) => {
|
|
118
|
-
console.log(`Cache hit for key: ${key}`);
|
|
119
|
-
},
|
|
120
|
-
onMiss: (key) => {
|
|
121
|
-
console.log(`Cache miss for key: ${key}`);
|
|
122
|
-
},
|
|
123
|
-
onConflict: (key, error) => {
|
|
124
|
-
console.log(`Conflict for key: ${key}`);
|
|
125
|
-
},
|
|
126
|
-
});
|
|
127
|
-
```
|
|
43
|
+
`MemoryStorage` is the default adapter, ideal for:
|
|
128
44
|
|
|
129
|
-
|
|
45
|
+
- Development and testing
|
|
46
|
+
- Single-instance deployments
|
|
47
|
+
- Prototyping
|
|
130
48
|
|
|
131
|
-
|
|
132
|
-
const instance = oncely.createInstance({
|
|
133
|
-
storage: redis(),
|
|
134
|
-
ttl: '1h',
|
|
135
|
-
onHit: (key) => analytics.track('cache_hit', { key }),
|
|
136
|
-
});
|
|
137
|
-
```
|
|
49
|
+
For production multi-instance deployments, use:
|
|
138
50
|
|
|
139
|
-
|
|
51
|
+
- [@oncely/redis](https://www.npmjs.com/package/@oncely/redis) - Standard Redis
|
|
52
|
+
- [@oncely/upstash](https://www.npmjs.com/package/@oncely/upstash) - Serverless Redis
|
|
140
53
|
|
|
141
|
-
|
|
54
|
+
### StorageAdapter Interface
|
|
142
55
|
|
|
143
56
|
```typescript
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
} from '@oncely/core';
|
|
151
|
-
|
|
152
|
-
try {
|
|
153
|
-
const result = await idempotency.run({ key, handler });
|
|
154
|
-
} catch (error) {
|
|
155
|
-
if (error instanceof MissingKeyError) {
|
|
156
|
-
// status: 400
|
|
157
|
-
// Idempotency key is required but not provided
|
|
158
|
-
return new Response('Idempotency-Key header required', { status: 400 });
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (error instanceof ConflictError) {
|
|
162
|
-
// status: 409
|
|
163
|
-
// Request with this key is already in progress
|
|
164
|
-
return new Response('Request in progress', {
|
|
165
|
-
status: 409,
|
|
166
|
-
headers: { 'Retry-After': String(error.retryAfter ?? 30) },
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
if (error instanceof MismatchError) {
|
|
171
|
-
// status: 422
|
|
172
|
-
// Same key used with different request body
|
|
173
|
-
return new Response('Idempotency key mismatch', {
|
|
174
|
-
status: 422,
|
|
175
|
-
headers: {
|
|
176
|
-
'Content-Type': 'application/problem+json',
|
|
177
|
-
},
|
|
178
|
-
body: JSON.stringify({
|
|
179
|
-
type: 'https://oncely.dev/errors/mismatch',
|
|
180
|
-
title: 'Idempotency Key Mismatch',
|
|
181
|
-
detail: `Key was previously used with different request body`,
|
|
182
|
-
existingHash: error.existingHash,
|
|
183
|
-
providedHash: error.providedHash,
|
|
184
|
-
}),
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (error instanceof StorageError) {
|
|
189
|
-
// status: 500
|
|
190
|
-
// Storage adapter failed
|
|
191
|
-
console.error('Storage error:', error);
|
|
192
|
-
return new Response('Storage error', { status: 500 });
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Other errors
|
|
196
|
-
throw error;
|
|
57
|
+
interface StorageAdapter {
|
|
58
|
+
acquire(key: string, hash: string | null, ttl: number): Promise<AcquireResult>;
|
|
59
|
+
save(key: string, response: StoredResponse): Promise<void>;
|
|
60
|
+
release(key: string): Promise<void>;
|
|
61
|
+
delete(key: string): Promise<void>;
|
|
62
|
+
clear(): Promise<void>;
|
|
197
63
|
}
|
|
64
|
+
|
|
65
|
+
type AcquireResult =
|
|
66
|
+
| { status: 'acquired' }
|
|
67
|
+
| { status: 'hit'; response: StoredResponse }
|
|
68
|
+
| { status: 'conflict'; startedAt: number }
|
|
69
|
+
| { status: 'mismatch'; existingHash: string; providedHash: string };
|
|
198
70
|
```
|
|
199
71
|
|
|
200
72
|
## Utilities
|
|
201
73
|
|
|
202
|
-
###
|
|
203
|
-
|
|
204
|
-
```typescript
|
|
205
|
-
import { generateKey, composeKey } from '@oncely/core';
|
|
206
|
-
|
|
207
|
-
// Generate UUID-based key
|
|
208
|
-
const key = generateKey();
|
|
209
|
-
// => '550e8400-e29b-41d4-a716-446655440000'
|
|
210
|
-
|
|
211
|
-
// Compose keys from parts
|
|
212
|
-
const key = composeKey('order', orderId, 'attempt', attemptNumber);
|
|
213
|
-
// => 'order:123:attempt:1'
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
### Hashing
|
|
74
|
+
### Hash Objects
|
|
217
75
|
|
|
218
76
|
```typescript
|
|
219
77
|
import { hashObject } from '@oncely/core';
|
|
220
78
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
// => 'f8e9b4a2c1d5e3f6...'
|
|
224
|
-
|
|
225
|
-
// Works with any serializable object
|
|
226
|
-
const hash = hashObject({
|
|
227
|
-
amount: 100,
|
|
228
|
-
currency: 'USD',
|
|
229
|
-
items: [{ id: 1, qty: 2 }],
|
|
230
|
-
});
|
|
79
|
+
const hash = hashObject({ amount: 100, currency: 'USD' });
|
|
80
|
+
// => 'a1b2c3d4...'
|
|
231
81
|
```
|
|
232
82
|
|
|
233
|
-
### TTL
|
|
83
|
+
### Parse TTL
|
|
234
84
|
|
|
235
85
|
```typescript
|
|
236
86
|
import { parseTtl } from '@oncely/core';
|
|
237
87
|
|
|
238
|
-
parseTtl(3600000); // 3600000 (milliseconds)
|
|
239
88
|
parseTtl('1h'); // 3600000
|
|
240
89
|
parseTtl('30m'); // 1800000
|
|
241
90
|
parseTtl('5s'); // 5000
|
|
242
91
|
parseTtl('1d'); // 86400000
|
|
243
|
-
|
|
244
|
-
// Invalid values throw Error
|
|
245
|
-
parseTtl('invalid'); // throws
|
|
92
|
+
parseTtl(60000); // 60000
|
|
246
93
|
```
|
|
247
94
|
|
|
248
|
-
##
|
|
249
|
-
|
|
250
|
-
### Memory (Built-in, Default)
|
|
251
|
-
|
|
252
|
-
In-memory storage for development and testing.
|
|
95
|
+
## Error Classes
|
|
253
96
|
|
|
254
97
|
```typescript
|
|
255
|
-
import {
|
|
256
|
-
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const storage = new MemoryStorage();
|
|
262
|
-
const idempotency = oncely.createInstance({ storage });
|
|
263
|
-
|
|
264
|
-
// Shared singleton
|
|
265
|
-
import { memory } from '@oncely/core';
|
|
266
|
-
const idempotency = oncely.createInstance({ storage: memory });
|
|
267
|
-
```
|
|
268
|
-
|
|
269
|
-
**Pros:**
|
|
270
|
-
|
|
271
|
-
- Zero configuration
|
|
272
|
-
- Fast (no I/O)
|
|
273
|
-
- Easy to test
|
|
274
|
-
|
|
275
|
-
**Cons:**
|
|
276
|
-
|
|
277
|
-
- Data lost on restart
|
|
278
|
-
- Not shared between processes
|
|
279
|
-
- Only suitable for single-instance apps
|
|
280
|
-
|
|
281
|
-
### Redis (Production)
|
|
282
|
-
|
|
283
|
-
For production deployments with ioredis.
|
|
284
|
-
|
|
285
|
-
```bash
|
|
286
|
-
npm install @oncely/redis ioredis
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
```typescript
|
|
290
|
-
import { redis } from '@oncely/redis';
|
|
291
|
-
|
|
292
|
-
// From environment variable (ONCELY_REDIS_URL)
|
|
293
|
-
const storage = redis();
|
|
294
|
-
|
|
295
|
-
// From explicit URL
|
|
296
|
-
const storage = redis('redis://localhost:6379');
|
|
297
|
-
|
|
298
|
-
// With options
|
|
299
|
-
const storage = redis('redis://localhost:6379', {
|
|
300
|
-
keyPrefix: 'oncely:',
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
// From existing client
|
|
304
|
-
import Redis from 'ioredis';
|
|
305
|
-
const client = new Redis(process.env.REDIS_URL);
|
|
306
|
-
const storage = new RedisStorage(client, { keyPrefix: 'oncely:' });
|
|
307
|
-
```
|
|
308
|
-
|
|
309
|
-
See [@oncely/redis](../redis) for full documentation.
|
|
310
|
-
|
|
311
|
-
### Upstash (Serverless)
|
|
312
|
-
|
|
313
|
-
For serverless and edge deployments.
|
|
314
|
-
|
|
315
|
-
```bash
|
|
316
|
-
npm install @oncely/upstash
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
```typescript
|
|
320
|
-
import { upstash } from '@oncely/upstash';
|
|
321
|
-
|
|
322
|
-
// From environment variables
|
|
323
|
-
const storage = upstash();
|
|
324
|
-
|
|
325
|
-
// With explicit config
|
|
326
|
-
const storage = upstash({
|
|
327
|
-
url: process.env.UPSTASH_REDIS_REST_URL,
|
|
328
|
-
token: process.env.UPSTASH_REDIS_REST_TOKEN,
|
|
329
|
-
});
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
See [@oncely/upstash](../upstash) for full documentation.
|
|
333
|
-
|
|
334
|
-
### Custom Adapters
|
|
335
|
-
|
|
336
|
-
Implement the `StorageAdapter` interface for custom backends.
|
|
337
|
-
|
|
338
|
-
```typescript
|
|
339
|
-
import type { StorageAdapter, AcquireResult, StoredResponse } from '@oncely/core';
|
|
340
|
-
|
|
341
|
-
export class DatabaseStorage implements StorageAdapter {
|
|
342
|
-
async acquire(key: string, hash: string | null, ttl: number): Promise<AcquireResult> {
|
|
343
|
-
// Acquire lock and check for existing response
|
|
344
|
-
// Return { status: 'acquired', startedAt: Date.now() }
|
|
345
|
-
// Or { status: 'conflict', startedAt, retryAfter }
|
|
346
|
-
// Or { status: 'mismatch', existingHash, providedHash }
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
async save(key: string, response: StoredResponse): Promise<void> {
|
|
350
|
-
// Save response and release lock
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
async release(key: string): Promise<void> {
|
|
354
|
-
// Release lock without saving
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
async delete(key: string): Promise<void> {
|
|
358
|
-
// Optional: delete stored response
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
async clear(): Promise<void> {
|
|
362
|
-
// Optional: clear all stored responses
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// Usage
|
|
367
|
-
const storage = new DatabaseStorage();
|
|
368
|
-
const idempotency = oncely.createInstance({ storage });
|
|
369
|
-
```
|
|
370
|
-
|
|
371
|
-
## Testing
|
|
372
|
-
|
|
373
|
-
The library includes testing utilities via `@oncely/core/testing`.
|
|
374
|
-
|
|
375
|
-
```typescript
|
|
376
|
-
import { MockStorage, createTestInstance } from '@oncely/core/testing';
|
|
377
|
-
|
|
378
|
-
// Use MockStorage to track operations
|
|
379
|
-
const storage = new MockStorage();
|
|
380
|
-
const idempotency = createTestInstance({ storage });
|
|
381
|
-
|
|
382
|
-
// Run handler
|
|
383
|
-
await idempotency.run({
|
|
384
|
-
key: 'test-key',
|
|
385
|
-
handler: async () => ({ id: 1 }),
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
// Verify operations
|
|
389
|
-
const operations = storage.operations;
|
|
390
|
-
// [
|
|
391
|
-
// { type: 'acquire', key: 'test-key', timestamp: ... },
|
|
392
|
-
// { type: 'save', key: 'test-key', timestamp: ... },
|
|
393
|
-
// ]
|
|
394
|
-
|
|
395
|
-
const saveOps = storage.getOperationsForKey('test-key', 'save');
|
|
396
|
-
expect(saveOps).toHaveLength(1);
|
|
397
|
-
```
|
|
398
|
-
|
|
399
|
-
## Callbacks
|
|
400
|
-
|
|
401
|
-
Hooks for monitoring and logging.
|
|
402
|
-
|
|
403
|
-
```typescript
|
|
404
|
-
oncely.configure({
|
|
405
|
-
onHit: (key, response) => {
|
|
406
|
-
console.log(`Cache hit: ${key}`);
|
|
407
|
-
analytics.track('idempotency_hit', { key });
|
|
408
|
-
},
|
|
409
|
-
|
|
410
|
-
onMiss: (key) => {
|
|
411
|
-
console.log(`Cache miss: ${key}`);
|
|
412
|
-
analytics.track('idempotency_miss', { key });
|
|
413
|
-
},
|
|
414
|
-
|
|
415
|
-
onConflict: (key, error) => {
|
|
416
|
-
console.log(`Conflict: ${key}`);
|
|
417
|
-
metrics.increment('idempotency_conflicts');
|
|
418
|
-
},
|
|
419
|
-
});
|
|
420
|
-
```
|
|
421
|
-
|
|
422
|
-
## Type Definitions
|
|
423
|
-
|
|
424
|
-
Full TypeScript support with strict typing.
|
|
425
|
-
|
|
426
|
-
```typescript
|
|
427
|
-
interface Oncely {
|
|
428
|
-
configure(options: OncelyConfig): void;
|
|
429
|
-
getConfig(): OncelyConfig;
|
|
430
|
-
resetConfig(): void;
|
|
431
|
-
getDefaultStorage(): StorageAdapter;
|
|
432
|
-
createInstance(options?: Partial<OncelyOptions>): OncelyInstance;
|
|
433
|
-
HEADER: string;
|
|
434
|
-
HEADER_REPLAY: string;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
interface OncelyOptions {
|
|
438
|
-
storage: StorageAdapter;
|
|
439
|
-
ttl: number | string;
|
|
440
|
-
debug: boolean;
|
|
441
|
-
onHit?: (key: string, response: StoredResponse) => void;
|
|
442
|
-
onMiss?: (key: string) => void;
|
|
443
|
-
onConflict?: (key: string, error: ConflictError) => void;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
interface OncelyInstance {
|
|
447
|
-
run<T>(options: RunOptions<T>): Promise<RunResult<T>>;
|
|
448
|
-
}
|
|
449
|
-
```
|
|
450
|
-
|
|
451
|
-
## Performance
|
|
452
|
-
|
|
453
|
-
- Small bundle: ~11 KB (ESM)
|
|
454
|
-
- No external dependencies
|
|
455
|
-
- Tree-shakeable exports
|
|
456
|
-
- Efficient hashing (SHA256)
|
|
457
|
-
- Minimal memory overhead
|
|
458
|
-
|
|
459
|
-
## FAQ
|
|
460
|
-
|
|
461
|
-
**Q: Should I use memory storage in production?**
|
|
462
|
-
A: Only for single-instance deployments. Use Redis or Upstash for distributed systems.
|
|
463
|
-
|
|
464
|
-
**Q: How do I generate idempotency keys on the client?**
|
|
465
|
-
A: See [@oncely/client](../client) package.
|
|
466
|
-
|
|
467
|
-
**Q: Can I customize error responses?**
|
|
468
|
-
A: Yes, catch errors and format responses as needed. Framework packages (Express, Next.js) handle this automatically.
|
|
469
|
-
|
|
470
|
-
**Q: How do I migrate from another idempotency library?**
|
|
471
|
-
A: See [migration guide](../../README.md#migration-guide) in root README.
|
|
472
|
-
|
|
473
|
-
## License
|
|
474
|
-
|
|
475
|
-
MIT © stacks0x
|
|
476
|
-
|
|
477
|
-
```typescript
|
|
478
|
-
import { fromEnv } from 'oncely';
|
|
479
|
-
|
|
480
|
-
// Read REDIS_URL or UPSTASH_REDIS_REST_URL + TOKEN
|
|
481
|
-
const storage = fromEnv();
|
|
482
|
-
|
|
483
|
-
// With options
|
|
484
|
-
const storage = fromEnv({
|
|
485
|
-
preferUpstash: true, // Prefer Upstash when both configured
|
|
486
|
-
env: {
|
|
487
|
-
redisUrl: 'MY_REDIS_URL', // Custom env var names
|
|
488
|
-
upstashUrl: 'MY_UPSTASH_URL',
|
|
489
|
-
upstashToken: 'MY_UPSTASH_TOKEN',
|
|
490
|
-
},
|
|
491
|
-
});
|
|
492
|
-
```
|
|
493
|
-
|
|
494
|
-
### `redis(url, options?)`
|
|
495
|
-
|
|
496
|
-
Quick helper to create Redis storage from a URL.
|
|
497
|
-
|
|
498
|
-
```typescript
|
|
499
|
-
import { redis } from 'oncely';
|
|
500
|
-
|
|
501
|
-
const storage = redis('redis://localhost:6379');
|
|
502
|
-
const storage = redis(process.env.REDIS_URL!, { keyPrefix: 'myapp:' });
|
|
503
|
-
```
|
|
504
|
-
|
|
505
|
-
### `idempotency.run(options)`
|
|
506
|
-
|
|
507
|
-
Run an operation with idempotency protection.
|
|
508
|
-
|
|
509
|
-
```typescript
|
|
510
|
-
const result = await idempotency.run({
|
|
511
|
-
key: 'unique-key', // Required: idempotency key
|
|
512
|
-
hash: 'request-hash', // Optional: for mismatch detection
|
|
513
|
-
handler: async () => {
|
|
514
|
-
// Required: your operation
|
|
515
|
-
return { id: 1 };
|
|
516
|
-
},
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
// result.data - your handler's return value
|
|
520
|
-
// result.cached - boolean, was this a cache hit?
|
|
521
|
-
// result.status - 'created' | 'hit'
|
|
522
|
-
// result.createdAt - when the response was created
|
|
523
|
-
```
|
|
524
|
-
|
|
525
|
-
## Errors
|
|
526
|
-
|
|
527
|
-
All errors extend `IdempotencyError` and include a `statusCode` property.
|
|
528
|
-
|
|
529
|
-
| Error | Status | When |
|
|
530
|
-
| ----------------- | ------ | ---------------------------- |
|
|
531
|
-
| `MissingKeyError` | 400 | Key is undefined/null |
|
|
532
|
-
| `ConflictError` | 409 | Same key already in progress |
|
|
533
|
-
| `MismatchError` | 422 | Same key, different hash |
|
|
534
|
-
| `StorageError` | 500 | Storage adapter failed |
|
|
535
|
-
|
|
536
|
-
```typescript
|
|
537
|
-
import { IdempotencyError, ConflictError } from 'oncely';
|
|
538
|
-
|
|
539
|
-
try {
|
|
540
|
-
await idempotency.run({ key, handler });
|
|
541
|
-
} catch (err) {
|
|
542
|
-
if (err instanceof ConflictError) {
|
|
543
|
-
// Return 409 with Retry-After header
|
|
544
|
-
return new Response('Request in progress', {
|
|
545
|
-
status: 409,
|
|
546
|
-
headers: { 'Retry-After': String(err.retryAfter) },
|
|
547
|
-
});
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
```
|
|
551
|
-
|
|
552
|
-
## Utilities
|
|
553
|
-
|
|
554
|
-
```typescript
|
|
555
|
-
import { generateKey, composeKey, hashObject } from 'oncely';
|
|
556
|
-
|
|
557
|
-
// Generate a UUID-like key
|
|
558
|
-
const key = generateKey(); // "f47ac10b-58cc-4372-a567-0e02b2c3d479"
|
|
559
|
-
|
|
560
|
-
// Compose a deterministic key
|
|
561
|
-
const key = composeKey('user', userId, 'order', orderId); // "user:123:order:456"
|
|
562
|
-
|
|
563
|
-
// Hash an object for fingerprinting
|
|
564
|
-
const hash = hashObject({ amount: 100, currency: 'usd' });
|
|
565
|
-
```
|
|
566
|
-
|
|
567
|
-
## Storage Adapters
|
|
568
|
-
|
|
569
|
-
### Memory (Built-in, Default)
|
|
570
|
-
|
|
571
|
-
For development and testing. Used automatically when no storage is configured:
|
|
572
|
-
|
|
573
|
-
```typescript
|
|
574
|
-
import { oncely, memory, MemoryStorage } from 'oncely';
|
|
575
|
-
|
|
576
|
-
// Zero-config — uses shared memory
|
|
577
|
-
const idempotency = oncely();
|
|
578
|
-
|
|
579
|
-
// Explicit memory storage
|
|
580
|
-
const idempotency = oncely({ storage: memory });
|
|
581
|
-
|
|
582
|
-
// Custom instance
|
|
583
|
-
const myStorage = new MemoryStorage();
|
|
584
|
-
const idempotency = oncely({ storage: myStorage });
|
|
98
|
+
import {
|
|
99
|
+
IdempotencyError,
|
|
100
|
+
MissingKeyError, // 400 - Key required but missing
|
|
101
|
+
ConflictError, // 409 - Request in progress
|
|
102
|
+
MismatchError, // 422 - Key reused with different body
|
|
103
|
+
} from '@oncely/core';
|
|
585
104
|
```
|
|
586
105
|
|
|
587
|
-
|
|
106
|
+
All errors extend `IdempotencyError` and include:
|
|
588
107
|
|
|
589
|
-
|
|
108
|
+
- `statusCode` - HTTP status code
|
|
109
|
+
- `toProblemDetails()` - RFC 7807 Problem Details
|
|
590
110
|
|
|
591
|
-
|
|
592
|
-
npm install ioredis
|
|
593
|
-
```
|
|
111
|
+
## Constants
|
|
594
112
|
|
|
595
113
|
```typescript
|
|
596
|
-
import {
|
|
597
|
-
import Redis from 'ioredis';
|
|
114
|
+
import { IDEMPOTENCY_KEY_HEADER, IDEMPOTENCY_REPLAY_HEADER } from '@oncely/core';
|
|
598
115
|
|
|
599
|
-
//
|
|
600
|
-
|
|
601
|
-
storage: redis('redis://localhost:6379'),
|
|
602
|
-
});
|
|
603
|
-
|
|
604
|
-
// With existing client
|
|
605
|
-
const client = new Redis(process.env.REDIS_URL);
|
|
606
|
-
const idempotency = oncely({
|
|
607
|
-
storage: ioredis(client, { keyPrefix: 'idempotent:' }),
|
|
608
|
-
});
|
|
116
|
+
IDEMPOTENCY_KEY_HEADER; // 'Idempotency-Key'
|
|
117
|
+
IDEMPOTENCY_REPLAY_HEADER; // 'Idempotency-Replay'
|
|
609
118
|
```
|
|
610
119
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
For serverless environments:
|
|
614
|
-
|
|
615
|
-
```bash
|
|
616
|
-
npm install @upstash/redis
|
|
617
|
-
```
|
|
120
|
+
## Testing Utilities
|
|
618
121
|
|
|
619
122
|
```typescript
|
|
620
|
-
import {
|
|
621
|
-
import { Redis } from '@upstash/redis';
|
|
123
|
+
import { createTestStorage, MockStorage } from '@oncely/core/testing';
|
|
622
124
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
125
|
+
// Create a test storage with optional pre-seeded data
|
|
126
|
+
const storage = createTestStorage({
|
|
127
|
+
'existing-key': { data: { id: 1 }, createdAt: Date.now(), hash: null },
|
|
626
128
|
});
|
|
627
129
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
130
|
+
// Or use MockStorage for fine-grained control
|
|
131
|
+
const mock = new MockStorage();
|
|
132
|
+
mock.simulateConflict('key');
|
|
133
|
+
mock.simulateError(new Error('Connection failed'));
|
|
631
134
|
```
|
|
632
135
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
Implement the `StorageAdapter` interface:
|
|
136
|
+
## Related Packages
|
|
636
137
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
// Try to acquire lock, return { acquired: true/false, response?: cached }
|
|
643
|
-
},
|
|
644
|
-
async save(key, response) {
|
|
645
|
-
// Save response, release lock
|
|
646
|
-
},
|
|
647
|
-
async release(key) {
|
|
648
|
-
// Release lock without saving
|
|
649
|
-
},
|
|
650
|
-
async delete(key) {
|
|
651
|
-
// Delete stored response (optional)
|
|
652
|
-
},
|
|
653
|
-
async clear() {
|
|
654
|
-
// Clear all stored responses (optional)
|
|
655
|
-
},
|
|
656
|
-
};
|
|
657
|
-
```
|
|
138
|
+
- [@oncely/express](https://www.npmjs.com/package/@oncely/express) - Express middleware
|
|
139
|
+
- [@oncely/next](https://www.npmjs.com/package/@oncely/next) - Next.js integration
|
|
140
|
+
- [@oncely/client](https://www.npmjs.com/package/@oncely/client) - Client utilities
|
|
141
|
+
- [@oncely/redis](https://www.npmjs.com/package/@oncely/redis) - Redis adapter
|
|
142
|
+
- [@oncely/upstash](https://www.npmjs.com/package/@oncely/upstash) - Upstash adapter
|
|
658
143
|
|
|
659
144
|
## License
|
|
660
145
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oncely/core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Idempotency engine for HTTP APIs - ensures operations execute exactly once",
|
|
5
5
|
"author": "stacks0x",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"access": "public"
|
|
49
49
|
},
|
|
50
50
|
"engines": {
|
|
51
|
-
"node": ">=
|
|
51
|
+
"node": ">=20.0.0"
|
|
52
52
|
},
|
|
53
53
|
"scripts": {
|
|
54
54
|
"build": "tsup",
|