@oncely/core 0.2.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/README.md +661 -0
- package/dist/errors-BUehgS6t.d.cts +310 -0
- package/dist/errors-BUehgS6t.d.ts +310 -0
- package/dist/index.cjs +470 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +183 -0
- package/dist/index.d.ts +183 -0
- package/dist/index.js +447 -0
- package/dist/index.js.map +1 -0
- package/dist/testing.cjs +518 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +119 -0
- package/dist/testing.d.ts +119 -0
- package/dist/testing.js +508 -0
- package/dist/testing.js.map +1 -0
- package/package.json +59 -0
package/README.md
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
# @oncely/core
|
|
2
|
+
|
|
3
|
+
Core idempotency library with memory storage adapter. Provides the lean kernel with full TypeScript support.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @oncely/core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { oncely, MemoryStorage } from '@oncely/core';
|
|
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
|
+
});
|
|
31
|
+
|
|
32
|
+
console.log(result.data); // { id: 'ord_123' }
|
|
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
|
+
```
|
|
69
|
+
|
|
70
|
+
### `idempotency.run(options)`
|
|
71
|
+
|
|
72
|
+
Execute a handler with idempotency protection.
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
interface RunOptions<T = any> {
|
|
76
|
+
key: string; // Idempotency key (required)
|
|
77
|
+
hash?: string; // Request hash for mismatch detection
|
|
78
|
+
handler: () => Promise<T>; // Async function to execute
|
|
79
|
+
ttl?: number | string; // Override global TTL
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface RunResult<T = any> {
|
|
83
|
+
data: T; // Handler return value
|
|
84
|
+
cached: boolean; // Was this a cache hit?
|
|
85
|
+
status: 'created' | 'hit'; // Result status
|
|
86
|
+
createdAt: number; // Timestamp of original request
|
|
87
|
+
}
|
|
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
|
+
```
|
|
99
|
+
|
|
100
|
+
## Configuration
|
|
101
|
+
|
|
102
|
+
### Global Configuration
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
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
|
+
```
|
|
128
|
+
|
|
129
|
+
### Per-Instance Configuration
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
const instance = oncely.createInstance({
|
|
133
|
+
storage: redis(),
|
|
134
|
+
ttl: '1h',
|
|
135
|
+
onHit: (key) => analytics.track('cache_hit', { key }),
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Error Handling
|
|
140
|
+
|
|
141
|
+
All errors are instances of `IdempotencyError` and include a `statusCode`.
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
import {
|
|
145
|
+
IdempotencyError,
|
|
146
|
+
MissingKeyError,
|
|
147
|
+
ConflictError,
|
|
148
|
+
MismatchError,
|
|
149
|
+
StorageError,
|
|
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;
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Utilities
|
|
201
|
+
|
|
202
|
+
### Key Generation
|
|
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
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
import { hashObject } from '@oncely/core';
|
|
220
|
+
|
|
221
|
+
// Hash request body for mismatch detection
|
|
222
|
+
const hash = hashObject(req.body);
|
|
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
|
+
});
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### TTL Parsing
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
import { parseTtl } from '@oncely/core';
|
|
237
|
+
|
|
238
|
+
parseTtl(3600000); // 3600000 (milliseconds)
|
|
239
|
+
parseTtl('1h'); // 3600000
|
|
240
|
+
parseTtl('30m'); // 1800000
|
|
241
|
+
parseTtl('5s'); // 5000
|
|
242
|
+
parseTtl('1d'); // 86400000
|
|
243
|
+
|
|
244
|
+
// Invalid values throw Error
|
|
245
|
+
parseTtl('invalid'); // throws
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Storage Adapters
|
|
249
|
+
|
|
250
|
+
### Memory (Built-in, Default)
|
|
251
|
+
|
|
252
|
+
In-memory storage for development and testing.
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
import { oncely, MemoryStorage } from '@oncely/core';
|
|
256
|
+
|
|
257
|
+
// Implicit (default)
|
|
258
|
+
const idempotency = oncely.createInstance();
|
|
259
|
+
|
|
260
|
+
// Explicit
|
|
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 });
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
### Redis with ioredis
|
|
588
|
+
|
|
589
|
+
For production with self-hosted Redis or managed services:
|
|
590
|
+
|
|
591
|
+
```bash
|
|
592
|
+
npm install ioredis
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
```typescript
|
|
596
|
+
import { oncely, redis, ioredis } from 'oncely';
|
|
597
|
+
import Redis from 'ioredis';
|
|
598
|
+
|
|
599
|
+
// Quick setup from URL
|
|
600
|
+
const idempotency = oncely({
|
|
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
|
+
});
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
### Upstash / Vercel KV
|
|
612
|
+
|
|
613
|
+
For serverless environments:
|
|
614
|
+
|
|
615
|
+
```bash
|
|
616
|
+
npm install @upstash/redis
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
```typescript
|
|
620
|
+
import { oncely, upstash } from 'oncely';
|
|
621
|
+
import { Redis } from '@upstash/redis';
|
|
622
|
+
|
|
623
|
+
const redis = new Redis({
|
|
624
|
+
url: process.env.UPSTASH_REDIS_REST_URL!,
|
|
625
|
+
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
const idempotency = oncely({
|
|
629
|
+
storage: upstash(redis, { keyPrefix: 'idempotent:' }),
|
|
630
|
+
});
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
### Custom Adapters
|
|
634
|
+
|
|
635
|
+
Implement the `StorageAdapter` interface:
|
|
636
|
+
|
|
637
|
+
```typescript
|
|
638
|
+
import type { StorageAdapter } from 'oncely';
|
|
639
|
+
|
|
640
|
+
const myAdapter: StorageAdapter = {
|
|
641
|
+
async acquire(key, hash, ttl) {
|
|
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
|
+
```
|
|
658
|
+
|
|
659
|
+
## License
|
|
660
|
+
|
|
661
|
+
MIT
|