@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 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