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/README.md +282 -0
- package/dist/index.cjs +1059 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +575 -0
- package/dist/index.js +1044 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
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
|