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 ADDED
@@ -0,0 +1,282 @@
1
+ # request-ledger
2
+
3
+ A durable, client-side HTTP request ledger for web applications operating on unreliable networks.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/request-ledger.svg)](https://www.npmjs.com/package/request-ledger)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.3-blue.svg)](https://www.typescriptlang.org/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+
9
+ ## Mental Model
10
+
11
+ Think of `request-ledger` as a **transactional outbox** for your client-side HTTP requests:
12
+
13
+ 1. When your app needs to make an API request, it goes through the ledger
14
+ 2. If online, the request executes immediately
15
+ 3. If offline (or network fails), the request is **durably persisted** to IndexedDB
16
+ 4. When connectivity returns, queued requests are **replayed in order**
17
+ 5. Failed requests are never silently dropped
18
+
19
+ This is **not** a retry library (it doesn't retry on every failure), and **not** a sync engine (it doesn't resolve conflicts).
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ npm install request-ledger
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ```ts
30
+ import { createLedger } from "request-ledger";
31
+
32
+ const ledger = createLedger({
33
+ onlineCheck: {
34
+ pingUrl: "/api/health", // Optional: ping endpoint for reliable online detection
35
+ },
36
+ hooks: {
37
+ onPersist: (entry) => console.log("Queued:", entry.id),
38
+ onReplaySuccess: (entry) => console.log("Completed:", entry.id),
39
+ onReplayFailure: (entry, error) =>
40
+ console.error("Failed:", entry.id, error),
41
+ },
42
+ });
43
+
44
+ // Make a request (queued if offline)
45
+ await ledger.request({
46
+ id: "order-123", // Required: unique ID
47
+ url: "/api/orders",
48
+ method: "POST",
49
+ body: { items: ["item-1", "item-2"] },
50
+ idempotencyKey: "order-123-v1", // Recommended for safe replay
51
+ });
52
+
53
+ // Process queued requests when online
54
+ await ledger.process({ concurrency: 1, stopOnError: true });
55
+
56
+ // Check state
57
+ const state = await ledger.getState(); // 'idle' | 'pending' | 'processing' | 'paused' | 'error'
58
+ const entries = await ledger.list(); // All entries with status
59
+ ```
60
+
61
+ ## API Reference
62
+
63
+ ### `createLedger(config?)`
64
+
65
+ Creates a new ledger instance.
66
+
67
+ ```ts
68
+ const ledger = createLedger({
69
+ // Optional: custom storage adapter (default: IndexedDB)
70
+ storage: new IndexedDBStorage({ maxEntries: 1000 }),
71
+
72
+ // Optional: retry strategy (default: exponential backoff)
73
+ retry: { type: "exponential", baseMs: 1000, maxMs: 30000, maxAttempts: 3 },
74
+
75
+ // Optional: online detection
76
+ onlineCheck: {
77
+ pingUrl: "/api/health",
78
+ pingTimeout: 5000,
79
+ customCheck: async () => {
80
+ /* your logic */ return true;
81
+ },
82
+ },
83
+
84
+ // Optional: lifecycle hooks
85
+ hooks: {
86
+ onPersist: (entry) => {},
87
+ onReplayStart: (entry) => {},
88
+ onReplaySuccess: (entry, response) => {},
89
+ onReplayFailure: (entry, error) => {},
90
+ },
91
+
92
+ // Optional: idempotency header name (default: 'X-Idempotency-Key')
93
+ idempotencyHeader: "X-Idempotency-Key",
94
+ });
95
+ ```
96
+
97
+ ### `ledger.request(options)`
98
+
99
+ Make a request through the ledger.
100
+
101
+ ```ts
102
+ await ledger.request({
103
+ id: string; // Required: unique identifier
104
+ url: string; // Required: target URL
105
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
106
+ headers?: Record<string, string>;
107
+ body?: unknown; // JSON serializable
108
+ idempotencyKey?: string; // For safe replay
109
+ metadata?: Record<string, unknown>; // Your custom data
110
+ });
111
+ ```
112
+
113
+ **Behavior:**
114
+
115
+ - If online → attempts immediately, returns `Response`
116
+ - If offline or network fails → persists to ledger, returns `void`
117
+ - If persistence fails → throws `PersistenceError`
118
+
119
+ ### `ledger.process(options?)`
120
+
121
+ Process queued entries.
122
+
123
+ ```ts
124
+ await ledger.process({
125
+ concurrency: 1, // Max concurrent requests (default: 1)
126
+ stopOnError: true, // Stop on first failure (default: true)
127
+ onSuccess: (entry) => {},
128
+ onFailure: (entry, error) => {},
129
+ });
130
+ ```
131
+
132
+ ### Control Methods
133
+
134
+ ```ts
135
+ ledger.pause(); // Pause processing
136
+ ledger.resume(); // Resume processing
137
+ await ledger.getState(); // 'idle' | 'pending' | 'processing' | 'paused' | 'error'
138
+ await ledger.list(); // All entries
139
+ await ledger.get(id); // Single entry
140
+ await ledger.retry(id); // Retry a failed entry
141
+ await ledger.remove(id); // Remove an entry
142
+ await ledger.clear(); // Remove all entries
143
+ await ledger.destroy(); // Close and cleanup
144
+ ```
145
+
146
+ ## Ledger Entry Schema
147
+
148
+ Each entry contains:
149
+
150
+ ```ts
151
+ {
152
+ id: string;
153
+ request: {
154
+ url: string;
155
+ method: string;
156
+ headers: Record<string, string>;
157
+ body: unknown;
158
+ };
159
+ status: 'pending' | 'processing' | 'completed' | 'failed';
160
+ attemptCount: number;
161
+ createdAt: number; // ms since epoch
162
+ lastAttemptAt?: number;
163
+ error?: { message: string; code?: string };
164
+ idempotencyKey?: string;
165
+ metadata?: Record<string, unknown>;
166
+ }
167
+ ```
168
+
169
+ ## Retry Strategies
170
+
171
+ ```ts
172
+ // Fixed delay
173
+ { type: 'fixed', delayMs: 1000, maxAttempts: 3 }
174
+
175
+ // Exponential backoff (default)
176
+ { type: 'exponential', baseMs: 1000, maxMs: 30000, maxAttempts: 3 }
177
+
178
+ // Manual (user-triggered retries only)
179
+ { type: 'manual' }
180
+ ```
181
+
182
+ **Retry rules:**
183
+
184
+ - ✅ Retry on network errors
185
+ - ✅ Retry on 5xx server errors
186
+ - ❌ Never retry on 4xx client errors
187
+
188
+ ## Custom Storage
189
+
190
+ Implement the `LedgerStorage` interface:
191
+
192
+ ```ts
193
+ interface LedgerStorage {
194
+ put(entry: LedgerEntry): Promise<void>;
195
+ getAll(): Promise<LedgerEntry[]>;
196
+ get(id: string): Promise<LedgerEntry | undefined>;
197
+ update(id: string, patch: Partial<LedgerEntry>): Promise<void>;
198
+ remove(id: string): Promise<void>;
199
+ clear(): Promise<void>;
200
+ count(): Promise<number>;
201
+ }
202
+ ```
203
+
204
+ ## Failure Scenarios
205
+
206
+ | Scenario | Behavior |
207
+ | ----------------------------- | ------------------------------------------------ |
208
+ | Offline when request made | Persisted to IndexedDB, replayed when online |
209
+ | Network fails mid-request | Persisted, retried with backoff |
210
+ | Page closed during processing | Entry stays in `processing`, recovered on reload |
211
+ | 4xx response | Marked as `failed`, no retry |
212
+ | 5xx response | Retried up to `maxAttempts` |
213
+ | IndexedDB quota exceeded | `PersistenceError` thrown |
214
+
215
+ ## ⚠️ Backend Idempotency Required
216
+
217
+ **Your backend MUST support idempotency keys for safe replay.**
218
+
219
+ When a request is replayed, there's no guarantee the first attempt didn't succeed. Your backend must:
220
+
221
+ 1. Accept an `X-Idempotency-Key` header
222
+ 2. If the key was already processed, return the cached response
223
+ 3. If new, process and cache the result
224
+
225
+ Without this, replayed requests may cause **duplicate side effects** (double charges, duplicate orders, etc.).
226
+
227
+ ## Non-Goals
228
+
229
+ This library explicitly does **NOT**:
230
+
231
+ - ❌ Resolve application-level conflicts
232
+ - ❌ Sync application state
233
+ - ❌ Guess backend behavior
234
+ - ❌ Mutate request payloads
235
+ - ❌ Hide failures
236
+ - ❌ Depend on Service Workers
237
+
238
+ ## Technical Details
239
+
240
+ - **Zero runtime dependencies**
241
+ - **TypeScript-first** with full type definitions
242
+ - **Tree-shakeable** ES modules
243
+ - **~8KB** gzipped
244
+ - Works in modern browsers (Chrome 80+, Firefox 75+, Safari 14+, Edge 80+)
245
+
246
+ ## Example: Offline → Reload → Replay
247
+
248
+ ```ts
249
+ // User creates an order while offline
250
+ await ledger.request({
251
+ id: "order-456",
252
+ url: "/api/orders",
253
+ method: "POST",
254
+ body: { product: "Widget", quantity: 5 },
255
+ idempotencyKey: "order-456-v1",
256
+ });
257
+
258
+ // Entry is now persisted in IndexedDB
259
+ console.log(await ledger.list());
260
+ // [{ id: 'order-456', status: 'pending', ... }]
261
+
262
+ // --- User closes browser, reopens later ---
263
+
264
+ // On app startup, check for pending entries
265
+ const ledger = createLedger({
266
+ /* config */
267
+ });
268
+ const state = await ledger.getState();
269
+
270
+ if (state === "pending") {
271
+ // Process queued requests
272
+ await ledger.process({
273
+ onSuccess: (entry) => showNotification(`Order ${entry.id} completed!`),
274
+ onFailure: (entry, error) =>
275
+ showError(`Order ${entry.id} failed: ${error.message}`),
276
+ });
277
+ }
278
+ ```
279
+
280
+ ## License
281
+
282
+ MIT