mvc-kit 2.12.0 → 2.12.2

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.
Files changed (139) hide show
  1. package/agent-config/bin/postinstall.mjs +5 -3
  2. package/agent-config/bin/setup.mjs +3 -4
  3. package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
  4. package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
  5. package/agent-config/lib/install-claude.mjs +19 -33
  6. package/dist/Model.cjs +9 -1
  7. package/dist/Model.cjs.map +1 -1
  8. package/dist/Model.d.ts +1 -1
  9. package/dist/Model.d.ts.map +1 -1
  10. package/dist/Model.js +9 -1
  11. package/dist/Model.js.map +1 -1
  12. package/dist/ViewModel.cjs +9 -1
  13. package/dist/ViewModel.cjs.map +1 -1
  14. package/dist/ViewModel.d.ts +1 -1
  15. package/dist/ViewModel.d.ts.map +1 -1
  16. package/dist/ViewModel.js +9 -1
  17. package/dist/ViewModel.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/mvc-kit.cjs +3 -0
  21. package/dist/mvc-kit.cjs.map +1 -1
  22. package/dist/mvc-kit.js +3 -0
  23. package/dist/mvc-kit.js.map +1 -1
  24. package/dist/produceDraft.cjs +105 -0
  25. package/dist/produceDraft.cjs.map +1 -0
  26. package/dist/produceDraft.d.ts +19 -0
  27. package/dist/produceDraft.d.ts.map +1 -0
  28. package/dist/produceDraft.js +105 -0
  29. package/dist/produceDraft.js.map +1 -0
  30. package/package.json +4 -2
  31. package/src/Channel.md +408 -0
  32. package/src/Channel.test.ts +957 -0
  33. package/src/Channel.ts +429 -0
  34. package/src/Collection.md +533 -0
  35. package/src/Collection.test.ts +1559 -0
  36. package/src/Collection.ts +653 -0
  37. package/src/Controller.md +306 -0
  38. package/src/Controller.test.ts +380 -0
  39. package/src/Controller.ts +90 -0
  40. package/src/EventBus.md +308 -0
  41. package/src/EventBus.test.ts +295 -0
  42. package/src/EventBus.ts +110 -0
  43. package/src/Feed.md +218 -0
  44. package/src/Feed.test.ts +442 -0
  45. package/src/Feed.ts +101 -0
  46. package/src/Model.md +524 -0
  47. package/src/Model.test.ts +642 -0
  48. package/src/Model.ts +260 -0
  49. package/src/Pagination.md +168 -0
  50. package/src/Pagination.test.ts +244 -0
  51. package/src/Pagination.ts +92 -0
  52. package/src/Pending.md +380 -0
  53. package/src/Pending.test.ts +1719 -0
  54. package/src/Pending.ts +390 -0
  55. package/src/PersistentCollection.md +183 -0
  56. package/src/PersistentCollection.test.ts +649 -0
  57. package/src/PersistentCollection.ts +375 -0
  58. package/src/Resource.ViewModel.test.ts +503 -0
  59. package/src/Resource.md +239 -0
  60. package/src/Resource.test.ts +786 -0
  61. package/src/Resource.ts +231 -0
  62. package/src/Selection.md +155 -0
  63. package/src/Selection.test.ts +326 -0
  64. package/src/Selection.ts +117 -0
  65. package/src/Service.md +440 -0
  66. package/src/Service.test.ts +241 -0
  67. package/src/Service.ts +72 -0
  68. package/src/Sorting.md +170 -0
  69. package/src/Sorting.test.ts +334 -0
  70. package/src/Sorting.ts +135 -0
  71. package/src/Trackable.md +166 -0
  72. package/src/Trackable.test.ts +236 -0
  73. package/src/Trackable.ts +129 -0
  74. package/src/ViewModel.async.test.ts +813 -0
  75. package/src/ViewModel.derived.test.ts +1583 -0
  76. package/src/ViewModel.md +1111 -0
  77. package/src/ViewModel.test.ts +1236 -0
  78. package/src/ViewModel.ts +800 -0
  79. package/src/bindPublicMethods.test.ts +126 -0
  80. package/src/bindPublicMethods.ts +48 -0
  81. package/src/env.d.ts +5 -0
  82. package/src/errors.test.ts +155 -0
  83. package/src/errors.ts +133 -0
  84. package/src/index.ts +49 -0
  85. package/src/produceDraft.md +90 -0
  86. package/src/produceDraft.test.ts +394 -0
  87. package/src/produceDraft.ts +168 -0
  88. package/src/react/components/CardList.md +97 -0
  89. package/src/react/components/CardList.test.tsx +142 -0
  90. package/src/react/components/CardList.tsx +68 -0
  91. package/src/react/components/DataTable.md +179 -0
  92. package/src/react/components/DataTable.test.tsx +599 -0
  93. package/src/react/components/DataTable.tsx +267 -0
  94. package/src/react/components/InfiniteScroll.md +116 -0
  95. package/src/react/components/InfiniteScroll.test.tsx +218 -0
  96. package/src/react/components/InfiniteScroll.tsx +70 -0
  97. package/src/react/components/types.ts +90 -0
  98. package/src/react/derived.test.tsx +261 -0
  99. package/src/react/guards.ts +24 -0
  100. package/src/react/index.ts +40 -0
  101. package/src/react/provider.test.tsx +143 -0
  102. package/src/react/provider.tsx +55 -0
  103. package/src/react/strict-mode.test.tsx +266 -0
  104. package/src/react/types.ts +25 -0
  105. package/src/react/use-event-bus.md +214 -0
  106. package/src/react/use-event-bus.test.tsx +168 -0
  107. package/src/react/use-event-bus.ts +40 -0
  108. package/src/react/use-instance.md +204 -0
  109. package/src/react/use-instance.test.tsx +350 -0
  110. package/src/react/use-instance.ts +60 -0
  111. package/src/react/use-local.md +457 -0
  112. package/src/react/use-local.rapid-remount.test.tsx +503 -0
  113. package/src/react/use-local.test.tsx +692 -0
  114. package/src/react/use-local.ts +165 -0
  115. package/src/react/use-model.md +364 -0
  116. package/src/react/use-model.test.tsx +394 -0
  117. package/src/react/use-model.ts +161 -0
  118. package/src/react/use-singleton.md +415 -0
  119. package/src/react/use-singleton.test.tsx +296 -0
  120. package/src/react/use-singleton.ts +69 -0
  121. package/src/react/use-subscribe-only.ts +39 -0
  122. package/src/react/use-teardown.md +169 -0
  123. package/src/react/use-teardown.test.tsx +86 -0
  124. package/src/react/use-teardown.ts +27 -0
  125. package/src/react-native/NativeCollection.test.ts +250 -0
  126. package/src/react-native/NativeCollection.ts +138 -0
  127. package/src/react-native/index.ts +1 -0
  128. package/src/singleton.md +310 -0
  129. package/src/singleton.test.ts +204 -0
  130. package/src/singleton.ts +70 -0
  131. package/src/types.ts +70 -0
  132. package/src/walkPrototypeChain.ts +22 -0
  133. package/src/web/IndexedDBCollection.test.ts +235 -0
  134. package/src/web/IndexedDBCollection.ts +66 -0
  135. package/src/web/WebStorageCollection.test.ts +214 -0
  136. package/src/web/WebStorageCollection.ts +116 -0
  137. package/src/web/idb.ts +184 -0
  138. package/src/web/index.ts +2 -0
  139. package/src/wrapAsyncMethods.ts +249 -0
