mvc-kit 2.12.0 → 2.12.1

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 +10 -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,649 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { PersistentCollection } from './PersistentCollection';
3
+ import { singleton, teardownAll } from './singleton';
4
+
5
+ interface Todo {
6
+ id: string;
7
+ text: string;
8
+ done: boolean;
9
+ }
10
+
11
+ // ── In-memory mock adapter ──
12
+
13
+ class MockStore<T extends { id: string | number }> {
14
+ data = new Map<T['id'], T>();
15
+
16
+ getAll(): T[] {
17
+ return [...this.data.values()];
18
+ }
19
+ get(id: T['id']): T | null {
20
+ return this.data.get(id) ?? null;
21
+ }
22
+ set(items: T[]): void {
23
+ for (const item of items) {
24
+ this.data.set(item.id, item);
25
+ }
26
+ }
27
+ remove(ids: T['id'][]): void {
28
+ for (const id of ids) {
29
+ this.data.delete(id);
30
+ }
31
+ }
32
+ clear(): void {
33
+ this.data.clear();
34
+ }
35
+ }
36
+
37
+ let mockStore: MockStore<Todo>;
38
+
39
+ class TestPersistentCollection extends PersistentCollection<Todo> {
40
+ protected readonly storageKey = 'test-todos';
41
+
42
+ protected persistGet(id: Todo['id']): Todo | null {
43
+ return mockStore.get(id);
44
+ }
45
+ protected persistGetAll(): Todo[] {
46
+ return mockStore.getAll();
47
+ }
48
+ protected persistSet(items: Todo[]): void {
49
+ mockStore.set(items);
50
+ }
51
+ protected persistRemove(ids: Todo['id'][]): void {
52
+ mockStore.remove(ids);
53
+ }
54
+ protected persistClear(): void {
55
+ mockStore.clear();
56
+ }
57
+ }
58
+
59
+ describe('PersistentCollection', () => {
60
+ beforeEach(() => {
61
+ teardownAll();
62
+ mockStore = new MockStore();
63
+ vi.useFakeTimers();
64
+ });
65
+
66
+ afterEach(() => {
67
+ vi.useRealTimers();
68
+ });
69
+
70
+ // ── Hydration ──
71
+
72
+ describe('hydration', () => {
73
+ it('loads data from storage via hydrate()', async () => {
74
+ mockStore.set([
75
+ { id: '1', text: 'Stored', done: false },
76
+ { id: '2', text: 'Also stored', done: true },
77
+ ]);
78
+
79
+ const collection = new TestPersistentCollection();
80
+ expect(collection.hydrated).toBe(false);
81
+
82
+ const items = await collection.hydrate();
83
+
84
+ expect(collection.hydrated).toBe(true);
85
+ expect(items).toHaveLength(2);
86
+ expect(collection.length).toBe(2);
87
+ expect(collection.get('1')!.text).toBe('Stored');
88
+ collection.dispose();
89
+ });
90
+
91
+ it('hydrate() is idempotent — second call returns current items', async () => {
92
+ mockStore.set([{ id: '1', text: 'Stored', done: false }]);
93
+
94
+ const collection = new TestPersistentCollection();
95
+ await collection.hydrate();
96
+
97
+ // Add another item after hydration
98
+ collection.add({ id: '2', text: 'New', done: false });
99
+
100
+ // Second hydrate returns current state (not re-reading storage)
101
+ const items = await collection.hydrate();
102
+ expect(items).toHaveLength(2);
103
+ collection.dispose();
104
+ });
105
+
106
+ it('hydrate() does not trigger persist calls (skip-save)', async () => {
107
+ const persistSetSpy = vi.fn();
108
+ const persistClearSpy = vi.fn();
109
+
110
+ class SpyCollection extends PersistentCollection<Todo> {
111
+ protected readonly storageKey = 'spy-hydrate';
112
+ protected persistGet(id: Todo['id']): Todo | null {
113
+ return mockStore.get(id);
114
+ }
115
+ protected persistGetAll(): Todo[] {
116
+ return mockStore.getAll();
117
+ }
118
+ protected persistSet(items: Todo[]): void {
119
+ persistSetSpy(items);
120
+ mockStore.set(items);
121
+ }
122
+ protected persistRemove(ids: Todo['id'][]): void {
123
+ mockStore.remove(ids);
124
+ }
125
+ protected persistClear(): void {
126
+ persistClearSpy();
127
+ mockStore.clear();
128
+ }
129
+ }
130
+
131
+ mockStore.set([{ id: '1', text: 'Stored', done: false }]);
132
+ const collection = new SpyCollection();
133
+ await collection.hydrate();
134
+
135
+ // Flush any pending timers
136
+ vi.runAllTimers();
137
+
138
+ expect(persistSetSpy).not.toHaveBeenCalled();
139
+ expect(persistClearSpy).not.toHaveBeenCalled();
140
+ collection.dispose();
141
+ });
142
+
143
+ it('handles storage errors during hydrate gracefully', async () => {
144
+ const errorSpy = vi.fn();
145
+
146
+ class ErrorCollection extends PersistentCollection<Todo> {
147
+ protected readonly storageKey = 'error-hydrate';
148
+ protected onPersistError = errorSpy;
149
+ protected persistGet(): Todo | null { return null; }
150
+ protected persistGetAll(): Todo[] { throw new Error('Storage read failed'); }
151
+ protected persistSet(): void {}
152
+ protected persistRemove(): void {}
153
+ protected persistClear(): void {}
154
+ }
155
+
156
+ const collection = new ErrorCollection();
157
+ const items = await collection.hydrate();
158
+
159
+ expect(collection.hydrated).toBe(true);
160
+ expect(items).toHaveLength(0);
161
+ expect(errorSpy).toHaveBeenCalledWith(expect.any(Error));
162
+ collection.dispose();
163
+ });
164
+ });
165
+
166
+ // ── Per-item save (delta tracking) ──
167
+
168
+ describe('delta tracking', () => {
169
+ it('add() persists only the new items', () => {
170
+ const persistSetSpy = vi.fn((items: Todo[]) => mockStore.set(items));
171
+
172
+ class SpyCollection extends PersistentCollection<Todo> {
173
+ protected readonly storageKey = 'spy-add';
174
+ protected persistGet(id: Todo['id']) { return mockStore.get(id); }
175
+ protected persistGetAll() { return mockStore.getAll(); }
176
+ protected persistSet(items: Todo[]) { persistSetSpy(items); }
177
+ protected persistRemove(ids: Todo['id'][]) { mockStore.remove(ids); }
178
+ protected persistClear() { mockStore.clear(); }
179
+ }
180
+
181
+ const collection = new SpyCollection();
182
+ collection.add({ id: '1', text: 'First', done: false });
183
+ vi.runAllTimers();
184
+
185
+ expect(persistSetSpy).toHaveBeenCalledWith([{ id: '1', text: 'First', done: false }]);
186
+ collection.dispose();
187
+ });
188
+
189
+ it('remove() persists only the removed IDs', () => {
190
+ const persistRemoveSpy = vi.fn((ids: Todo['id'][]) => mockStore.remove(ids));
191
+
192
+ class SpyCollection extends PersistentCollection<Todo> {
193
+ protected readonly storageKey = 'spy-remove';
194
+ constructor(items: Todo[] = []) {
195
+ super(items);
196
+ }
197
+ protected persistGet(id: Todo['id']) { return mockStore.get(id); }
198
+ protected persistGetAll() { return mockStore.getAll(); }
199
+ protected persistSet(items: Todo[]) { mockStore.set(items); }
200
+ protected persistRemove(ids: Todo['id'][]) { persistRemoveSpy(ids); }
201
+ protected persistClear() { mockStore.clear(); }
202
+ }
203
+
204
+ const collection = new SpyCollection([
205
+ { id: '1', text: 'First', done: false },
206
+ { id: '2', text: 'Second', done: true },
207
+ ]);
208
+ collection.remove('1');
209
+ vi.runAllTimers();
210
+
211
+ expect(persistRemoveSpy).toHaveBeenCalledWith(['1']);
212
+ collection.dispose();
213
+ });
214
+
215
+ it('update() persists only the updated item', () => {
216
+ const persistSetSpy = vi.fn((items: Todo[]) => mockStore.set(items));
217
+
218
+ class SpyCollection extends PersistentCollection<Todo> {
219
+ protected readonly storageKey = 'spy-update';
220
+ constructor(items: Todo[] = []) {
221
+ super(items);
222
+ }
223
+ protected persistGet(id: Todo['id']) { return mockStore.get(id); }
224
+ protected persistGetAll() { return mockStore.getAll(); }
225
+ protected persistSet(items: Todo[]) { persistSetSpy(items); }
226
+ protected persistRemove(ids: Todo['id'][]) { mockStore.remove(ids); }
227
+ protected persistClear() { mockStore.clear(); }
228
+ }
229
+
230
+ const collection = new SpyCollection([{ id: '1', text: 'Original', done: false }]);
231
+ collection.update('1', { text: 'Updated' });
232
+ vi.runAllTimers();
233
+
234
+ expect(persistSetSpy).toHaveBeenCalledWith([
235
+ expect.objectContaining({ id: '1', text: 'Updated', done: false }),
236
+ ]);
237
+ collection.dispose();
238
+ });
239
+
240
+ it('upsert() persists changed/new items', () => {
241
+ const persistSetSpy = vi.fn((items: Todo[]) => mockStore.set(items));
242
+
243
+ class SpyCollection extends PersistentCollection<Todo> {
244
+ protected readonly storageKey = 'spy-upsert';
245
+ constructor(items: Todo[] = []) {
246
+ super(items);
247
+ }
248
+ protected persistGet(id: Todo['id']) { return mockStore.get(id); }
249
+ protected persistGetAll() { return mockStore.getAll(); }
250
+ protected persistSet(items: Todo[]) { persistSetSpy(items); }
251
+ protected persistRemove(ids: Todo['id'][]) { mockStore.remove(ids); }
252
+ protected persistClear() { mockStore.clear(); }
253
+ }
254
+
255
+ const collection = new SpyCollection([
256
+ { id: '1', text: 'Original', done: false },
257
+ ]);
258
+ collection.upsert(
259
+ { id: '1', text: 'Changed', done: false },
260
+ { id: '2', text: 'New', done: true },
261
+ );
262
+ vi.runAllTimers();
263
+
264
+ expect(persistSetSpy).toHaveBeenCalledWith(
265
+ expect.arrayContaining([
266
+ { id: '1', text: 'Changed', done: false },
267
+ { id: '2', text: 'New', done: true },
268
+ ]),
269
+ );
270
+ collection.dispose();
271
+ });
272
+
273
+ it('reset() triggers persistClear + persistSet with all new items', () => {
274
+ const persistClearSpy = vi.fn(() => mockStore.clear());
275
+ const persistSetSpy = vi.fn((items: Todo[]) => mockStore.set(items));
276
+
277
+ class SpyCollection extends PersistentCollection<Todo> {
278
+ protected readonly storageKey = 'spy-reset';
279
+ constructor(items: Todo[] = []) {
280
+ super(items);
281
+ }
282
+ protected persistGet(id: Todo['id']) { return mockStore.get(id); }
283
+ protected persistGetAll() { return mockStore.getAll(); }
284
+ protected persistSet(items: Todo[]) { persistSetSpy(items); }
285
+ protected persistRemove(ids: Todo['id'][]) { mockStore.remove(ids); }
286
+ protected persistClear() { persistClearSpy(); }
287
+ }
288
+
289
+ const collection = new SpyCollection([{ id: '1', text: 'Old', done: false }]);
290
+ collection.reset([
291
+ { id: '2', text: 'New1', done: false },
292
+ { id: '3', text: 'New2', done: true },
293
+ ]);
294
+ vi.runAllTimers();
295
+
296
+ expect(persistClearSpy).toHaveBeenCalled();
297
+ expect(persistSetSpy).toHaveBeenCalledWith(
298
+ expect.arrayContaining([
299
+ { id: '2', text: 'New1', done: false },
300
+ { id: '3', text: 'New2', done: true },
301
+ ]),
302
+ );
303
+ collection.dispose();
304
+ });
305
+
306
+ it('clear() triggers persistClear', () => {
307
+ const persistClearSpy = vi.fn(() => mockStore.clear());
308
+ const persistSetSpy = vi.fn();
309
+
310
+ class SpyCollection extends PersistentCollection<Todo> {
311
+ protected readonly storageKey = 'spy-clear';
312
+ constructor(items: Todo[] = []) {
313
+ super(items);
314
+ }
315
+ protected persistGet(id: Todo['id']) { return mockStore.get(id); }
316
+ protected persistGetAll() { return mockStore.getAll(); }
317
+ protected persistSet(items: Todo[]) { persistSetSpy(items); }
318
+ protected persistRemove(ids: Todo['id'][]) { mockStore.remove(ids); }
319
+ protected persistClear() { persistClearSpy(); }
320
+ }
321
+
322
+ const collection = new SpyCollection([{ id: '1', text: 'Item', done: false }]);
323
+ collection.clear();
324
+ vi.runAllTimers();
325
+
326
+ expect(persistClearSpy).toHaveBeenCalled();
327
+ expect(persistSetSpy).not.toHaveBeenCalled();
328
+ collection.dispose();
329
+ });
330
+ });
331
+
332
+ // ── Debounce coalescing ──
333
+
334
+ describe('debounce', () => {
335
+ it('coalesces rapid mutations into a single flush', () => {
336
+ const persistSetSpy = vi.fn((items: Todo[]) => mockStore.set(items));
337
+ const persistRemoveSpy = vi.fn((ids: Todo['id'][]) => mockStore.remove(ids));
338
+
339
+ class SpyCollection extends PersistentCollection<Todo> {
340
+ static override WRITE_DELAY = 100;
341
+ protected readonly storageKey = 'spy-debounce';
342
+ protected persistGet(id: Todo['id']) { return mockStore.get(id); }
343
+ protected persistGetAll() { return mockStore.getAll(); }
344
+ protected persistSet(items: Todo[]) { persistSetSpy(items); }
345
+ protected persistRemove(ids: Todo['id'][]) { persistRemoveSpy(ids); }
346
+ protected persistClear() { mockStore.clear(); }
347
+ }
348
+
349
+ const collection = new SpyCollection();
350
+
351
+ // Rapid mutations
352
+ collection.add({ id: '1', text: 'First', done: false });
353
+ collection.add({ id: '2', text: 'Second', done: false });
354
+ collection.remove('1');
355
+
356
+ // Nothing flushed yet
357
+ expect(persistSetSpy).not.toHaveBeenCalled();
358
+ expect(persistRemoveSpy).not.toHaveBeenCalled();
359
+
360
+ vi.runAllTimers();
361
+
362
+ // Net result: id=2 added, id=1 removed (was added then removed — cancel out)
363
+ expect(persistSetSpy).toHaveBeenCalledTimes(1);
364
+ expect(persistSetSpy).toHaveBeenCalledWith([{ id: '2', text: 'Second', done: false }]);
365
+ collection.dispose();
366
+ });
367
+
368
+ it('default WRITE_DELAY = 0 saves immediately', () => {
369
+ const persistSetSpy = vi.fn();
370
+
371
+ class ImmediateCollection extends PersistentCollection<Todo> {
372
+ protected readonly storageKey = 'immediate';
373
+ protected persistGet() { return null; }
374
+ protected persistGetAll() { return []; }
375
+ protected persistSet(items: Todo[]) { persistSetSpy(items); }
376
+ protected persistRemove() {}
377
+ protected persistClear() {}
378
+ }
379
+
380
+ const collection = new ImmediateCollection();
381
+ collection.add({ id: '1', text: 'Test', done: false });
382
+
383
+ // Should have flushed immediately (no timer needed)
384
+ expect(persistSetSpy).toHaveBeenCalledTimes(1);
385
+ collection.dispose();
386
+ });
387
+ });
388
+
389
+ // ── Dispose flushes ──
390
+
391
+ describe('dispose', () => {
392
+ it('flushes pending save on dispose', () => {
393
+ const persistSetSpy = vi.fn();
394
+
395
+ class SpyCollection extends PersistentCollection<Todo> {
396
+ static override WRITE_DELAY = 100;
397
+ protected readonly storageKey = 'spy-dispose';
398
+ protected persistGet() { return null; }
399
+ protected persistGetAll() { return []; }
400
+ protected persistSet(items: Todo[]) { persistSetSpy(items); }
401
+ protected persistRemove() {}
402
+ protected persistClear() {}
403
+ }
404
+
405
+ const collection = new SpyCollection();
406
+ collection.add({ id: '1', text: 'Test', done: false });
407
+
408
+ // Not yet flushed (debounced)
409
+ expect(persistSetSpy).not.toHaveBeenCalled();
410
+
411
+ collection.dispose();
412
+
413
+ // Should have flushed on dispose
414
+ expect(persistSetSpy).toHaveBeenCalledTimes(1);
415
+ });
416
+ });
417
+
418
+ // ── clearStorage ──
419
+
420
+ describe('clearStorage', () => {
421
+ it('removes storage AND clears in-memory items', () => {
422
+ const persistClearSpy = vi.fn(() => mockStore.clear());
423
+
424
+ class SpyCollection extends PersistentCollection<Todo> {
425
+ protected readonly storageKey = 'spy-clearStorage';
426
+ constructor(items: Todo[] = []) {
427
+ super(items);
428
+ }
429
+ protected persistGet() { return null; }
430
+ protected persistGetAll() { return mockStore.getAll(); }
431
+ protected persistSet(items: Todo[]) { mockStore.set(items); }
432
+ protected persistRemove(ids: Todo['id'][]) { mockStore.remove(ids); }
433
+ protected persistClear() { persistClearSpy(); }
434
+ }
435
+
436
+ const collection = new SpyCollection([
437
+ { id: '1', text: 'Test', done: false },
438
+ ]);
439
+
440
+ collection.clearStorage();
441
+
442
+ expect(collection.length).toBe(0);
443
+ expect(persistClearSpy).toHaveBeenCalled();
444
+ collection.dispose();
445
+ });
446
+ });
447
+
448
+ // ── Error handling ──
449
+
450
+ describe('error handling', () => {
451
+ it('storage errors do not break the collection', () => {
452
+ class ErrorCollection extends PersistentCollection<Todo> {
453
+ protected readonly storageKey = 'error-write';
454
+ protected persistGet() { return null; }
455
+ protected persistGetAll() { return []; }
456
+ protected persistSet() { throw new Error('Write failed'); }
457
+ protected persistRemove() {}
458
+ protected persistClear() {}
459
+ }
460
+
461
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
462
+ const collection = new ErrorCollection();
463
+
464
+ // Should not throw
465
+ collection.add({ id: '1', text: 'Test', done: false });
466
+
467
+ expect(collection.length).toBe(1);
468
+ expect(collection.get('1')!.text).toBe('Test');
469
+ warnSpy.mockRestore();
470
+ collection.dispose();
471
+ });
472
+
473
+ it('onPersistError hook receives errors', () => {
474
+ const errorSpy = vi.fn();
475
+
476
+ class ErrorCollection extends PersistentCollection<Todo> {
477
+ protected readonly storageKey = 'error-hook';
478
+ protected onPersistError = errorSpy;
479
+ protected persistGet() { return null; }
480
+ protected persistGetAll() { return []; }
481
+ protected persistSet() { throw new Error('Write failed'); }
482
+ protected persistRemove() {}
483
+ protected persistClear() {}
484
+ }
485
+
486
+ const collection = new ErrorCollection();
487
+ collection.add({ id: '1', text: 'Test', done: false });
488
+
489
+ expect(errorSpy).toHaveBeenCalledWith(expect.any(Error));
490
+ collection.dispose();
491
+ });
492
+ });
493
+
494
+ // ── DEV guards ──
495
+
496
+ describe('DEV guards', () => {
497
+ it('warns on duplicate storageKey across different classes', () => {
498
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
499
+
500
+ class CollectionA extends PersistentCollection<Todo> {
501
+ protected readonly storageKey = 'shared-key';
502
+ protected persistGet() { return null; }
503
+ protected persistGetAll() { return []; }
504
+ protected persistSet() {}
505
+ protected persistRemove() {}
506
+ protected persistClear() {}
507
+ }
508
+
509
+ class CollectionB extends PersistentCollection<Todo> {
510
+ protected readonly storageKey = 'shared-key';
511
+ protected persistGet() { return null; }
512
+ protected persistGetAll() { return []; }
513
+ protected persistSet() {}
514
+ protected persistRemove() {}
515
+ protected persistClear() {}
516
+ }
517
+
518
+ const a = new CollectionA();
519
+ // Trigger _ensurePersistenceReady by mutating
520
+ a.add({ id: '1', text: 'A', done: false });
521
+ vi.runAllTimers();
522
+
523
+ const b = new CollectionB();
524
+ b.add({ id: '2', text: 'B', done: false });
525
+ vi.runAllTimers();
526
+
527
+ expect(warnSpy).toHaveBeenCalledWith(
528
+ expect.stringContaining('Duplicate storageKey'),
529
+ );
530
+
531
+ warnSpy.mockRestore();
532
+ a.dispose();
533
+ b.dispose();
534
+ });
535
+
536
+ it('warns on items access before hydrate()', () => {
537
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
538
+
539
+ const collection = new TestPersistentCollection();
540
+
541
+ // Access items before hydrate
542
+ void collection.items;
543
+
544
+ expect(warnSpy).toHaveBeenCalledWith(
545
+ expect.stringContaining('before hydrate()'),
546
+ );
547
+
548
+ // Only warns once
549
+ void collection.items;
550
+
551
+ warnSpy.mockRestore();
552
+ collection.dispose();
553
+ });
554
+ });
555
+
556
+ // ── Optimistic rollback ──
557
+
558
+ describe('optimistic rollback', () => {
559
+ it('persists correctly after rollback', () => {
560
+ const persistSetSpy = vi.fn((items: Todo[]) => mockStore.set(items));
561
+ const persistRemoveSpy = vi.fn((ids: Todo['id'][]) => mockStore.remove(ids));
562
+
563
+ class SpyCollection extends PersistentCollection<Todo> {
564
+ protected readonly storageKey = 'spy-optimistic';
565
+ constructor(items: Todo[] = []) {
566
+ super(items);
567
+ }
568
+ protected persistGet(id: Todo['id']) { return mockStore.get(id); }
569
+ protected persistGetAll() { return mockStore.getAll(); }
570
+ protected persistSet(items: Todo[]) { persistSetSpy(items); }
571
+ protected persistRemove(ids: Todo['id'][]) { persistRemoveSpy(ids); }
572
+ protected persistClear() { mockStore.clear(); }
573
+ }
574
+
575
+ const collection = new SpyCollection([
576
+ { id: '1', text: 'Original', done: false },
577
+ ]);
578
+
579
+ // Flush initial state (no pending since constructor items don't trigger subscriber)
580
+ vi.runAllTimers();
581
+ persistSetSpy.mockClear();
582
+ persistRemoveSpy.mockClear();
583
+
584
+ const rollback = collection.optimistic(() => {
585
+ collection.remove('1');
586
+ collection.add({ id: '2', text: 'Optimistic', done: false });
587
+ });
588
+
589
+ // Flush optimistic state
590
+ vi.runAllTimers();
591
+ persistRemoveSpy.mockClear();
592
+ persistSetSpy.mockClear();
593
+
594
+ // Rollback
595
+ rollback();
596
+ vi.runAllTimers();
597
+
598
+ // After rollback, should persist the restored state:
599
+ // id=1 should be written back, id=2 should be removed
600
+ expect(persistSetSpy).toHaveBeenCalledWith([
601
+ expect.objectContaining({ id: '1', text: 'Original' }),
602
+ ]);
603
+ expect(persistRemoveSpy).toHaveBeenCalledWith(['2']);
604
+ collection.dispose();
605
+ });
606
+ });
607
+
608
+ // ── Singleton integration ──
609
+
610
+ describe('singleton integration', () => {
611
+ it('works with singleton() and teardownAll()', () => {
612
+ const collection = singleton(TestPersistentCollection);
613
+ collection.add({ id: '1', text: 'Test', done: false });
614
+ expect(collection.length).toBe(1);
615
+
616
+ teardownAll();
617
+ expect(collection.disposed).toBe(true);
618
+ });
619
+ });
620
+
621
+ // ── TTL sweep triggers persist ──
622
+
623
+ describe('TTL eviction triggers persist', () => {
624
+ it('evicted items are persisted as removes', () => {
625
+ const persistRemoveSpy = vi.fn();
626
+
627
+ class TTLCollection extends PersistentCollection<Todo> {
628
+ static override TTL = 100;
629
+ protected readonly storageKey = 'ttl-test';
630
+ protected persistGet() { return null; }
631
+ protected persistGetAll() { return []; }
632
+ protected persistSet() {}
633
+ protected persistRemove(ids: Todo['id'][]) { persistRemoveSpy(ids); }
634
+ protected persistClear() {}
635
+ }
636
+
637
+ const collection = new TTLCollection();
638
+ collection.add({ id: '1', text: 'Expiring', done: false });
639
+ persistRemoveSpy.mockClear();
640
+
641
+ // Advance past TTL
642
+ vi.advanceTimersByTime(150);
643
+
644
+ expect(persistRemoveSpy).toHaveBeenCalledWith(['1']);
645
+ expect(collection.length).toBe(0);
646
+ collection.dispose();
647
+ });
648
+ });
649
+ });