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