request-ledger 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/dist/index.cjs ADDED
@@ -0,0 +1,1059 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Request Ledger - Type Definitions
5
+ *
6
+ * This file contains all public TypeScript interfaces and types
7
+ * for the request-ledger library.
8
+ */
9
+ // =============================================================================
10
+ // Error Types
11
+ // =============================================================================
12
+ /**
13
+ * Base error class for request-ledger errors.
14
+ */
15
+ class LedgerError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = 'LedgerError';
19
+ }
20
+ }
21
+ /**
22
+ * Error thrown when persistence fails.
23
+ */
24
+ class PersistenceError extends LedgerError {
25
+ constructor(message, cause) {
26
+ super(message);
27
+ this.cause = cause;
28
+ this.name = 'PersistenceError';
29
+ }
30
+ }
31
+ /**
32
+ * Error thrown when a network request fails.
33
+ */
34
+ class NetworkError extends LedgerError {
35
+ constructor(message, cause) {
36
+ super(message);
37
+ this.cause = cause;
38
+ this.name = 'NetworkError';
39
+ }
40
+ }
41
+ /**
42
+ * Error thrown when an entry is not found.
43
+ */
44
+ class EntryNotFoundError extends LedgerError {
45
+ constructor(entryId) {
46
+ super(`Entry not found: ${entryId}`);
47
+ this.entryId = entryId;
48
+ this.name = 'EntryNotFoundError';
49
+ }
50
+ }
51
+ /**
52
+ * Error thrown when a duplicate entry is detected.
53
+ */
54
+ class DuplicateEntryError extends LedgerError {
55
+ constructor(entryId) {
56
+ super(`Duplicate entry: ${entryId}`);
57
+ this.entryId = entryId;
58
+ this.name = 'DuplicateEntryError';
59
+ }
60
+ }
61
+
62
+ /**
63
+ * IndexedDB Storage Adapter
64
+ *
65
+ * Implements the LedgerStorage interface using IndexedDB for
66
+ * persistent, reliable storage that survives page reloads.
67
+ */
68
+ const DEFAULT_DB_NAME = 'request-ledger';
69
+ const DEFAULT_STORE_NAME = 'entries';
70
+ const DEFAULT_MAX_ENTRIES = 1000;
71
+ const DB_VERSION = 1;
72
+ /**
73
+ * IndexedDB implementation of LedgerStorage.
74
+ *
75
+ * Features:
76
+ * - Atomic writes using transactions
77
+ * - Entries ordered by createdAt
78
+ * - Max size enforcement with oldest-first eviction
79
+ * - Proper error handling
80
+ */
81
+ class IndexedDBStorage {
82
+ constructor(config = {}) {
83
+ this.db = null;
84
+ this.dbPromise = null;
85
+ this.dbName = config.dbName ?? DEFAULT_DB_NAME;
86
+ this.storeName = config.storeName ?? DEFAULT_STORE_NAME;
87
+ this.maxEntries = config.maxEntries ?? DEFAULT_MAX_ENTRIES;
88
+ }
89
+ /**
90
+ * Get or initialize the database connection.
91
+ */
92
+ async getDb() {
93
+ if (this.db) {
94
+ return this.db;
95
+ }
96
+ if (this.dbPromise) {
97
+ return this.dbPromise;
98
+ }
99
+ this.dbPromise = new Promise((resolve, reject) => {
100
+ const request = indexedDB.open(this.dbName, DB_VERSION);
101
+ request.onerror = () => {
102
+ reject(new PersistenceError('Failed to open IndexedDB', request.error ?? undefined));
103
+ };
104
+ request.onsuccess = () => {
105
+ this.db = request.result;
106
+ resolve(request.result);
107
+ };
108
+ request.onupgradeneeded = (event) => {
109
+ const db = event.target.result;
110
+ // Create object store if it doesn't exist
111
+ if (!db.objectStoreNames.contains(this.storeName)) {
112
+ const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
113
+ // Create indexes for efficient querying
114
+ store.createIndex('status', 'status', { unique: false });
115
+ store.createIndex('createdAt', 'createdAt', { unique: false });
116
+ store.createIndex('idempotencyKey', 'idempotencyKey', { unique: false });
117
+ }
118
+ };
119
+ });
120
+ return this.dbPromise;
121
+ }
122
+ /**
123
+ * Execute a transaction and return a promise.
124
+ */
125
+ async transaction(mode, operation) {
126
+ const db = await this.getDb();
127
+ return new Promise((resolve, reject) => {
128
+ const tx = db.transaction(this.storeName, mode);
129
+ const store = tx.objectStore(this.storeName);
130
+ const request = operation(store);
131
+ request.onsuccess = () => {
132
+ resolve(request.result);
133
+ };
134
+ request.onerror = () => {
135
+ reject(new PersistenceError('Transaction failed', request.error ?? undefined));
136
+ };
137
+ tx.onerror = () => {
138
+ reject(new PersistenceError('Transaction failed', tx.error ?? undefined));
139
+ };
140
+ });
141
+ }
142
+ /**
143
+ * Store a new entry.
144
+ * Throws DuplicateEntryError if entry with same ID exists.
145
+ * Evicts oldest entries if maxEntries is exceeded.
146
+ */
147
+ async put(entry) {
148
+ const db = await this.getDb();
149
+ return new Promise((resolve, reject) => {
150
+ const tx = db.transaction(this.storeName, 'readwrite');
151
+ const store = tx.objectStore(this.storeName);
152
+ // First check if entry already exists
153
+ const getRequest = store.get(entry.id);
154
+ getRequest.onsuccess = () => {
155
+ if (getRequest.result) {
156
+ reject(new DuplicateEntryError(entry.id));
157
+ return;
158
+ }
159
+ // Serialize body and metadata for storage
160
+ const storedEntry = {
161
+ ...entry,
162
+ request: {
163
+ ...entry.request,
164
+ body: JSON.stringify(entry.request.body),
165
+ },
166
+ metadata: entry.metadata ? JSON.stringify(entry.metadata) : undefined,
167
+ };
168
+ const addRequest = store.add(storedEntry);
169
+ addRequest.onsuccess = () => {
170
+ // Check if we need to evict old entries
171
+ this.evictIfNeeded(store).then(resolve).catch(reject);
172
+ };
173
+ addRequest.onerror = () => {
174
+ reject(new PersistenceError('Failed to add entry', addRequest.error ?? undefined));
175
+ };
176
+ };
177
+ getRequest.onerror = () => {
178
+ reject(new PersistenceError('Failed to check for existing entry', getRequest.error ?? undefined));
179
+ };
180
+ tx.onerror = () => {
181
+ reject(new PersistenceError('Transaction failed', tx.error ?? undefined));
182
+ };
183
+ });
184
+ }
185
+ /**
186
+ * Evict oldest entries if count exceeds maxEntries.
187
+ */
188
+ async evictIfNeeded(store) {
189
+ return new Promise((resolve, reject) => {
190
+ const countRequest = store.count();
191
+ countRequest.onsuccess = () => {
192
+ const count = countRequest.result;
193
+ if (count <= this.maxEntries) {
194
+ resolve();
195
+ return;
196
+ }
197
+ const toDelete = count - this.maxEntries;
198
+ const index = store.index('createdAt');
199
+ const cursorRequest = index.openCursor();
200
+ let deleted = 0;
201
+ cursorRequest.onsuccess = () => {
202
+ const cursor = cursorRequest.result;
203
+ if (cursor && deleted < toDelete) {
204
+ store.delete(cursor.primaryKey);
205
+ deleted++;
206
+ cursor.continue();
207
+ }
208
+ else {
209
+ resolve();
210
+ }
211
+ };
212
+ cursorRequest.onerror = () => {
213
+ reject(new PersistenceError('Failed to evict entries', cursorRequest.error ?? undefined));
214
+ };
215
+ };
216
+ countRequest.onerror = () => {
217
+ reject(new PersistenceError('Failed to count entries', countRequest.error ?? undefined));
218
+ };
219
+ });
220
+ }
221
+ /**
222
+ * Get all entries ordered by createdAt ascending.
223
+ */
224
+ async getAll() {
225
+ const db = await this.getDb();
226
+ return new Promise((resolve, reject) => {
227
+ const tx = db.transaction(this.storeName, 'readonly');
228
+ const store = tx.objectStore(this.storeName);
229
+ const index = store.index('createdAt');
230
+ const request = index.getAll();
231
+ request.onsuccess = () => {
232
+ const entries = request.result.map(this.deserializeEntry);
233
+ resolve(entries);
234
+ };
235
+ request.onerror = () => {
236
+ reject(new PersistenceError('Failed to get entries', request.error ?? undefined));
237
+ };
238
+ });
239
+ }
240
+ /**
241
+ * Get a single entry by ID.
242
+ */
243
+ async get(id) {
244
+ const result = await this.transaction('readonly', (store) => store.get(id));
245
+ return result ? this.deserializeEntry(result) : undefined;
246
+ }
247
+ /**
248
+ * Update an existing entry.
249
+ */
250
+ async update(id, patch) {
251
+ const db = await this.getDb();
252
+ return new Promise((resolve, reject) => {
253
+ const tx = db.transaction(this.storeName, 'readwrite');
254
+ const store = tx.objectStore(this.storeName);
255
+ const getRequest = store.get(id);
256
+ getRequest.onsuccess = () => {
257
+ const existing = getRequest.result;
258
+ if (!existing) {
259
+ reject(new EntryNotFoundError(id));
260
+ return;
261
+ }
262
+ // Merge patch with existing entry
263
+ const updated = { ...existing };
264
+ if (patch.status !== undefined)
265
+ updated.status = patch.status;
266
+ if (patch.attemptCount !== undefined)
267
+ updated.attemptCount = patch.attemptCount;
268
+ if (patch.lastAttemptAt !== undefined)
269
+ updated.lastAttemptAt = patch.lastAttemptAt;
270
+ // Allow explicitly clearing error by checking if key exists in patch
271
+ if ('error' in patch) {
272
+ if (patch.error === undefined) {
273
+ delete updated.error;
274
+ }
275
+ else {
276
+ updated.error = patch.error;
277
+ }
278
+ }
279
+ const putRequest = store.put(updated);
280
+ putRequest.onsuccess = () => resolve();
281
+ putRequest.onerror = () => {
282
+ reject(new PersistenceError('Failed to update entry', putRequest.error ?? undefined));
283
+ };
284
+ };
285
+ getRequest.onerror = () => {
286
+ reject(new PersistenceError('Failed to get entry for update', getRequest.error ?? undefined));
287
+ };
288
+ tx.onerror = () => {
289
+ reject(new PersistenceError('Transaction failed', tx.error ?? undefined));
290
+ };
291
+ });
292
+ }
293
+ /**
294
+ * Remove an entry by ID.
295
+ */
296
+ async remove(id) {
297
+ await this.transaction('readwrite', (store) => store.delete(id));
298
+ }
299
+ /**
300
+ * Remove all entries.
301
+ */
302
+ async clear() {
303
+ await this.transaction('readwrite', (store) => store.clear());
304
+ }
305
+ /**
306
+ * Get the count of entries.
307
+ */
308
+ async count() {
309
+ return this.transaction('readonly', (store) => store.count());
310
+ }
311
+ /**
312
+ * Deserialize an entry from storage.
313
+ */
314
+ deserializeEntry(stored) {
315
+ const request = stored['request'];
316
+ return {
317
+ id: stored['id'],
318
+ request: {
319
+ url: request['url'],
320
+ method: request['method'],
321
+ headers: request['headers'],
322
+ body: request['body'] ? JSON.parse(request['body']) : undefined,
323
+ },
324
+ status: stored['status'],
325
+ attemptCount: stored['attemptCount'],
326
+ createdAt: stored['createdAt'],
327
+ lastAttemptAt: stored['lastAttemptAt'],
328
+ error: stored['error'],
329
+ idempotencyKey: stored['idempotencyKey'],
330
+ metadata: stored['metadata']
331
+ ? JSON.parse(stored['metadata'])
332
+ : undefined,
333
+ };
334
+ }
335
+ /**
336
+ * Close the database connection.
337
+ */
338
+ close() {
339
+ if (this.db) {
340
+ this.db.close();
341
+ this.db = null;
342
+ this.dbPromise = null;
343
+ }
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Online Detection Module
349
+ *
350
+ * Provides reliable online detection that doesn't rely solely on navigator.onLine.
351
+ * Supports custom ping endpoints and user-provided check functions.
352
+ */
353
+ const DEFAULT_PING_TIMEOUT = 5000;
354
+ /**
355
+ * Creates an online checker function based on the provided configuration.
356
+ *
357
+ * The checker combines multiple signals:
358
+ * 1. navigator.onLine (fast but unreliable)
359
+ * 2. Optional ping endpoint (reliable but slower)
360
+ * 3. Custom check function (user-defined)
361
+ *
362
+ * @param config Online check configuration
363
+ * @returns Function that returns true if online, false if offline
364
+ */
365
+ function createOnlineChecker(config = {}) {
366
+ const { pingUrl, pingTimeout = DEFAULT_PING_TIMEOUT, customCheck } = config;
367
+ // If user provided a custom check, use it
368
+ if (customCheck) {
369
+ return customCheck;
370
+ }
371
+ // If no ping URL, use navigator.onLine only
372
+ if (!pingUrl) {
373
+ return async () => {
374
+ return typeof navigator !== 'undefined' ? navigator.onLine : true;
375
+ };
376
+ }
377
+ // Combine navigator.onLine with ping check
378
+ return async () => {
379
+ // Fast path: if navigator says offline, trust it
380
+ if (typeof navigator !== 'undefined' && !navigator.onLine) {
381
+ return false;
382
+ }
383
+ // Ping the endpoint to confirm connectivity
384
+ try {
385
+ const controller = new AbortController();
386
+ const timeoutId = setTimeout(() => controller.abort(), pingTimeout);
387
+ const response = await fetch(pingUrl, {
388
+ method: 'HEAD',
389
+ mode: 'no-cors', // Allow cross-origin pings
390
+ cache: 'no-store',
391
+ signal: controller.signal,
392
+ });
393
+ clearTimeout(timeoutId);
394
+ // In no-cors mode, we can't read the response, but if we get here
395
+ // without an error, the request succeeded
396
+ return response.type === 'opaque' || response.ok;
397
+ }
398
+ catch (error) {
399
+ // Any error (network, DNS, timeout, abort) means offline
400
+ return false;
401
+ }
402
+ };
403
+ }
404
+ /**
405
+ * Check if an error indicates a network failure (vs application error).
406
+ *
407
+ * This is used to determine if a request should be queued vs reported as failed.
408
+ */
409
+ function isNetworkError(error) {
410
+ if (error instanceof TypeError) {
411
+ // Fetch throws TypeError for network errors with specific messages
412
+ const message = error.message.toLowerCase();
413
+ // Check for specific network error patterns from browsers
414
+ return (message === 'failed to fetch' || // Chrome, Edge
415
+ message === 'network request failed' || // Safari
416
+ message === 'load failed' || // Safari
417
+ message === 'networkerror' || // Firefox
418
+ message.startsWith('networkerror when') // Firefox detailed
419
+ );
420
+ }
421
+ if (error instanceof DOMException) {
422
+ // AbortError from timeout or manual abort
423
+ return error.name === 'AbortError';
424
+ }
425
+ return false;
426
+ }
427
+ /**
428
+ * Check if an HTTP status code indicates a retryable server error.
429
+ *
430
+ * Returns true for 5xx errors (server errors).
431
+ * Returns false for 4xx errors (client errors - these should not be retried).
432
+ */
433
+ function isRetryableStatusCode(status) {
434
+ return status >= 500 && status < 600;
435
+ }
436
+ /**
437
+ * Check if an HTTP status code indicates a client error (non-retryable).
438
+ */
439
+ function isClientError(status) {
440
+ return status >= 400 && status < 500;
441
+ }
442
+
443
+ /**
444
+ * Backoff Utilities
445
+ *
446
+ * Provides delay calculation for retry strategies.
447
+ */
448
+ /**
449
+ * Calculate the delay before the next retry attempt.
450
+ *
451
+ * @param strategy The retry strategy configuration
452
+ * @param attemptCount The number of attempts made so far (1-indexed)
453
+ * @returns Delay in milliseconds, or null if max attempts reached
454
+ */
455
+ function calculateBackoffDelay(strategy, attemptCount) {
456
+ switch (strategy.type) {
457
+ case 'fixed': {
458
+ if (attemptCount >= strategy.maxAttempts) {
459
+ return null;
460
+ }
461
+ return strategy.delayMs;
462
+ }
463
+ case 'exponential': {
464
+ if (attemptCount >= strategy.maxAttempts) {
465
+ return null;
466
+ }
467
+ // Exponential backoff: baseMs * 2^(attempt-1)
468
+ const delay = strategy.baseMs * Math.pow(2, attemptCount - 1);
469
+ return Math.min(delay, strategy.maxMs);
470
+ }
471
+ case 'manual': {
472
+ // Manual strategy never auto-retries
473
+ return null;
474
+ }
475
+ }
476
+ }
477
+ /**
478
+ * Check if more retry attempts are allowed.
479
+ *
480
+ * @param strategy The retry strategy configuration
481
+ * @param attemptCount The number of attempts made so far
482
+ * @returns true if more attempts are allowed
483
+ */
484
+ function canRetry(strategy, attemptCount) {
485
+ if (strategy.type === 'manual') {
486
+ // Manual strategy allows retries but user must trigger them
487
+ return true;
488
+ }
489
+ return attemptCount < strategy.maxAttempts;
490
+ }
491
+ /**
492
+ * Create a promise that resolves after the specified delay.
493
+ */
494
+ function delay(ms) {
495
+ return new Promise((resolve) => setTimeout(resolve, ms));
496
+ }
497
+ /**
498
+ * Default retry strategy.
499
+ */
500
+ const DEFAULT_RETRY_STRATEGY = {
501
+ type: 'exponential',
502
+ baseMs: 1000,
503
+ maxMs: 30000,
504
+ maxAttempts: 3,
505
+ };
506
+
507
+ /**
508
+ * Replay Engine
509
+ *
510
+ * Handles the ordered processing of queued requests.
511
+ * Ensures crash-safety and deterministic processing.
512
+ */
513
+ /**
514
+ * The replay engine processes queued requests in order.
515
+ *
516
+ * Key behaviors:
517
+ * - Processes entries in insertion order (by createdAt)
518
+ * - Single processing loop at a time (no parallel process() calls)
519
+ * - Crash-safe: marks stale 'processing' entries as 'pending' on start
520
+ * - Respects concurrency limit
521
+ * - Stops on first error if stopOnError is true
522
+ */
523
+ class ReplayEngine {
524
+ constructor(config) {
525
+ this.isProcessing = false;
526
+ this.isPaused = false;
527
+ this.lastError = null;
528
+ this.abortController = null;
529
+ this.storage = config.storage;
530
+ this.onlineCheck = config.onlineCheck;
531
+ this.retry = config.retry;
532
+ this.hooks = config.hooks;
533
+ this.idempotencyHeader = config.idempotencyHeader;
534
+ }
535
+ /**
536
+ * Get the current state of the replay engine.
537
+ */
538
+ async getState() {
539
+ if (this.isPaused) {
540
+ return 'paused';
541
+ }
542
+ if (this.isProcessing) {
543
+ return 'processing';
544
+ }
545
+ if (this.lastError) {
546
+ return 'error';
547
+ }
548
+ const entries = await this.storage.getAll();
549
+ const hasPending = entries.some(e => e.status === 'pending' || e.status === 'processing');
550
+ return hasPending ? 'pending' : 'idle';
551
+ }
552
+ /**
553
+ * Process pending entries in the queue.
554
+ *
555
+ * @param options Processing options
556
+ */
557
+ async process(options = {}) {
558
+ const { concurrency = 1, stopOnError = true, onSuccess, onFailure, } = options;
559
+ // Prevent multiple concurrent process() calls
560
+ if (this.isProcessing) {
561
+ return;
562
+ }
563
+ this.isProcessing = true;
564
+ this.lastError = null;
565
+ this.abortController = new AbortController();
566
+ try {
567
+ // Crash recovery: mark any 'processing' entries as 'pending'
568
+ await this.recoverStaleEntries();
569
+ // Process loop
570
+ while (!this.isPaused && !this.abortController.signal.aborted) {
571
+ // Check if we're online
572
+ const online = await this.onlineCheck();
573
+ if (!online) {
574
+ // Wait a bit and check again
575
+ await delay(1000);
576
+ continue;
577
+ }
578
+ // Get pending entries
579
+ const entries = await this.storage.getAll();
580
+ const pending = entries.filter(e => e.status === 'pending');
581
+ if (pending.length === 0) {
582
+ break;
583
+ }
584
+ // Process up to 'concurrency' entries in parallel
585
+ const batch = pending.slice(0, concurrency);
586
+ const results = await Promise.allSettled(batch.map(entry => this.processEntry(entry)));
587
+ // Handle results
588
+ let hasError = false;
589
+ for (let i = 0; i < results.length; i++) {
590
+ const result = results[i];
591
+ const entry = batch[i];
592
+ if (!entry || !result)
593
+ continue;
594
+ if (result.status === 'fulfilled') {
595
+ // Entry processed successfully
596
+ await this.storage.remove(entry.id);
597
+ onSuccess?.(entry);
598
+ }
599
+ else {
600
+ // Entry failed
601
+ hasError = true;
602
+ const error = result.reason instanceof Error
603
+ ? result.reason
604
+ : new Error(String(result.reason));
605
+ // Get updated entry from storage (it may have been updated)
606
+ const updatedEntry = await this.storage.get(entry.id);
607
+ if (updatedEntry) {
608
+ onFailure?.(updatedEntry, error);
609
+ }
610
+ if (stopOnError) {
611
+ this.lastError = error;
612
+ break;
613
+ }
614
+ }
615
+ }
616
+ // Stop if we encountered an error and stopOnError is true
617
+ if (hasError && stopOnError) {
618
+ break;
619
+ }
620
+ }
621
+ }
622
+ finally {
623
+ this.isProcessing = false;
624
+ this.abortController = null;
625
+ }
626
+ }
627
+ /**
628
+ * Process a single entry.
629
+ *
630
+ * @param entry The entry to process
631
+ * @throws Error if processing fails
632
+ */
633
+ async processEntry(entry) {
634
+ // Mark as processing
635
+ await this.storage.update(entry.id, {
636
+ status: 'processing',
637
+ lastAttemptAt: Date.now(),
638
+ attemptCount: entry.attemptCount + 1,
639
+ });
640
+ // Fire replay start hook
641
+ this.hooks.onReplayStart?.(entry);
642
+ try {
643
+ // Build the request
644
+ const headers = new Headers(entry.request.headers);
645
+ // Add idempotency key if present
646
+ if (entry.idempotencyKey) {
647
+ headers.set(this.idempotencyHeader, entry.idempotencyKey);
648
+ }
649
+ // Determine body
650
+ let body;
651
+ if (entry.request.body !== undefined && entry.request.body !== null) {
652
+ body = JSON.stringify(entry.request.body);
653
+ if (!headers.has('Content-Type')) {
654
+ headers.set('Content-Type', 'application/json');
655
+ }
656
+ }
657
+ // Make the request
658
+ const response = await fetch(entry.request.url, {
659
+ method: entry.request.method,
660
+ headers,
661
+ body,
662
+ signal: this.abortController?.signal,
663
+ });
664
+ // Check for client errors (4xx) - not retryable
665
+ if (isClientError(response.status)) {
666
+ const error = new Error(`HTTP ${response.status}: Client error`);
667
+ await this.markAsFailed(entry, error, response.status.toString());
668
+ this.hooks.onReplayFailure?.(entry, error);
669
+ throw error;
670
+ }
671
+ // Check for server errors (5xx) - retryable
672
+ if (isRetryableStatusCode(response.status)) {
673
+ const canRetryMore = this.canRetryEntry(entry);
674
+ if (canRetryMore) {
675
+ // Mark back as pending for retry
676
+ await this.storage.update(entry.id, { status: 'pending' });
677
+ // Wait for backoff delay
678
+ const backoffDelay = calculateBackoffDelay(this.retry, entry.attemptCount + 1);
679
+ if (backoffDelay !== null) {
680
+ await delay(backoffDelay);
681
+ }
682
+ throw new Error(`HTTP ${response.status}: Server error, will retry`);
683
+ }
684
+ else {
685
+ // No more retries, mark as failed
686
+ const error = new Error(`HTTP ${response.status}: Server error, max retries exceeded`);
687
+ await this.markAsFailed(entry, error, response.status.toString());
688
+ this.hooks.onReplayFailure?.(entry, error);
689
+ throw error;
690
+ }
691
+ }
692
+ // Success! Fire success hook
693
+ this.hooks.onReplaySuccess?.(entry, response);
694
+ }
695
+ catch (error) {
696
+ // Check if it's a network error
697
+ if (isNetworkError(error)) {
698
+ const canRetryMore = this.canRetryEntry(entry);
699
+ if (canRetryMore) {
700
+ // Mark back as pending for retry
701
+ await this.storage.update(entry.id, { status: 'pending' });
702
+ // Wait for backoff delay
703
+ const backoffDelay = calculateBackoffDelay(this.retry, entry.attemptCount + 1);
704
+ if (backoffDelay !== null) {
705
+ await delay(backoffDelay);
706
+ }
707
+ }
708
+ else {
709
+ // No more retries, mark as failed
710
+ const networkError = new NetworkError('Network error, max retries exceeded', error instanceof Error ? error : undefined);
711
+ await this.markAsFailed(entry, networkError, 'NETWORK_ERROR');
712
+ this.hooks.onReplayFailure?.(entry, networkError);
713
+ }
714
+ throw error;
715
+ }
716
+ // Re-throw other errors (they were already handled above)
717
+ throw error;
718
+ }
719
+ }
720
+ /**
721
+ * Check if entry can be retried based on retry strategy.
722
+ */
723
+ canRetryEntry(entry) {
724
+ if (this.retry.type === 'manual') {
725
+ return false; // Manual retries don't auto-retry
726
+ }
727
+ return entry.attemptCount + 1 < this.retry.maxAttempts;
728
+ }
729
+ /**
730
+ * Mark an entry as failed.
731
+ */
732
+ async markAsFailed(entry, error, code) {
733
+ await this.storage.update(entry.id, {
734
+ status: 'failed',
735
+ error: {
736
+ message: error.message,
737
+ code,
738
+ },
739
+ });
740
+ }
741
+ /**
742
+ * Recover stale 'processing' entries.
743
+ *
744
+ * This handles crash recovery: if the page was closed while
745
+ * processing, entries would be stuck in 'processing' state.
746
+ */
747
+ async recoverStaleEntries() {
748
+ const entries = await this.storage.getAll();
749
+ for (const entry of entries) {
750
+ if (entry.status === 'processing') {
751
+ await this.storage.update(entry.id, { status: 'pending' });
752
+ }
753
+ }
754
+ }
755
+ /**
756
+ * Pause processing.
757
+ */
758
+ pause() {
759
+ this.isPaused = true;
760
+ this.abortController?.abort();
761
+ }
762
+ /**
763
+ * Resume processing.
764
+ */
765
+ resume() {
766
+ this.isPaused = false;
767
+ }
768
+ /**
769
+ * Check if processing is paused.
770
+ */
771
+ get paused() {
772
+ return this.isPaused;
773
+ }
774
+ /**
775
+ * Check if currently processing.
776
+ */
777
+ get processing() {
778
+ return this.isProcessing;
779
+ }
780
+ }
781
+
782
+ /**
783
+ * Request Ledger
784
+ *
785
+ * A durable, client-side HTTP request ledger for web applications
786
+ * operating on unreliable networks.
787
+ *
788
+ * Core behaviors:
789
+ * - Records API request intent when offline or network is unstable
790
+ * - Persists requests across page reloads, crashes, and browser restarts
791
+ * - Replays requests deterministically when connectivity is restored
792
+ * - Never silently drops requests
793
+ * - Never assumes business-level conflict resolution
794
+ */
795
+ const DEFAULT_IDEMPOTENCY_HEADER = 'X-Idempotency-Key';
796
+ /**
797
+ * The main RequestLedger class.
798
+ *
799
+ * Provides a durable request queue that persists across page reloads
800
+ * and replays requests when connectivity is restored.
801
+ */
802
+ class RequestLedger {
803
+ constructor(config = {}) {
804
+ this.isDestroyed = false;
805
+ // Initialize storage
806
+ this.storage = config.storage ?? new IndexedDBStorage(config.storageConfig);
807
+ // Initialize online checker
808
+ this.onlineCheck = createOnlineChecker(config.onlineCheck);
809
+ // Set retry strategy
810
+ this.retryStrategy = config.retry ?? DEFAULT_RETRY_STRATEGY;
811
+ // Set hooks
812
+ this.hooks = config.hooks ?? {};
813
+ // Set idempotency header
814
+ this.idempotencyHeader = config.idempotencyHeader ?? DEFAULT_IDEMPOTENCY_HEADER;
815
+ // Initialize replay engine
816
+ this.replayEngine = new ReplayEngine({
817
+ storage: this.storage,
818
+ onlineCheck: this.onlineCheck,
819
+ retry: this.retryStrategy,
820
+ hooks: this.hooks,
821
+ idempotencyHeader: this.idempotencyHeader,
822
+ });
823
+ }
824
+ /**
825
+ * Make a request through the ledger.
826
+ *
827
+ * Behavior:
828
+ * - If online → attempt immediately
829
+ * - If offline or request fails due to network → persist to ledger
830
+ * - If persistence fails → throw explicitly
831
+ *
832
+ * @param options The request options
833
+ * @returns Response if request succeeded immediately, void if queued
834
+ */
835
+ async request(options) {
836
+ this.ensureNotDestroyed();
837
+ // Check if online
838
+ const online = await this.onlineCheck();
839
+ if (online) {
840
+ // Try to make the request immediately
841
+ try {
842
+ const response = await this.executeRequest(options);
843
+ return response;
844
+ }
845
+ catch (error) {
846
+ // If it's a network error, queue the request
847
+ if (isNetworkError(error)) {
848
+ await this.persistRequest(options);
849
+ return;
850
+ }
851
+ // Re-throw non-network errors
852
+ throw error;
853
+ }
854
+ }
855
+ else {
856
+ // Offline: queue the request
857
+ await this.persistRequest(options);
858
+ }
859
+ }
860
+ /**
861
+ * Execute an HTTP request.
862
+ */
863
+ async executeRequest(options) {
864
+ const { url, method, headers = {}, body, idempotencyKey } = options;
865
+ const requestHeaders = new Headers(headers);
866
+ // Add idempotency key if present
867
+ if (idempotencyKey) {
868
+ requestHeaders.set(this.idempotencyHeader, idempotencyKey);
869
+ }
870
+ // Determine body
871
+ let requestBody;
872
+ if (body !== undefined && body !== null) {
873
+ requestBody = JSON.stringify(body);
874
+ if (!requestHeaders.has('Content-Type')) {
875
+ requestHeaders.set('Content-Type', 'application/json');
876
+ }
877
+ }
878
+ return fetch(url, {
879
+ method,
880
+ headers: requestHeaders,
881
+ body: requestBody ?? null,
882
+ });
883
+ }
884
+ /**
885
+ * Persist a request to the ledger.
886
+ */
887
+ async persistRequest(options) {
888
+ const entry = {
889
+ id: options.id,
890
+ request: {
891
+ url: options.url,
892
+ method: options.method,
893
+ headers: options.headers ?? {},
894
+ body: options.body,
895
+ },
896
+ status: 'pending',
897
+ attemptCount: 0,
898
+ createdAt: Date.now(),
899
+ ...(options.idempotencyKey && { idempotencyKey: options.idempotencyKey }),
900
+ ...(options.metadata && { metadata: options.metadata }),
901
+ };
902
+ try {
903
+ await this.storage.put(entry);
904
+ // Fire onPersist hook
905
+ this.hooks.onPersist?.(entry);
906
+ }
907
+ catch (error) {
908
+ if (error instanceof PersistenceError) {
909
+ throw error;
910
+ }
911
+ throw new PersistenceError('Failed to persist request to ledger', error instanceof Error ? error : undefined);
912
+ }
913
+ }
914
+ /**
915
+ * Process pending entries in the ledger.
916
+ *
917
+ * @param options Processing options
918
+ */
919
+ async process(options = {}) {
920
+ this.ensureNotDestroyed();
921
+ await this.replayEngine.process(options);
922
+ }
923
+ /**
924
+ * Pause processing.
925
+ */
926
+ pause() {
927
+ this.ensureNotDestroyed();
928
+ this.replayEngine.pause();
929
+ }
930
+ /**
931
+ * Resume processing.
932
+ */
933
+ resume() {
934
+ this.ensureNotDestroyed();
935
+ this.replayEngine.resume();
936
+ }
937
+ /**
938
+ * Clear all completed entries.
939
+ *
940
+ * Note: In this implementation, completed entries are automatically
941
+ * removed, so this is a no-op. Provided for API completeness.
942
+ */
943
+ async clearCompleted() {
944
+ this.ensureNotDestroyed();
945
+ const entries = await this.storage.getAll();
946
+ for (const entry of entries) {
947
+ if (entry.status === 'completed') {
948
+ await this.storage.remove(entry.id);
949
+ }
950
+ }
951
+ }
952
+ /**
953
+ * Get the current state of the ledger.
954
+ */
955
+ async getState() {
956
+ this.ensureNotDestroyed();
957
+ return this.replayEngine.getState();
958
+ }
959
+ /**
960
+ * List all entries in the ledger.
961
+ */
962
+ async list() {
963
+ this.ensureNotDestroyed();
964
+ return this.storage.getAll();
965
+ }
966
+ /**
967
+ * Get a single entry by ID.
968
+ */
969
+ async get(id) {
970
+ this.ensureNotDestroyed();
971
+ return this.storage.get(id);
972
+ }
973
+ /**
974
+ * Manually retry a failed entry.
975
+ *
976
+ * This is useful when using the 'manual' retry strategy.
977
+ *
978
+ * @param id The entry ID to retry
979
+ */
980
+ async retry(id) {
981
+ this.ensureNotDestroyed();
982
+ const entry = await this.storage.get(id);
983
+ if (!entry) {
984
+ throw new Error(`Entry not found: ${id}`);
985
+ }
986
+ if (entry.status !== 'failed') {
987
+ throw new Error(`Entry is not in failed state: ${id}`);
988
+ }
989
+ // Reset status to pending and clear error
990
+ await this.storage.update(id, {
991
+ status: 'pending',
992
+ error: undefined,
993
+ });
994
+ }
995
+ /**
996
+ * Remove a specific entry from the ledger.
997
+ *
998
+ * @param id The entry ID to remove
999
+ */
1000
+ async remove(id) {
1001
+ this.ensureNotDestroyed();
1002
+ await this.storage.remove(id);
1003
+ }
1004
+ /**
1005
+ * Clear all entries from the ledger.
1006
+ */
1007
+ async clear() {
1008
+ this.ensureNotDestroyed();
1009
+ await this.storage.clear();
1010
+ }
1011
+ /**
1012
+ * Destroy the ledger instance.
1013
+ *
1014
+ * This closes the storage connection and prevents further operations.
1015
+ */
1016
+ async destroy() {
1017
+ if (this.isDestroyed)
1018
+ return;
1019
+ this.isDestroyed = true;
1020
+ this.replayEngine.pause();
1021
+ // Close storage if it has a close method
1022
+ if ('close' in this.storage && typeof this.storage.close === 'function') {
1023
+ this.storage.close();
1024
+ }
1025
+ }
1026
+ /**
1027
+ * Ensure the ledger is not destroyed.
1028
+ */
1029
+ ensureNotDestroyed() {
1030
+ if (this.isDestroyed) {
1031
+ throw new Error('Ledger has been destroyed');
1032
+ }
1033
+ }
1034
+ }
1035
+ /**
1036
+ * Create a new RequestLedger instance.
1037
+ *
1038
+ * @param config Configuration options
1039
+ * @returns A new RequestLedger instance
1040
+ */
1041
+ function createLedger(config = {}) {
1042
+ return new RequestLedger(config);
1043
+ }
1044
+
1045
+ exports.DuplicateEntryError = DuplicateEntryError;
1046
+ exports.EntryNotFoundError = EntryNotFoundError;
1047
+ exports.IndexedDBStorage = IndexedDBStorage;
1048
+ exports.LedgerError = LedgerError;
1049
+ exports.NetworkError = NetworkError;
1050
+ exports.PersistenceError = PersistenceError;
1051
+ exports.RequestLedger = RequestLedger;
1052
+ exports.calculateBackoffDelay = calculateBackoffDelay;
1053
+ exports.canRetry = canRetry;
1054
+ exports.createLedger = createLedger;
1055
+ exports.createOnlineChecker = createOnlineChecker;
1056
+ exports.delay = delay;
1057
+ exports.isNetworkError = isNetworkError;
1058
+ exports.isRetryableStatusCode = isRetryableStatusCode;
1059
+ //# sourceMappingURL=index.cjs.map