@@ -0,0 +1,1719 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { Pending } from './Pending';
3
+ import { HttpError } from './errors';
4
+ import { ViewModel } from './ViewModel';
5
+
6
+ describe('Pending', () => {
7
+ beforeEach(() => {
8
+ vi.useFakeTimers();
9
+ });
10
+
11
+ afterEach(() => {
12
+ vi.useRealTimers();
13
+ });
14
+
15
+ describe('initial state', () => {
16
+ it('starts with count 0, hasPending false, hasFailed false', () => {
17
+ const p = new Pending();
18
+ expect(p.count).toBe(0);
19
+ expect(p.hasPending).toBe(false);
20
+ expect(p.hasFailed).toBe(false);
21
+ expect(p.failedCount).toBe(0);
22
+ });
23
+
24
+ it('getStatus returns null for unknown ID', () => {
25
+ const p = new Pending();
26
+ expect(p.getStatus('unknown')).toBe(null);
27
+ });
28
+
29
+ it('has returns false for unknown ID', () => {
30
+ const p = new Pending();
31
+ expect(p.has('unknown')).toBe(false);
32
+ });
33
+ });
34
+
35
+ describe('enqueue', () => {
36
+ it('sets status to active and notifies', async () => {
37
+ const p = new Pending<string>();
38
+ const listener = vi.fn();
39
+ p.subscribe(listener);
40
+
41
+ let resolve!: () => void;
42
+ const promise = new Promise<void>(r => { resolve = r; });
43
+
44
+ p.enqueue('item-1', 'delete', () => promise);
45
+
46
+ // Immediately after enqueue, snapshot is active (pre-microtask)
47
+ const preStatus = p.getStatus('item-1');
48
+ expect(preStatus).not.toBe(null);
49
+ expect(preStatus!.status).toBe('active');
50
+
51
+ // After microtask, execute runs
52
+ await vi.advanceTimersByTimeAsync(0);
53
+
54
+ const status = p.getStatus('item-1');
55
+ expect(status).not.toBe(null);
56
+ expect(status!.status).toBe('active');
57
+ expect(status!.operation).toBe('delete');
58
+ expect(status!.attempts).toBe(1);
59
+ expect(status!.error).toBe(null);
60
+ expect(status!.errorCode).toBe(null);
61
+ expect(status!.nextRetryAt).toBe(null);
62
+ expect(typeof status!.createdAt).toBe('number');
63
+ expect(listener).toHaveBeenCalled();
64
+
65
+ resolve();
66
+ await vi.advanceTimersByTimeAsync(0);
67
+ p.dispose();
68
+ });
69
+
70
+ it('getStatus returns PendingOperation with correct maxRetries', async () => {
71
+ const p = new Pending<string>();
72
+ let resolve!: () => void;
73
+ const promise = new Promise<void>(r => { resolve = r; });
74
+
75
+ p.enqueue('a', 'update', () => promise);
76
+ await vi.advanceTimersByTimeAsync(0);
77
+
78
+ const status = p.getStatus('a');
79
+ expect(status!.maxRetries).toBe(5);
80
+
81
+ resolve();
82
+ await vi.advanceTimersByTimeAsync(0);
83
+ p.dispose();
84
+ });
85
+
86
+ it('has returns true for enqueued ID', async () => {
87
+ const p = new Pending<string>();
88
+ let resolve!: () => void;
89
+ const promise = new Promise<void>(r => { resolve = r; });
90
+
91
+ p.enqueue('a', 'delete', () => promise);
92
+ expect(p.has('a')).toBe(true);
93
+
94
+ resolve();
95
+ await vi.advanceTimersByTimeAsync(0);
96
+ p.dispose();
97
+ });
98
+
99
+ it('count reflects number of pending operations', () => {
100
+ const p = new Pending<string>();
101
+
102
+ p.enqueue('a', 'op', () => new Promise(() => {}));
103
+ p.enqueue('b', 'op', () => new Promise(() => {}));
104
+ expect(p.count).toBe(2);
105
+
106
+ p.dispose();
107
+ });
108
+
109
+ it('hasPending is true when operations exist', () => {
110
+ const p = new Pending<string>();
111
+ p.enqueue('a', 'op', () => new Promise(() => {}));
112
+ expect(p.hasPending).toBe(true);
113
+ p.dispose();
114
+ });
115
+
116
+ it('execute receives an AbortSignal', async () => {
117
+ const p = new Pending<string>();
118
+ let receivedSignal: AbortSignal | null = null;
119
+
120
+ p.enqueue('a', 'op', async (signal) => {
121
+ receivedSignal = signal;
122
+ });
123
+ await vi.advanceTimersByTimeAsync(0);
124
+
125
+ expect(receivedSignal).toBeInstanceOf(AbortSignal);
126
+ p.dispose();
127
+ });
128
+
129
+ it('supersedes existing operation for same ID (aborts previous signal)', async () => {
130
+ const p = new Pending<string>();
131
+ let secondSignal: AbortSignal | null = null;
132
+
133
+ let firstAborted = false;
134
+
135
+ p.enqueue('a', 'first', async (signal) => {
136
+ signal.addEventListener('abort', () => { firstAborted = true; });
137
+ await new Promise(() => {}); // never resolves
138
+ });
139
+
140
+ // Process the first microtask so execute runs and signal listener is attached
141
+ await vi.advanceTimersByTimeAsync(0);
142
+
143
+ p.enqueue('a', 'second', async (signal) => {
144
+ secondSignal = signal;
145
+ });
146
+
147
+ await vi.advanceTimersByTimeAsync(0);
148
+
149
+ expect(firstAborted).toBe(true);
150
+ expect(secondSignal!.aborted).toBe(false);
151
+ expect(p.getStatus('a')).toBe(null); // second completed
152
+ p.dispose();
153
+ });
154
+
155
+ it('is no-op after dispose', () => {
156
+ const p = new Pending<string>();
157
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
158
+ p.dispose();
159
+
160
+ p.enqueue('a', 'op', async () => {});
161
+ expect(p.count).toBe(0);
162
+ expect(warnSpy).toHaveBeenCalledWith(
163
+ '[mvc-kit] Pending.enqueue() called after dispose — ignored.',
164
+ );
165
+ warnSpy.mockRestore();
166
+ });
167
+ });
168
+
169
+ describe('confirmation', () => {
170
+ it('removes operation on successful execute', async () => {
171
+ const p = new Pending<string>();
172
+ p.enqueue('a', 'delete', async () => {});
173
+ await vi.advanceTimersByTimeAsync(0);
174
+
175
+ expect(p.has('a')).toBe(false);
176
+ expect(p.getStatus('a')).toBe(null);
177
+ expect(p.count).toBe(0);
178
+ p.dispose();
179
+ });
180
+
181
+ it('notifies on confirmation', async () => {
182
+ const p = new Pending<string>();
183
+ p.enqueue('a', 'op', async () => {});
184
+ await vi.advanceTimersByTimeAsync(0);
185
+
186
+ // Now subscribe after active notification
187
+ const listener = vi.fn();
188
+ p.subscribe(listener);
189
+
190
+ p.enqueue('b', 'op', async () => {});
191
+ await vi.advanceTimersByTimeAsync(0);
192
+
193
+ // Should have notified: enqueue (active), _process (active), confirmed (removed)
194
+ expect(listener.mock.calls.length).toBeGreaterThanOrEqual(2);
195
+ p.dispose();
196
+ });
197
+
198
+ it('calls onConfirmed hook', async () => {
199
+ const confirmed = vi.fn();
200
+
201
+ class TestPending extends Pending<string> {
202
+ protected onConfirmed(id: string, operation: string) {
203
+ confirmed(id, operation);
204
+ }
205
+ }
206
+
207
+ const p = new TestPending();
208
+ p.enqueue('item-1', 'delete', async () => {});
209
+ await vi.advanceTimersByTimeAsync(0);
210
+
211
+ expect(confirmed).toHaveBeenCalledWith('item-1', 'delete');
212
+ p.dispose();
213
+ });
214
+
215
+ it('getStatus returns null after confirmation', async () => {
216
+ const p = new Pending<string>();
217
+ p.enqueue('a', 'op', async () => {});
218
+ await vi.advanceTimersByTimeAsync(0);
219
+
220
+ expect(p.getStatus('a')).toBe(null);
221
+ p.dispose();
222
+ });
223
+ });
224
+
225
+ describe('supersede race condition', () => {
226
+ it('does not delete superseding operation when superseded op rejects', async () => {
227
+ const p = new Pending<string>();
228
+ let secondCompleted = false;
229
+
230
+ // First operation: hangs forever (will be superseded)
231
+ p.enqueue('a', 'first', async (signal) => {
232
+ // Wait until aborted
233
+ await new Promise((_, reject) => {
234
+ signal.addEventListener('abort', () => {
235
+ reject(new DOMException('Aborted', 'AbortError'));
236
+ });
237
+ });
238
+ });
239
+
240
+ await vi.advanceTimersByTimeAsync(0);
241
+ expect(p.getStatus('a')!.operation).toBe('first');
242
+
243
+ // Second operation: will succeed
244
+ p.enqueue('a', 'second', async () => {
245
+ secondCompleted = true;
246
+ });
247
+
248
+ // Process the second microtask — both the abort rejection from op1
249
+ // and the execution of op2 happen here
250
+ await vi.advanceTimersByTimeAsync(0);
251
+
252
+ // The second operation should have completed successfully
253
+ expect(secondCompleted).toBe(true);
254
+ // And the ID should be gone (confirmed), NOT still present
255
+ expect(p.has('a')).toBe(false);
256
+ expect(p.getStatus('a')).toBe(null);
257
+ p.dispose();
258
+ });
259
+
260
+ it('does not confirm superseded operation when it resolves late', async () => {
261
+ let resolveFirst!: () => void;
262
+ const confirmed = vi.fn();
263
+
264
+ class TestPending extends Pending<string> {
265
+ protected onConfirmed(id: string, operation: string) {
266
+ confirmed(id, operation);
267
+ }
268
+ }
269
+
270
+ const tp = new TestPending();
271
+
272
+ // First operation: controlled resolution
273
+ tp.enqueue('a', 'first', async () => {
274
+ await new Promise<void>(r => { resolveFirst = r; });
275
+ });
276
+ await vi.advanceTimersByTimeAsync(0);
277
+
278
+ // Supersede before first resolves
279
+ tp.enqueue('a', 'second', async () => {
280
+ await new Promise(() => {}); // hang
281
+ });
282
+ await vi.advanceTimersByTimeAsync(0);
283
+
284
+ // Now resolve the first (it's been superseded)
285
+ resolveFirst();
286
+ await vi.advanceTimersByTimeAsync(0);
287
+
288
+ // onConfirmed should NOT have been called for 'first'
289
+ // because it was superseded by 'second'
290
+ expect(confirmed).not.toHaveBeenCalled();
291
+ // Second should still be active
292
+ expect(tp.getStatus('a')!.operation).toBe('second');
293
+ tp.dispose();
294
+ });
295
+
296
+ it('superseding a retrying operation clears the retry timer', async () => {
297
+ const p = new Pending<string>();
298
+ let firstCallCount = 0;
299
+ let secondCalled = false;
300
+
301
+ p.enqueue('a', 'first', async () => {
302
+ firstCallCount++;
303
+ throw new TypeError('Failed to fetch');
304
+ });
305
+ await vi.advanceTimersByTimeAsync(0);
306
+
307
+ // First op is now retrying with a timer
308
+ expect(p.getStatus('a')!.status).toBe('retrying');
309
+ expect(firstCallCount).toBe(1);
310
+
311
+ // Supersede with a new operation
312
+ p.enqueue('a', 'second', async () => {
313
+ secondCalled = true;
314
+ });
315
+ await vi.advanceTimersByTimeAsync(0);
316
+
317
+ expect(secondCalled).toBe(true);
318
+ expect(p.has('a')).toBe(false); // second completed
319
+
320
+ // Advance past when the old retry timer would have fired — should be no-op
321
+ await vi.advanceTimersByTimeAsync(60000);
322
+ expect(firstCallCount).toBe(1); // never retried
323
+ p.dispose();
324
+ });
325
+ });
326
+
327
+ describe('retry on transient error', () => {
328
+ it('retries on network error', async () => {
329
+ const p = new Pending<string>();
330
+ let callCount = 0;
331
+
332
+ p.enqueue('a', 'op', async () => {
333
+ callCount++;
334
+ if (callCount === 1) throw new TypeError('Failed to fetch');
335
+ });
336
+ await vi.advanceTimersByTimeAsync(0);
337
+
338
+ // First call fails — should be retrying
339
+ expect(p.getStatus('a')!.status).toBe('retrying');
340
+ expect(callCount).toBe(1);
341
+
342
+ // Advance past retry delay
343
+ await vi.advanceTimersByTimeAsync(30000);
344
+
345
+ // Second call succeeds
346
+ expect(p.has('a')).toBe(false);
347
+ expect(callCount).toBe(2);
348
+ p.dispose();
349
+ });
350
+
351
+ it('retries on timeout error', async () => {
352
+ const p = new Pending<string>();
353
+ let callCount = 0;
354
+ const timeoutErr = new Error('Request timed out');
355
+ timeoutErr.name = 'TimeoutError';
356
+
357
+ p.enqueue('a', 'op', async () => {
358
+ callCount++;
359
+ if (callCount === 1) throw timeoutErr;
360
+ });
361
+ await vi.advanceTimersByTimeAsync(0);
362
+
363
+ expect(p.getStatus('a')!.status).toBe('retrying');
364
+ expect(p.getStatus('a')!.errorCode).toBe('timeout');
365
+
366
+ await vi.advanceTimersByTimeAsync(30000);
367
+ expect(callCount).toBe(2);
368
+ p.dispose();
369
+ });
370
+
371
+ it('retries on server error', async () => {
372
+ const p = new Pending<string>();
373
+ let callCount = 0;
374
+
375
+ p.enqueue('a', 'op', async () => {
376
+ callCount++;
377
+ if (callCount === 1) throw new HttpError(500, 'Internal Server Error');
378
+ });
379
+ await vi.advanceTimersByTimeAsync(0);
380
+
381
+ expect(p.getStatus('a')!.status).toBe('retrying');
382
+ expect(p.getStatus('a')!.errorCode).toBe('server_error');
383
+
384
+ await vi.advanceTimersByTimeAsync(30000);
385
+ expect(callCount).toBe(2);
386
+ p.dispose();
387
+ });
388
+
389
+ it('sets status to retrying between attempts', async () => {
390
+ const p = new Pending<string>();
391
+ const statuses: string[] = [];
392
+ const listener = () => {
393
+ const s = p.getStatus('a');
394
+ if (s) statuses.push(s.status);
395
+ };
396
+ p.subscribe(listener);
397
+
398
+ p.enqueue('a', 'op', async () => {
399
+ throw new TypeError('Failed to fetch');
400
+ });
401
+ await vi.advanceTimersByTimeAsync(0);
402
+
403
+ expect(statuses).toContain('retrying');
404
+ p.dispose();
405
+ });
406
+
407
+ it('increments attempts counter', async () => {
408
+ vi.spyOn(Math, 'random').mockReturnValue(1);
409
+
410
+ class SlowRetryPending extends Pending<string> {
411
+ static override RETRY_BASE = 10000;
412
+ static override MAX_RETRIES = 5;
413
+ }
414
+
415
+ const p = new SlowRetryPending();
416
+
417
+ p.enqueue('a', 'op', async () => {
418
+ throw new TypeError('Failed to fetch');
419
+ });
420
+ await vi.advanceTimersByTimeAsync(0);
421
+
422
+ expect(p.getStatus('a')!.attempts).toBe(1);
423
+
424
+ // Advance just past the first retry delay (10000ms)
425
+ await vi.advanceTimersByTimeAsync(10001);
426
+ expect(p.getStatus('a')!.attempts).toBe(2);
427
+
428
+ vi.spyOn(Math, 'random').mockRestore();
429
+ p.dispose();
430
+ });
431
+
432
+ it('sets nextRetryAt with delay', async () => {
433
+ vi.spyOn(Math, 'random').mockReturnValue(0.5);
434
+
435
+ const p = new Pending<string>();
436
+
437
+ p.enqueue('a', 'op', async () => {
438
+ throw new TypeError('Failed to fetch');
439
+ });
440
+ await vi.advanceTimersByTimeAsync(0);
441
+
442
+ const status = p.getStatus('a')!;
443
+ expect(status.nextRetryAt).not.toBe(null);
444
+ expect(status.nextRetryAt).toBeGreaterThan(0);
445
+
446
+ vi.spyOn(Math, 'random').mockRestore();
447
+ p.dispose();
448
+ });
449
+
450
+ it('respects MAX_RETRIES limit', async () => {
451
+ class LimitedPending extends Pending<string> {
452
+ static override MAX_RETRIES = 2;
453
+ }
454
+
455
+ const p = new LimitedPending();
456
+ let callCount = 0;
457
+
458
+ p.enqueue('a', 'op', async () => {
459
+ callCount++;
460
+ throw new TypeError('Failed to fetch');
461
+ });
462
+
463
+ // Process through all retries
464
+ await vi.advanceTimersByTimeAsync(0);
465
+ expect(callCount).toBe(1);
466
+
467
+ await vi.advanceTimersByTimeAsync(30000);
468
+ expect(callCount).toBe(2);
469
+
470
+ // Should be failed, not retrying again
471
+ expect(p.getStatus('a')!.status).toBe('failed');
472
+ p.dispose();
473
+ });
474
+
475
+ it('uses exponential backoff with jitter', async () => {
476
+ vi.spyOn(Math, 'random').mockReturnValue(1); // Max jitter for deterministic testing
477
+
478
+ const p = new Pending<string>();
479
+ const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
480
+
481
+ p.enqueue('a', 'op', async () => {
482
+ throw new TypeError('Failed to fetch');
483
+ });
484
+ await vi.advanceTimersByTimeAsync(0);
485
+
486
+ // Collect the delay from the retry setTimeout call
487
+ const retryCalls = setTimeoutSpy.mock.calls.filter(
488
+ call => typeof call[1] === 'number' && call[1] > 0,
489
+ );
490
+
491
+ // With Math.random()=1, RETRY_BASE=1000, RETRY_FACTOR=2:
492
+ // attempt 0: Math.random() * min(1000 * 2^0, 30000) = 1 * 1000 = 1000
493
+ expect(retryCalls[retryCalls.length - 1][1]).toBe(1000);
494
+
495
+ vi.spyOn(Math, 'random').mockRestore();
496
+ setTimeoutSpy.mockRestore();
497
+ p.dispose();
498
+ });
499
+
500
+ it('transitions to failed after max retries', async () => {
501
+ class LimitedPending extends Pending<string> {
502
+ static override MAX_RETRIES = 1;
503
+ }
504
+
505
+ const p = new LimitedPending();
506
+
507
+ p.enqueue('a', 'op', async () => {
508
+ throw new HttpError(503, 'Service Unavailable');
509
+ });
510
+ await vi.advanceTimersByTimeAsync(0);
511
+
512
+ // First attempt fails, at max retries
513
+ expect(p.getStatus('a')!.status).toBe('failed');
514
+ expect(p.getStatus('a')!.error).toBe('Service Unavailable');
515
+ expect(p.getStatus('a')!.errorCode).toBe('server_error');
516
+ p.dispose();
517
+ });
518
+
519
+ it('calls onFailed hook after max retries', async () => {
520
+ const failed = vi.fn();
521
+
522
+ class LimitedPending extends Pending<string> {
523
+ static override MAX_RETRIES = 1;
524
+ protected onFailed(id: string, operation: string, error: unknown) {
525
+ failed(id, operation, error);
526
+ }
527
+ }
528
+
529
+ const p = new LimitedPending();
530
+ const err = new HttpError(500, 'Server Error');
531
+
532
+ p.enqueue('a', 'delete', async () => { throw err; });
533
+ await vi.advanceTimersByTimeAsync(0);
534
+
535
+ expect(failed).toHaveBeenCalledWith('a', 'delete', err);
536
+ p.dispose();
537
+ });
538
+ });
539
+
540
+ describe('non-retryable errors', () => {
541
+ it('fails immediately on 401 (unauthorized)', async () => {
542
+ const p = new Pending<string>();
543
+
544
+ p.enqueue('a', 'op', async () => {
545
+ throw new HttpError(401, 'Unauthorized');
546
+ });
547
+ await vi.advanceTimersByTimeAsync(0);
548
+
549
+ expect(p.getStatus('a')!.status).toBe('failed');
550
+ expect(p.getStatus('a')!.errorCode).toBe('unauthorized');
551
+ p.dispose();
552
+ });
553
+
554
+ it('fails immediately on 403 (forbidden)', async () => {
555
+ const p = new Pending<string>();
556
+
557
+ p.enqueue('a', 'op', async () => {
558
+ throw new HttpError(403, 'Forbidden');
559
+ });
560
+ await vi.advanceTimersByTimeAsync(0);
561
+
562
+ expect(p.getStatus('a')!.status).toBe('failed');
563
+ expect(p.getStatus('a')!.errorCode).toBe('forbidden');
564
+ p.dispose();
565
+ });
566
+
567
+ it('fails immediately on 404 (not_found)', async () => {
568
+ const p = new Pending<string>();
569
+
570
+ p.enqueue('a', 'op', async () => {
571
+ throw new HttpError(404, 'Not Found');
572
+ });
573
+ await vi.advanceTimersByTimeAsync(0);
574
+
575
+ expect(p.getStatus('a')!.status).toBe('failed');
576
+ expect(p.getStatus('a')!.errorCode).toBe('not_found');
577
+ p.dispose();
578
+ });
579
+
580
+ it('fails immediately on 422 (validation)', async () => {
581
+ const p = new Pending<string>();
582
+
583
+ p.enqueue('a', 'op', async () => {
584
+ throw new HttpError(422, 'Unprocessable Entity');
585
+ });
586
+ await vi.advanceTimersByTimeAsync(0);
587
+
588
+ expect(p.getStatus('a')!.status).toBe('failed');
589
+ expect(p.getStatus('a')!.errorCode).toBe('validation');
590
+ p.dispose();
591
+ });
592
+
593
+ it('calls onFailed hook immediately', async () => {
594
+ const failed = vi.fn();
595
+
596
+ class TestPending extends Pending<string> {
597
+ protected onFailed(id: string, operation: string, error: unknown) {
598
+ failed(id, operation, error);
599
+ }
600
+ }
601
+
602
+ const p = new TestPending();
603
+ const err = new HttpError(403, 'Forbidden');
604
+
605
+ p.enqueue('a', 'update', async () => { throw err; });
606
+ await vi.advanceTimersByTimeAsync(0);
607
+
608
+ expect(failed).toHaveBeenCalledWith('a', 'update', err);
609
+ p.dispose();
610
+ });
611
+
612
+ it('sets error and errorCode on the operation', async () => {
613
+ const p = new Pending<string>();
614
+
615
+ p.enqueue('a', 'op', async () => {
616
+ throw new HttpError(404, 'Not Found');
617
+ });
618
+ await vi.advanceTimersByTimeAsync(0);
619
+
620
+ const status = p.getStatus('a')!;
621
+ expect(status.error).toBe('Not Found');
622
+ expect(status.errorCode).toBe('not_found');
623
+ p.dispose();
624
+ });
625
+ });
626
+
627
+ describe('abort handling', () => {
628
+ it('removes operation silently on AbortError', async () => {
629
+ const p = new Pending<string>();
630
+
631
+ p.enqueue('a', 'op', async () => {
632
+ const err = new Error('The operation was aborted');
633
+ err.name = 'AbortError';
634
+ throw err;
635
+ });
636
+ await vi.advanceTimersByTimeAsync(0);
637
+
638
+ expect(p.has('a')).toBe(false);
639
+ expect(p.getStatus('a')).toBe(null);
640
+ p.dispose();
641
+ });
642
+
643
+ it('does not call onFailed for AbortError', async () => {
644
+ const failed = vi.fn();
645
+
646
+ class TestPending extends Pending<string> {
647
+ protected onFailed(id: string, operation: string, error: unknown) {
648
+ failed(id, operation, error);
649
+ }
650
+ }
651
+
652
+ const p = new TestPending();
653
+
654
+ p.enqueue('a', 'op', async () => {
655
+ const err = new Error('Aborted');
656
+ err.name = 'AbortError';
657
+ throw err;
658
+ });
659
+ await vi.advanceTimersByTimeAsync(0);
660
+
661
+ expect(failed).not.toHaveBeenCalled();
662
+ p.dispose();
663
+ });
664
+ });
665
+
666
+ describe('retry(id)', () => {
667
+ it('re-processes a failed operation', async () => {
668
+ class LimitedPending extends Pending<string> {
669
+ static override MAX_RETRIES = 1;
670
+ }
671
+
672
+ const p = new LimitedPending();
673
+ let callCount = 0;
674
+
675
+ p.enqueue('a', 'op', async () => {
676
+ callCount++;
677
+ if (callCount === 1) throw new HttpError(500, 'Server Error');
678
+ // Second call succeeds
679
+ });
680
+ await vi.advanceTimersByTimeAsync(0);
681
+
682
+ expect(p.getStatus('a')!.status).toBe('failed');
683
+ expect(callCount).toBe(1);
684
+
685
+ p.retry('a');
686
+ await vi.advanceTimersByTimeAsync(0);
687
+
688
+ expect(p.has('a')).toBe(false); // succeeded on retry
689
+ expect(callCount).toBe(2);
690
+ p.dispose();
691
+ });
692
+
693
+ it('resets attempts to 0', async () => {
694
+ class LimitedPending extends Pending<string> {
695
+ static override MAX_RETRIES = 1;
696
+ }
697
+
698
+ const p = new LimitedPending();
699
+
700
+ p.enqueue('a', 'op', async () => {
701
+ throw new HttpError(500, 'Error');
702
+ });
703
+ await vi.advanceTimersByTimeAsync(0);
704
+
705
+ expect(p.getStatus('a')!.attempts).toBe(1);
706
+
707
+ p.retry('a');
708
+ // After retry starts, attempts should be 1 (reset to 0, then incremented by _process)
709
+ await vi.advanceTimersByTimeAsync(0);
710
+ expect(p.getStatus('a')!.attempts).toBe(1);
711
+ p.dispose();
712
+ });
713
+
714
+ it('is no-op for non-failed operations', async () => {
715
+ const p = new Pending<string>();
716
+
717
+ p.enqueue('a', 'op', () => new Promise(() => {}));
718
+ await vi.advanceTimersByTimeAsync(0);
719
+
720
+ const statusBefore = p.getStatus('a');
721
+ p.retry('a'); // should be no-op since status is 'active'
722
+ const statusAfter = p.getStatus('a');
723
+
724
+ expect(statusBefore).toEqual(statusAfter);
725
+ p.dispose();
726
+ });
727
+
728
+ it('is no-op for unknown IDs', () => {
729
+ const p = new Pending<string>();
730
+ // Should not throw
731
+ p.retry('nonexistent');
732
+ p.dispose();
733
+ });
734
+ });
735
+
736
+ describe('retryAll', () => {
737
+ it('retries all failed operations', async () => {
738
+ class LimitedPending extends Pending<string> {
739
+ static override MAX_RETRIES = 1;
740
+ }
741
+
742
+ const p = new LimitedPending();
743
+ let aCount = 0;
744
+ let bCount = 0;
745
+
746
+ p.enqueue('a', 'op', async () => {
747
+ aCount++;
748
+ if (aCount === 1) throw new HttpError(500, 'Error');
749
+ });
750
+ p.enqueue('b', 'op', async () => {
751
+ bCount++;
752
+ if (bCount === 1) throw new HttpError(502, 'Error');
753
+ });
754
+ await vi.advanceTimersByTimeAsync(0);
755
+
756
+ expect(p.getStatus('a')!.status).toBe('failed');
757
+ expect(p.getStatus('b')!.status).toBe('failed');
758
+
759
+ p.retryAll();
760
+ await vi.advanceTimersByTimeAsync(0);
761
+
762
+ // Both should have succeeded on retry
763
+ expect(p.has('a')).toBe(false);
764
+ expect(p.has('b')).toBe(false);
765
+ expect(aCount).toBe(2);
766
+ expect(bCount).toBe(2);
767
+ p.dispose();
768
+ });
769
+ });
770
+
771
+ describe('cancel', () => {
772
+ it('aborts the signal', async () => {
773
+ const p = new Pending<string>();
774
+ let capturedSignal: AbortSignal | null = null;
775
+
776
+ p.enqueue('a', 'op', async (signal) => {
777
+ capturedSignal = signal;
778
+ await new Promise(() => {}); // hang
779
+ });
780
+ await vi.advanceTimersByTimeAsync(0);
781
+
782
+ p.cancel('a');
783
+ expect(capturedSignal!.aborted).toBe(true);
784
+ p.dispose();
785
+ });
786
+
787
+ it('clears the retry timer', async () => {
788
+ const p = new Pending<string>();
789
+
790
+ p.enqueue('a', 'op', async () => {
791
+ throw new TypeError('Failed to fetch');
792
+ });
793
+ await vi.advanceTimersByTimeAsync(0);
794
+
795
+ expect(p.getStatus('a')!.status).toBe('retrying');
796
+
797
+ p.cancel('a');
798
+ expect(p.has('a')).toBe(false);
799
+
800
+ // Advance timers — nothing should happen
801
+ await vi.advanceTimersByTimeAsync(60000);
802
+ expect(p.count).toBe(0);
803
+ p.dispose();
804
+ });
805
+
806
+ it('removes from maps', async () => {
807
+ const p = new Pending<string>();
808
+
809
+ p.enqueue('a', 'op', () => new Promise(() => {}));
810
+ await vi.advanceTimersByTimeAsync(0);
811
+
812
+ p.cancel('a');
813
+ expect(p.has('a')).toBe(false);
814
+ expect(p.getStatus('a')).toBe(null);
815
+ expect(p.count).toBe(0);
816
+ p.dispose();
817
+ });
818
+
819
+ it('notifies listeners', async () => {
820
+ const p = new Pending<string>();
821
+ p.enqueue('a', 'op', () => new Promise(() => {}));
822
+ await vi.advanceTimersByTimeAsync(0);
823
+
824
+ const listener = vi.fn();
825
+ p.subscribe(listener);
826
+ p.cancel('a');
827
+ expect(listener).toHaveBeenCalledTimes(1);
828
+ p.dispose();
829
+ });
830
+
831
+ it('is no-op for unknown IDs', () => {
832
+ const p = new Pending<string>();
833
+ const listener = vi.fn();
834
+ p.subscribe(listener);
835
+ p.cancel('nonexistent');
836
+ expect(listener).not.toHaveBeenCalled();
837
+ p.dispose();
838
+ });
839
+ });
840
+
841
+ describe('cancelAll', () => {
842
+ it('cancels all pending operations', async () => {
843
+ const p = new Pending<string>();
844
+ let signalA: AbortSignal | null = null;
845
+ let signalB: AbortSignal | null = null;
846
+
847
+ p.enqueue('a', 'op', async (signal) => {
848
+ signalA = signal;
849
+ await new Promise(() => {});
850
+ });
851
+ p.enqueue('b', 'op', async (signal) => {
852
+ signalB = signal;
853
+ await new Promise(() => {});
854
+ });
855
+ await vi.advanceTimersByTimeAsync(0);
856
+
857
+ p.cancelAll();
858
+
859
+ expect(signalA!.aborted).toBe(true);
860
+ expect(signalB!.aborted).toBe(true);
861
+ expect(p.count).toBe(0);
862
+ expect(p.has('a')).toBe(false);
863
+ expect(p.has('b')).toBe(false);
864
+ p.dispose();
865
+ });
866
+ });
867
+
868
+ describe('dismiss', () => {
869
+ it('removes a failed operation', async () => {
870
+ class LimitedPending extends Pending<string> {
871
+ static override MAX_RETRIES = 1;
872
+ }
873
+
874
+ const p = new LimitedPending();
875
+
876
+ p.enqueue('a', 'op', async () => {
877
+ throw new HttpError(404, 'Not Found');
878
+ });
879
+ await vi.advanceTimersByTimeAsync(0);
880
+
881
+ expect(p.getStatus('a')!.status).toBe('failed');
882
+
883
+ p.dismiss('a');
884
+ expect(p.has('a')).toBe(false);
885
+ expect(p.getStatus('a')).toBe(null);
886
+ expect(p.count).toBe(0);
887
+ p.dispose();
888
+ });
889
+
890
+ it('notifies listeners', async () => {
891
+ class LimitedPending extends Pending<string> {
892
+ static override MAX_RETRIES = 1;
893
+ }
894
+
895
+ const p = new LimitedPending();
896
+
897
+ p.enqueue('a', 'op', async () => {
898
+ throw new HttpError(404, 'Not Found');
899
+ });
900
+ await vi.advanceTimersByTimeAsync(0);
901
+
902
+ const listener = vi.fn();
903
+ p.subscribe(listener);
904
+ p.dismiss('a');
905
+ expect(listener).toHaveBeenCalledTimes(1);
906
+ p.dispose();
907
+ });
908
+
909
+ it('is no-op for active operations', async () => {
910
+ const p = new Pending<string>();
911
+
912
+ p.enqueue('a', 'op', () => new Promise(() => {}));
913
+ await vi.advanceTimersByTimeAsync(0);
914
+
915
+ const listener = vi.fn();
916
+ p.subscribe(listener);
917
+ p.dismiss('a'); // active, not failed
918
+ expect(listener).not.toHaveBeenCalled();
919
+ expect(p.has('a')).toBe(true);
920
+ p.dispose();
921
+ });
922
+
923
+ it('is no-op for retrying operations', async () => {
924
+ const p = new Pending<string>();
925
+
926
+ p.enqueue('a', 'op', async () => {
927
+ throw new TypeError('Failed to fetch');
928
+ });
929
+ await vi.advanceTimersByTimeAsync(0);
930
+
931
+ expect(p.getStatus('a')!.status).toBe('retrying');
932
+
933
+ const listener = vi.fn();
934
+ p.subscribe(listener);
935
+ p.dismiss('a'); // retrying, not failed
936
+ expect(listener).not.toHaveBeenCalled();
937
+ expect(p.has('a')).toBe(true);
938
+ p.dispose();
939
+ });
940
+
941
+ it('is no-op for unknown IDs', () => {
942
+ const p = new Pending<string>();
943
+ const listener = vi.fn();
944
+ p.subscribe(listener);
945
+ p.dismiss('nonexistent');
946
+ expect(listener).not.toHaveBeenCalled();
947
+ p.dispose();
948
+ });
949
+ });
950
+
951
+ describe('dismissAll', () => {
952
+ it('removes all failed operations', async () => {
953
+ class LimitedPending extends Pending<string> {
954
+ static override MAX_RETRIES = 1;
955
+ }
956
+
957
+ const p = new LimitedPending();
958
+
959
+ p.enqueue('a', 'op', async () => { throw new HttpError(404, 'Not Found'); });
960
+ p.enqueue('b', 'op', async () => { throw new HttpError(403, 'Forbidden'); });
961
+ p.enqueue('c', 'op', () => new Promise(() => {})); // stays active
962
+ await vi.advanceTimersByTimeAsync(0);
963
+
964
+ expect(p.failedCount).toBe(2);
965
+ expect(p.count).toBe(3);
966
+
967
+ p.dismissAll();
968
+
969
+ expect(p.failedCount).toBe(0);
970
+ expect(p.count).toBe(1); // only 'c' remains
971
+ expect(p.has('a')).toBe(false);
972
+ expect(p.has('b')).toBe(false);
973
+ expect(p.has('c')).toBe(true);
974
+ p.dispose();
975
+ });
976
+
977
+ it('fires single notification', async () => {
978
+ class LimitedPending extends Pending<string> {
979
+ static override MAX_RETRIES = 1;
980
+ }
981
+
982
+ const p = new LimitedPending();
983
+
984
+ p.enqueue('a', 'op', async () => { throw new HttpError(404, 'NF'); });
985
+ p.enqueue('b', 'op', async () => { throw new HttpError(403, 'F'); });
986
+ await vi.advanceTimersByTimeAsync(0);
987
+
988
+ const listener = vi.fn();
989
+ p.subscribe(listener);
990
+ p.dismissAll();
991
+ expect(listener).toHaveBeenCalledTimes(1);
992
+ p.dispose();
993
+ });
994
+
995
+ it('does not affect retrying operations', async () => {
996
+ class LimitedPending extends Pending<string> {
997
+ static override MAX_RETRIES = 2;
998
+ }
999
+
1000
+ const p = new LimitedPending();
1001
+
1002
+ // 'a' will fail immediately (non-retryable)
1003
+ p.enqueue('a', 'op', async () => { throw new HttpError(404, 'Not Found'); });
1004
+ // 'b' will be retrying (retryable, under max)
1005
+ p.enqueue('b', 'op', async () => { throw new TypeError('Failed to fetch'); });
1006
+ await vi.advanceTimersByTimeAsync(0);
1007
+
1008
+ expect(p.getStatus('a')!.status).toBe('failed');
1009
+ expect(p.getStatus('b')!.status).toBe('retrying');
1010
+
1011
+ p.dismissAll();
1012
+
1013
+ expect(p.has('a')).toBe(false); // dismissed
1014
+ expect(p.has('b')).toBe(true); // still retrying
1015
+ expect(p.getStatus('b')!.status).toBe('retrying');
1016
+ p.dispose();
1017
+ });
1018
+
1019
+ it('is no-op when no failed operations', () => {
1020
+ const p = new Pending<string>();
1021
+ const listener = vi.fn();
1022
+ p.subscribe(listener);
1023
+ p.dismissAll();
1024
+ expect(listener).not.toHaveBeenCalled();
1025
+ p.dispose();
1026
+ });
1027
+ });
1028
+
1029
+ describe('hasPending', () => {
1030
+ it('is false when only failed operations exist', async () => {
1031
+ class LimitedPending extends Pending<string> {
1032
+ static override MAX_RETRIES = 1;
1033
+ }
1034
+
1035
+ const p = new LimitedPending();
1036
+
1037
+ p.enqueue('a', 'op', async () => {
1038
+ throw new HttpError(404, 'Not Found');
1039
+ });
1040
+ await vi.advanceTimersByTimeAsync(0);
1041
+
1042
+ expect(p.getStatus('a')!.status).toBe('failed');
1043
+ expect(p.count).toBe(1);
1044
+ expect(p.hasPending).toBe(false);
1045
+ expect(p.hasFailed).toBe(true);
1046
+ p.dispose();
1047
+ });
1048
+
1049
+ it('is true when there are active and failed operations', async () => {
1050
+ class LimitedPending extends Pending<string> {
1051
+ static override MAX_RETRIES = 1;
1052
+ }
1053
+
1054
+ const p = new LimitedPending();
1055
+
1056
+ p.enqueue('a', 'op', async () => { throw new HttpError(404, 'NF'); });
1057
+ p.enqueue('b', 'op', () => new Promise(() => {})); // stays active
1058
+ await vi.advanceTimersByTimeAsync(0);
1059
+
1060
+ expect(p.hasPending).toBe(true);
1061
+ expect(p.hasFailed).toBe(true);
1062
+ p.dispose();
1063
+ });
1064
+
1065
+ it('is true when there are retrying operations (no active, no failed)', async () => {
1066
+ const p = new Pending<string>();
1067
+
1068
+ p.enqueue('a', 'op', async () => {
1069
+ throw new TypeError('Failed to fetch');
1070
+ });
1071
+ await vi.advanceTimersByTimeAsync(0);
1072
+
1073
+ expect(p.getStatus('a')!.status).toBe('retrying');
1074
+ expect(p.hasPending).toBe(true);
1075
+ expect(p.hasFailed).toBe(false);
1076
+ p.dispose();
1077
+ });
1078
+ });
1079
+
1080
+ describe('failedCount', () => {
1081
+ it('returns 0 when no failures', () => {
1082
+ const p = new Pending<string>();
1083
+ expect(p.failedCount).toBe(0);
1084
+ p.dispose();
1085
+ });
1086
+
1087
+ it('counts failed operations', async () => {
1088
+ class LimitedPending extends Pending<string> {
1089
+ static override MAX_RETRIES = 1;
1090
+ }
1091
+
1092
+ const p = new LimitedPending();
1093
+
1094
+ p.enqueue('a', 'op', async () => { throw new HttpError(404, 'NF'); });
1095
+ p.enqueue('b', 'op', async () => { throw new HttpError(403, 'F'); });
1096
+ p.enqueue('c', 'op', () => new Promise(() => {})); // active
1097
+ await vi.advanceTimersByTimeAsync(0);
1098
+
1099
+ expect(p.failedCount).toBe(2);
1100
+ expect(p.count).toBe(3);
1101
+ p.dispose();
1102
+ });
1103
+
1104
+ it('decreases when failed op is dismissed', async () => {
1105
+ class LimitedPending extends Pending<string> {
1106
+ static override MAX_RETRIES = 1;
1107
+ }
1108
+
1109
+ const p = new LimitedPending();
1110
+
1111
+ p.enqueue('a', 'op', async () => { throw new HttpError(404, 'NF'); });
1112
+ p.enqueue('b', 'op', async () => { throw new HttpError(403, 'F'); });
1113
+ await vi.advanceTimersByTimeAsync(0);
1114
+
1115
+ expect(p.failedCount).toBe(2);
1116
+ p.dismiss('a');
1117
+ expect(p.failedCount).toBe(1);
1118
+ p.dispose();
1119
+ });
1120
+
1121
+ it('decreases when failed op is retried', async () => {
1122
+ class LimitedPending extends Pending<string> {
1123
+ static override MAX_RETRIES = 1;
1124
+ }
1125
+
1126
+ const p = new LimitedPending();
1127
+ let callCount = 0;
1128
+
1129
+ p.enqueue('a', 'op', async () => {
1130
+ callCount++;
1131
+ if (callCount === 1) throw new HttpError(500, 'Error');
1132
+ });
1133
+ await vi.advanceTimersByTimeAsync(0);
1134
+
1135
+ expect(p.failedCount).toBe(1);
1136
+
1137
+ p.retry('a');
1138
+ // After retry starts, op is active — no longer failed
1139
+ expect(p.failedCount).toBe(0);
1140
+
1141
+ await vi.advanceTimersByTimeAsync(0);
1142
+ // Succeeded on retry — removed entirely
1143
+ expect(p.count).toBe(0);
1144
+ p.dispose();
1145
+ });
1146
+ });
1147
+
1148
+ describe('no queued status', () => {
1149
+ it('initial snapshot from enqueue is active, not queued', () => {
1150
+ const p = new Pending<string>();
1151
+ p.enqueue('a', 'op', () => new Promise(() => {}));
1152
+
1153
+ // Synchronously after enqueue, before microtask
1154
+ const status = p.getStatus('a');
1155
+ expect(status!.status).toBe('active');
1156
+ p.dispose();
1157
+ });
1158
+
1159
+ it('status transitions are active → retrying → failed (no queued)', async () => {
1160
+ class LimitedPending extends Pending<string> {
1161
+ static override MAX_RETRIES = 1;
1162
+ }
1163
+
1164
+ const p = new LimitedPending();
1165
+ const statuses: string[] = [];
1166
+ p.subscribe(() => {
1167
+ const s = p.getStatus('a');
1168
+ if (s && !statuses.includes(s.status)) statuses.push(s.status);
1169
+ });
1170
+
1171
+ p.enqueue('a', 'op', async () => {
1172
+ throw new TypeError('Failed to fetch');
1173
+ });
1174
+ await vi.advanceTimersByTimeAsync(0);
1175
+
1176
+ expect(statuses).toEqual(['active', 'failed']);
1177
+ expect(statuses).not.toContain('queued');
1178
+ p.dispose();
1179
+ });
1180
+ });
1181
+
1182
+ describe('isRetryable override', () => {
1183
+ it('subclass can override to customize retry logic', async () => {
1184
+ class CustomPending extends Pending<string> {
1185
+ protected override isRetryable(_error: unknown): boolean {
1186
+ return false; // never retry
1187
+ }
1188
+ }
1189
+
1190
+ const p = new CustomPending();
1191
+
1192
+ p.enqueue('a', 'op', async () => {
1193
+ throw new TypeError('Failed to fetch'); // normally retryable
1194
+ });
1195
+ await vi.advanceTimersByTimeAsync(0);
1196
+
1197
+ // Should fail immediately despite being a network error
1198
+ expect(p.getStatus('a')!.status).toBe('failed');
1199
+ p.dispose();
1200
+ });
1201
+ });
1202
+
1203
+ describe('static config override', () => {
1204
+ it('subclass can override MAX_RETRIES', async () => {
1205
+ class CustomPending extends Pending<string> {
1206
+ static override MAX_RETRIES = 2;
1207
+ }
1208
+
1209
+ const p = new CustomPending();
1210
+ let callCount = 0;
1211
+
1212
+ p.enqueue('a', 'op', async () => {
1213
+ callCount++;
1214
+ throw new TypeError('Failed to fetch');
1215
+ });
1216
+
1217
+ await vi.advanceTimersByTimeAsync(0);
1218
+ expect(callCount).toBe(1);
1219
+
1220
+ await vi.advanceTimersByTimeAsync(30000);
1221
+ expect(callCount).toBe(2);
1222
+ expect(p.getStatus('a')!.status).toBe('failed');
1223
+ expect(p.getStatus('a')!.maxRetries).toBe(2);
1224
+ p.dispose();
1225
+ });
1226
+
1227
+ it('backoff respects overridden RETRY_BASE and RETRY_FACTOR', async () => {
1228
+ vi.spyOn(Math, 'random').mockReturnValue(1);
1229
+
1230
+ class FastPending extends Pending<string> {
1231
+ static override RETRY_BASE = 100;
1232
+ static override RETRY_FACTOR = 3;
1233
+ static override RETRY_MAX = 50000;
1234
+ }
1235
+
1236
+ const p = new FastPending();
1237
+ const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
1238
+
1239
+ p.enqueue('a', 'op', async () => {
1240
+ throw new TypeError('Failed to fetch');
1241
+ });
1242
+ await vi.advanceTimersByTimeAsync(0);
1243
+
1244
+ // With random()=1: delay = 1 * min(100 * 3^0, 50000) = 100
1245
+ const retryCalls = setTimeoutSpy.mock.calls.filter(
1246
+ call => typeof call[1] === 'number' && call[1] > 0,
1247
+ );
1248
+ expect(retryCalls[retryCalls.length - 1][1]).toBe(100);
1249
+
1250
+ vi.spyOn(Math, 'random').mockRestore();
1251
+ setTimeoutSpy.mockRestore();
1252
+ p.dispose();
1253
+ });
1254
+ });
1255
+
1256
+ describe('subscribe', () => {
1257
+ it('notifies on enqueue', () => {
1258
+ const p = new Pending<string>();
1259
+ const listener = vi.fn();
1260
+ p.subscribe(listener);
1261
+
1262
+ p.enqueue('a', 'op', async () => {});
1263
+ expect(listener).toHaveBeenCalled();
1264
+ p.dispose();
1265
+ });
1266
+
1267
+ it('notifies on status change', async () => {
1268
+ const p = new Pending<string>();
1269
+ const listener = vi.fn();
1270
+ p.subscribe(listener);
1271
+
1272
+ p.enqueue('a', 'op', async () => {});
1273
+ const countAfterEnqueue = listener.mock.calls.length;
1274
+
1275
+ await vi.advanceTimersByTimeAsync(0);
1276
+ // Should have been notified again when _process ran
1277
+ expect(listener.mock.calls.length).toBeGreaterThan(countAfterEnqueue);
1278
+ p.dispose();
1279
+ });
1280
+
1281
+ it('notifies on confirmation', async () => {
1282
+ const p = new Pending<string>();
1283
+ const listener = vi.fn();
1284
+ p.subscribe(listener);
1285
+
1286
+ p.enqueue('a', 'op', async () => {});
1287
+ await vi.advanceTimersByTimeAsync(0);
1288
+
1289
+ // Should have notifications for: enqueue (active), _process (active), confirmed (removed)
1290
+ expect(listener.mock.calls.length).toBeGreaterThanOrEqual(2);
1291
+ p.dispose();
1292
+ });
1293
+
1294
+ it('notifies on cancellation', async () => {
1295
+ const p = new Pending<string>();
1296
+ p.enqueue('a', 'op', () => new Promise(() => {}));
1297
+ await vi.advanceTimersByTimeAsync(0);
1298
+
1299
+ const listener = vi.fn();
1300
+ p.subscribe(listener);
1301
+ p.cancel('a');
1302
+ expect(listener).toHaveBeenCalledTimes(1);
1303
+ p.dispose();
1304
+ });
1305
+
1306
+ it('unsubscribe stops notifications', () => {
1307
+ const p = new Pending<string>();
1308
+ const listener = vi.fn();
1309
+ const unsub = p.subscribe(listener);
1310
+ unsub();
1311
+
1312
+ p.enqueue('a', 'op', async () => {});
1313
+ expect(listener).not.toHaveBeenCalled();
1314
+ p.dispose();
1315
+ });
1316
+ });
1317
+
1318
+ describe('dispose', () => {
1319
+ it('cancels all operations', async () => {
1320
+ const p = new Pending<string>();
1321
+ let signal: AbortSignal | null = null;
1322
+
1323
+ p.enqueue('a', 'op', async (s) => {
1324
+ signal = s;
1325
+ await new Promise(() => {});
1326
+ });
1327
+ await vi.advanceTimersByTimeAsync(0);
1328
+
1329
+ p.dispose();
1330
+ expect(signal!.aborted).toBe(true);
1331
+ expect(p.count).toBe(0);
1332
+ });
1333
+
1334
+ it('clears all listeners', async () => {
1335
+ const p = new Pending<string>();
1336
+ const listener = vi.fn();
1337
+ p.subscribe(listener);
1338
+
1339
+ p.dispose();
1340
+ listener.mockClear();
1341
+
1342
+ // Even if somehow a notification fires, listener shouldn't be called
1343
+ expect(listener).not.toHaveBeenCalled();
1344
+ });
1345
+
1346
+ it('subsequent enqueue is no-op', () => {
1347
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
1348
+ const p = new Pending<string>();
1349
+ p.dispose();
1350
+
1351
+ p.enqueue('a', 'op', async () => {});
1352
+ expect(p.count).toBe(0);
1353
+ expect(p.disposed).toBe(true);
1354
+ warnSpy.mockRestore();
1355
+ });
1356
+ });
1357
+
1358
+ describe('ViewModel auto-tracking integration', () => {
1359
+ it('is auto-tracked when assigned as ViewModel property', () => {
1360
+ class TestVM extends ViewModel {
1361
+ readonly pending = new Pending<string>();
1362
+
1363
+ get pendingCount() {
1364
+ return this.pending.count;
1365
+ }
1366
+ }
1367
+
1368
+ const vm = new TestVM();
1369
+ vm.init();
1370
+
1371
+ const listener = vi.fn();
1372
+ vm.subscribe(listener);
1373
+
1374
+ vm.pending.enqueue('a', 'op', () => new Promise(() => {}));
1375
+ expect(listener).toHaveBeenCalled();
1376
+
1377
+ vm.dispose();
1378
+ });
1379
+
1380
+ it('ViewModel getter recomputes when operation status changes', async () => {
1381
+ let computeCount = 0;
1382
+
1383
+ class TestVM extends ViewModel {
1384
+ readonly pending = new Pending<string>();
1385
+
1386
+ get pendingCount() {
1387
+ computeCount++;
1388
+ return this.pending.count;
1389
+ }
1390
+ }
1391
+
1392
+ const vm = new TestVM();
1393
+ vm.init();
1394
+
1395
+ expect(vm.pendingCount).toBe(0);
1396
+ expect(computeCount).toBe(1);
1397
+
1398
+ // Cached
1399
+ vm.pendingCount;
1400
+ expect(computeCount).toBe(1);
1401
+
1402
+ // Enqueue — getter should recompute
1403
+ vm.pending.enqueue('a', 'op', async () => {});
1404
+ await vi.advanceTimersByTimeAsync(0);
1405
+
1406
+ // After completion, count is 0 again
1407
+ expect(vm.pendingCount).toBe(0);
1408
+ expect(computeCount).toBeGreaterThan(1);
1409
+
1410
+ vm.dispose();
1411
+ });
1412
+
1413
+ it('ViewModel getter caches when no change', () => {
1414
+ let computeCount = 0;
1415
+
1416
+ class TestVM extends ViewModel {
1417
+ readonly pending = new Pending<string>();
1418
+
1419
+ get hasFailed() {
1420
+ computeCount++;
1421
+ return this.pending.hasFailed;
1422
+ }
1423
+ }
1424
+
1425
+ const vm = new TestVM();
1426
+ vm.init();
1427
+
1428
+ expect(vm.hasFailed).toBe(false);
1429
+ expect(computeCount).toBe(1);
1430
+
1431
+ // Cached — same result
1432
+ vm.hasFailed;
1433
+ expect(computeCount).toBe(1);
1434
+
1435
+ vm.dispose();
1436
+ });
1437
+ });
1438
+
1439
+ describe('meta', () => {
1440
+ interface TestMeta { label: string; priority: number }
1441
+
1442
+ it('enqueue with meta stores it on snapshot', async () => {
1443
+ const p = new Pending<string, TestMeta>();
1444
+ let resolve!: () => void;
1445
+ const promise = new Promise<void>(r => { resolve = r; });
1446
+
1447
+ p.enqueue('a', 'send', () => promise, { label: 'hello', priority: 1 });
1448
+ await vi.advanceTimersByTimeAsync(0);
1449
+
1450
+ const status = p.getStatus('a');
1451
+ expect(status!.meta).toEqual({ label: 'hello', priority: 1 });
1452
+
1453
+ resolve();
1454
+ await vi.advanceTimersByTimeAsync(0);
1455
+ p.dispose();
1456
+ });
1457
+
1458
+ it('enqueue without meta defaults to null', async () => {
1459
+ const p = new Pending<string, TestMeta>();
1460
+ let resolve!: () => void;
1461
+ const promise = new Promise<void>(r => { resolve = r; });
1462
+
1463
+ p.enqueue('a', 'send', () => promise);
1464
+ await vi.advanceTimersByTimeAsync(0);
1465
+
1466
+ const status = p.getStatus('a');
1467
+ expect(status!.meta).toBe(null);
1468
+
1469
+ resolve();
1470
+ await vi.advanceTimersByTimeAsync(0);
1471
+ p.dispose();
1472
+ });
1473
+
1474
+ it('untyped Pending has meta: null', async () => {
1475
+ const p = new Pending<string>();
1476
+ let resolve!: () => void;
1477
+ const promise = new Promise<void>(r => { resolve = r; });
1478
+
1479
+ p.enqueue('a', 'delete', () => promise);
1480
+ await vi.advanceTimersByTimeAsync(0);
1481
+
1482
+ expect(p.getStatus('a')!.meta).toBe(null);
1483
+
1484
+ resolve();
1485
+ await vi.advanceTimersByTimeAsync(0);
1486
+ p.dispose();
1487
+ });
1488
+
1489
+ it('meta survives retry cycles', async () => {
1490
+ const p = new Pending<string, TestMeta>();
1491
+ let callCount = 0;
1492
+
1493
+ p.enqueue('a', 'send', async () => {
1494
+ callCount++;
1495
+ if (callCount === 1) throw new TypeError('Failed to fetch');
1496
+ }, { label: 'retry-test', priority: 5 });
1497
+
1498
+ // First attempt — fails, enters retrying
1499
+ await vi.advanceTimersByTimeAsync(0);
1500
+ expect(p.getStatus('a')!.status).toBe('retrying');
1501
+ expect(p.getStatus('a')!.meta).toEqual({ label: 'retry-test', priority: 5 });
1502
+
1503
+ // Second attempt — succeeds, removed
1504
+ await vi.advanceTimersByTimeAsync(30_000);
1505
+ expect(p.getStatus('a')).toBe(null);
1506
+ expect(callCount).toBe(2);
1507
+
1508
+ p.dispose();
1509
+ });
1510
+
1511
+ it('supersede replaces meta', async () => {
1512
+ const p = new Pending<string, TestMeta>();
1513
+ let resolve1!: () => void;
1514
+ const promise1 = new Promise<void>(r => { resolve1 = r; });
1515
+
1516
+ p.enqueue('a', 'send', () => promise1, { label: 'first', priority: 1 });
1517
+ await vi.advanceTimersByTimeAsync(0);
1518
+ expect(p.getStatus('a')!.meta).toEqual({ label: 'first', priority: 1 });
1519
+
1520
+ // Supersede with different meta
1521
+ let resolve2!: () => void;
1522
+ const promise2 = new Promise<void>(r => { resolve2 = r; });
1523
+ p.enqueue('a', 'send', () => promise2, { label: 'second', priority: 2 });
1524
+ await vi.advanceTimersByTimeAsync(0);
1525
+ expect(p.getStatus('a')!.meta).toEqual({ label: 'second', priority: 2 });
1526
+
1527
+ resolve2();
1528
+ await vi.advanceTimersByTimeAsync(0);
1529
+ p.dispose();
1530
+ });
1531
+
1532
+ it('meta is frozen on snapshot', async () => {
1533
+ const p = new Pending<string, TestMeta>();
1534
+ let resolve!: () => void;
1535
+ const promise = new Promise<void>(r => { resolve = r; });
1536
+
1537
+ p.enqueue('a', 'send', () => promise, { label: 'frozen', priority: 1 });
1538
+ await vi.advanceTimersByTimeAsync(0);
1539
+
1540
+ const status = p.getStatus('a')!;
1541
+ expect(() => { (status as any).meta = 'changed'; }).toThrow();
1542
+
1543
+ resolve();
1544
+ await vi.advanceTimersByTimeAsync(0);
1545
+ p.dispose();
1546
+ });
1547
+ });
1548
+
1549
+ describe('entries', () => {
1550
+ it('returns empty array when no operations', () => {
1551
+ const p = new Pending<string>();
1552
+ expect(p.entries).toEqual([]);
1553
+ expect(p.entries.length).toBe(0);
1554
+ p.dispose();
1555
+ });
1556
+
1557
+ it('returns all operations with ids', async () => {
1558
+ const p = new Pending<string>();
1559
+ let resolve1!: () => void;
1560
+ let resolve2!: () => void;
1561
+ const p1 = new Promise<void>(r => { resolve1 = r; });
1562
+ const p2 = new Promise<void>(r => { resolve2 = r; });
1563
+
1564
+ p.enqueue('a', 'delete', () => p1);
1565
+ p.enqueue('b', 'update', () => p2);
1566
+ await vi.advanceTimersByTimeAsync(0);
1567
+
1568
+ const entries = p.entries;
1569
+ expect(entries.length).toBe(2);
1570
+
1571
+ const entryA = entries.find(e => e.id === 'a');
1572
+ const entryB = entries.find(e => e.id === 'b');
1573
+ expect(entryA).toBeDefined();
1574
+ expect(entryA!.operation).toBe('delete');
1575
+ expect(entryA!.status).toBe('active');
1576
+ expect(entryB).toBeDefined();
1577
+ expect(entryB!.operation).toBe('update');
1578
+
1579
+ resolve1();
1580
+ resolve2();
1581
+ await vi.advanceTimersByTimeAsync(0);
1582
+ p.dispose();
1583
+ });
1584
+
1585
+ it('entries include meta', async () => {
1586
+ interface Meta { label: string }
1587
+ const p = new Pending<string, Meta>();
1588
+ let resolve!: () => void;
1589
+ const promise = new Promise<void>(r => { resolve = r; });
1590
+
1591
+ p.enqueue('a', 'send', () => promise, { label: 'test-meta' });
1592
+ await vi.advanceTimersByTimeAsync(0);
1593
+
1594
+ const entry = p.entries[0]!;
1595
+ expect(entry.id).toBe('a');
1596
+ expect(entry.meta).toEqual({ label: 'test-meta' });
1597
+
1598
+ resolve();
1599
+ await vi.advanceTimersByTimeAsync(0);
1600
+ p.dispose();
1601
+ });
1602
+
1603
+ it('entries are reference-stable between reads without changes', async () => {
1604
+ const p = new Pending<string>();
1605
+ let resolve!: () => void;
1606
+ const promise = new Promise<void>(r => { resolve = r; });
1607
+
1608
+ p.enqueue('a', 'delete', () => promise);
1609
+ await vi.advanceTimersByTimeAsync(0);
1610
+
1611
+ const first = p.entries;
1612
+ const second = p.entries;
1613
+ expect(first).toBe(second); // same reference
1614
+
1615
+ resolve();
1616
+ await vi.advanceTimersByTimeAsync(0);
1617
+ p.dispose();
1618
+ });
1619
+
1620
+ it('entries cache invalidates on enqueue', async () => {
1621
+ const p = new Pending<string>();
1622
+ let resolve1!: () => void;
1623
+ let resolve2!: () => void;
1624
+ const p1 = new Promise<void>(r => { resolve1 = r; });
1625
+ const p2 = new Promise<void>(r => { resolve2 = r; });
1626
+
1627
+ p.enqueue('a', 'delete', () => p1);
1628
+ await vi.advanceTimersByTimeAsync(0);
1629
+
1630
+ const before = p.entries;
1631
+ expect(before.length).toBe(1);
1632
+
1633
+ p.enqueue('b', 'update', () => p2);
1634
+ await vi.advanceTimersByTimeAsync(0);
1635
+
1636
+ const after = p.entries;
1637
+ expect(after).not.toBe(before); // new reference
1638
+ expect(after.length).toBe(2);
1639
+
1640
+ resolve1();
1641
+ resolve2();
1642
+ await vi.advanceTimersByTimeAsync(0);
1643
+ p.dispose();
1644
+ });
1645
+
1646
+ it('entries cache invalidates on cancel', async () => {
1647
+ const p = new Pending<string>();
1648
+ p.enqueue('a', 'delete', () => new Promise(() => {}));
1649
+ await vi.advanceTimersByTimeAsync(0);
1650
+
1651
+ const before = p.entries;
1652
+ expect(before.length).toBe(1);
1653
+
1654
+ p.cancel('a');
1655
+
1656
+ const after = p.entries;
1657
+ expect(after).not.toBe(before);
1658
+ expect(after.length).toBe(0);
1659
+
1660
+ p.dispose();
1661
+ });
1662
+
1663
+ it('entries cache invalidates on dismiss', async () => {
1664
+ const p = new Pending<string>();
1665
+ p.enqueue('a', 'delete', async () => {
1666
+ throw new HttpError(422, 'Validation failed');
1667
+ });
1668
+ await vi.advanceTimersByTimeAsync(0);
1669
+
1670
+ expect(p.getStatus('a')!.status).toBe('failed');
1671
+ const before = p.entries;
1672
+ expect(before.length).toBe(1);
1673
+
1674
+ p.dismiss('a');
1675
+
1676
+ const after = p.entries;
1677
+ expect(after).not.toBe(before);
1678
+ expect(after.length).toBe(0);
1679
+
1680
+ p.dispose();
1681
+ });
1682
+
1683
+ it('entries are frozen', async () => {
1684
+ const p = new Pending<string>();
1685
+ let resolve!: () => void;
1686
+ const promise = new Promise<void>(r => { resolve = r; });
1687
+
1688
+ p.enqueue('a', 'delete', () => promise);
1689
+ await vi.advanceTimersByTimeAsync(0);
1690
+
1691
+ const entries = p.entries;
1692
+ expect(() => { (entries as any).push('x'); }).toThrow();
1693
+ expect(() => { (entries[0] as any).id = 'changed'; }).toThrow();
1694
+
1695
+ resolve();
1696
+ await vi.advanceTimersByTimeAsync(0);
1697
+ p.dispose();
1698
+ });
1699
+
1700
+ it('empty entries array is reference-stable', () => {
1701
+ const p = new Pending<string>();
1702
+ const first = p.entries;
1703
+ const second = p.entries;
1704
+ expect(first).toBe(second);
1705
+ expect(first.length).toBe(0);
1706
+ p.dispose();
1707
+ });
1708
+ });
1709
+
1710
+ describe('method binding', () => {
1711
+ it('destructured methods work point-free', () => {
1712
+ const pending = new Pending<string>();
1713
+ const { enqueue, has, getStatus } = pending;
1714
+ enqueue('1', 'save', async () => {});
1715
+ expect(has('1')).toBe(true);
1716
+ expect(getStatus('1')).not.toBeNull();
1717
+ });
1718
+ });
1719
+ });