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,70 @@
1
+ import type { Disposable } from './types';
2
+
3
+ // Using 'any' for registry types to avoid variance issues with generics
4
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
5
+ type AnyDisposableClass = new (...args: any[]) => Disposable;
6
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
+ const registry = new Map<AnyDisposableClass, Disposable>();
8
+
9
+ /**
10
+ * Get-or-create a singleton instance for the given class.
11
+ * Returns the existing instance if one exists and is not disposed; otherwise creates a new one.
12
+ *
13
+ * If the class defines `static DEFAULT_STATE`, it is used as the constructor argument
14
+ * when no args are passed — eliminating repeated object literals at every call site.
15
+ */
16
+ export function singleton<T extends Disposable>(
17
+ Class: (new (...args: any[]) => T) & { DEFAULT_STATE: unknown },
18
+ ): T;
19
+ export function singleton<T extends Disposable, Args extends unknown[]>(
20
+ Class: new (...args: Args) => T,
21
+ ...args: Args
22
+ ): T;
23
+ export function singleton<T extends Disposable>(
24
+ Class: (new (...args: any[]) => T) & { DEFAULT_STATE?: unknown },
25
+ ...args: unknown[]
26
+ ): T {
27
+ const existing = registry.get(Class as AnyDisposableClass);
28
+
29
+ if (existing && !existing.disposed) {
30
+ return existing as T;
31
+ }
32
+
33
+ const instance = (args.length === 0 && 'DEFAULT_STATE' in Class)
34
+ ? new Class(Class.DEFAULT_STATE)
35
+ : new Class(...args);
36
+
37
+ registry.set(Class as AnyDisposableClass, instance);
38
+ return instance;
39
+ }
40
+
41
+ /**
42
+ * Check if a singleton instance exists for a class.
43
+ */
44
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
+ export function hasSingleton(Class: new (...args: any[]) => Disposable): boolean {
46
+ const existing = registry.get(Class);
47
+ return existing !== undefined && !existing.disposed;
48
+ }
49
+
50
+ /**
51
+ * Disposes and removes the singleton instance for the given class.
52
+ */
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
+ export function teardown(Class: new (...args: any[]) => Disposable): void {
55
+ const instance = registry.get(Class);
56
+ if (instance) {
57
+ instance.dispose();
58
+ registry.delete(Class);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Disposes all singletons and clears the registry. Typically used in test cleanup.
64
+ */
65
+ export function teardownAll(): void {
66
+ for (const instance of registry.values()) {
67
+ instance.dispose();
68
+ }
69
+ registry.clear();
70
+ }
package/src/types.ts ADDED
@@ -0,0 +1,70 @@
1
+ import type { AppError } from './errors';
2
+
3
+ /**
4
+ * Listener callback type for state change notifications.
5
+ */
6
+ export type Listener<S> = (state: S, prev: S) => void;
7
+
8
+ /**
9
+ * Updater function type for functional state updates.
10
+ */
11
+ export type Updater<S> = (prev: S) => Partial<S>;
12
+
13
+ /**
14
+ * Validation errors mapped by field key.
15
+ */
16
+ export type ValidationErrors<S> = Partial<Record<keyof S, string>>;
17
+
18
+ /**
19
+ * Async method tracking state. Frozen snapshots are stored per method key.
20
+ */
21
+ export interface TaskState {
22
+ readonly loading: boolean;
23
+ readonly error: string | null;
24
+ readonly errorCode: AppError['code'] | null;
25
+ }
26
+
27
+ /**
28
+ * Interface for objects that can be disposed.
29
+ */
30
+ export interface Disposable {
31
+ readonly disposed: boolean;
32
+ readonly disposeSignal: AbortSignal;
33
+ dispose(): void;
34
+ }
35
+
36
+ /**
37
+ * Interface for objects with async-safe initialization lifecycle.
38
+ */
39
+ export interface Initializable {
40
+ readonly initialized: boolean;
41
+ init(): void | Promise<void>;
42
+ }
43
+
44
+ /**
45
+ * Interface for reactive subscribable state containers.
46
+ */
47
+ export interface Subscribable<S> extends Disposable {
48
+ state: S;
49
+ subscribe(listener: Listener<S>): () => void;
50
+ }
51
+
52
+ /**
53
+ * Structural type for event sources (Channel and EventBus both match).
54
+ * Exported as a public utility type. `listenTo` uses an inline structural
55
+ * type instead to avoid generic-to-generic variance issues.
56
+ */
57
+ export interface EventSource<E extends Record<string, any>> {
58
+ on<K extends keyof E>(event: K, handler: (payload: E[K]) => void): () => void;
59
+ }
60
+
61
+ /**
62
+ * Extracts the payload type for a specific event from an event source.
63
+ * Uses the `_types` phantom field (present on EventBus and Channel) for exact
64
+ * payload lookup. Falls back to conditional inference for structural sources.
65
+ */
66
+ export type EventPayload<Source, Event> =
67
+ Source extends { readonly _types: infer E extends Record<string, any> }
68
+ ? Event extends keyof E ? E[Event] : never
69
+ : Source extends { on(event: Event, handler: (payload: infer P) => void): any }
70
+ ? P : never;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Walk the prototype chain from `instance`'s class up to (but not including)
3
+ * `stopAt`. Calls `visitor` for each own property descriptor found.
4
+ *
5
+ * Shared utility — used by ViewModel's _processMembers(), wrapAsyncMethods(),
6
+ * ViewModel/Resource's _guardReservedKeys(), and bindPublicMethods().
7
+ */
8
+ export function walkPrototypeChain(
9
+ instance: object,
10
+ stopAt: object,
11
+ visitor: (key: string, desc: PropertyDescriptor, proto: object) => void,
12
+ ): void {
13
+ let proto = Object.getPrototypeOf(instance);
14
+ while (proto && proto !== stopAt) {
15
+ const descriptors = Object.getOwnPropertyDescriptors(proto);
16
+ for (const [key, desc] of Object.entries(descriptors)) {
17
+ if (key === 'constructor') continue;
18
+ visitor(key, desc, proto);
19
+ }
20
+ proto = Object.getPrototypeOf(proto);
21
+ }
22
+ }
@@ -0,0 +1,235 @@
1
+ // @vitest-environment jsdom
2
+ import 'fake-indexeddb/auto';
3
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
4
+ import { IndexedDBCollection } from './IndexedDBCollection';
5
+ import { closeAllConnections, deleteDatabase } from './idb';
6
+ import { teardownAll } from '../singleton';
7
+
8
+ interface Message {
9
+ id: string;
10
+ text: string;
11
+ ts: number;
12
+ }
13
+
14
+ // Use WRITE_DELAY = 0 so flushes happen immediately (no setTimeout needed).
15
+ // Still need a tick for the async IDB promises to resolve.
16
+ const tick = (ms = 20) => new Promise((r) => setTimeout(r, ms));
17
+
18
+ class MessagesCollection extends IndexedDBCollection<Message> {
19
+ protected readonly storageKey = 'messages';
20
+ }
21
+
22
+ class OtherCollection extends IndexedDBCollection<Message> {
23
+ protected readonly storageKey = 'other';
24
+ }
25
+
26
+ describe('IndexedDBCollection', () => {
27
+ beforeEach(async () => {
28
+ teardownAll();
29
+ closeAllConnections();
30
+ await deleteDatabase('mvc-kit');
31
+ await deleteDatabase('custom-db');
32
+ });
33
+
34
+ afterEach(() => {
35
+ closeAllConnections();
36
+ });
37
+
38
+ describe('hydration', () => {
39
+ it('loads persisted data via hydrate()', async () => {
40
+ // First: create and populate
41
+ const c1 = new MessagesCollection();
42
+ c1.add(
43
+ { id: '1', text: 'Hello', ts: 100 },
44
+ { id: '2', text: 'World', ts: 200 },
45
+ );
46
+ await tick();
47
+ c1.dispose();
48
+ closeAllConnections();
49
+
50
+ // Second: hydrate from storage
51
+ const c2 = new MessagesCollection();
52
+ const items = await c2.hydrate();
53
+
54
+ expect(c2.hydrated).toBe(true);
55
+ expect(items).toHaveLength(2);
56
+ expect(c2.get('1')!.text).toBe('Hello');
57
+ expect(c2.get('2')!.text).toBe('World');
58
+ c2.dispose();
59
+ });
60
+
61
+ it('hydrate() is idempotent', async () => {
62
+ const c = new MessagesCollection();
63
+ c.add({ id: '1', text: 'Test', ts: 100 });
64
+ await tick();
65
+
66
+ await c.hydrate();
67
+ c.add({ id: '2', text: 'New', ts: 200 });
68
+ const items = await c.hydrate();
69
+
70
+ // Second call returns current state, not re-reading
71
+ expect(items).toHaveLength(2);
72
+ c.dispose();
73
+ });
74
+ });
75
+
76
+ describe('per-item writes', () => {
77
+ it('add persists individual items', async () => {
78
+ const c = new MessagesCollection();
79
+ c.add({ id: '1', text: 'Hello', ts: 100 });
80
+ await tick();
81
+ c.dispose();
82
+ closeAllConnections();
83
+
84
+ const c2 = new MessagesCollection();
85
+ await c2.hydrate();
86
+ expect(c2.length).toBe(1);
87
+ expect(c2.get('1')!.text).toBe('Hello');
88
+ c2.dispose();
89
+ });
90
+
91
+ it('remove deletes individual items from store', async () => {
92
+ const c = new MessagesCollection();
93
+ c.add(
94
+ { id: '1', text: 'A', ts: 100 },
95
+ { id: '2', text: 'B', ts: 200 },
96
+ );
97
+ await tick();
98
+
99
+ c.remove('1');
100
+ await tick();
101
+ c.dispose();
102
+ closeAllConnections();
103
+
104
+ const c2 = new MessagesCollection();
105
+ await c2.hydrate();
106
+ expect(c2.length).toBe(1);
107
+ expect(c2.get('2')!.text).toBe('B');
108
+ c2.dispose();
109
+ });
110
+
111
+ it('update persists the changed item', async () => {
112
+ const c = new MessagesCollection();
113
+ c.add({ id: '1', text: 'Original', ts: 100 });
114
+ await tick();
115
+
116
+ c.update('1', { text: 'Updated' });
117
+ await tick();
118
+ c.dispose();
119
+ closeAllConnections();
120
+
121
+ const c2 = new MessagesCollection();
122
+ await c2.hydrate();
123
+ expect(c2.get('1')!.text).toBe('Updated');
124
+ c2.dispose();
125
+ });
126
+ });
127
+
128
+ describe('bulk operations', () => {
129
+ it('reset clears store and writes all new items', async () => {
130
+ const c = new MessagesCollection();
131
+ c.add({ id: '1', text: 'Old', ts: 100 });
132
+ await tick();
133
+
134
+ c.reset([
135
+ { id: '2', text: 'New1', ts: 200 },
136
+ { id: '3', text: 'New2', ts: 300 },
137
+ ]);
138
+ await tick();
139
+ c.dispose();
140
+ closeAllConnections();
141
+
142
+ const c2 = new MessagesCollection();
143
+ await c2.hydrate();
144
+ expect(c2.length).toBe(2);
145
+ expect(c2.has('1')).toBe(false);
146
+ expect(c2.get('2')!.text).toBe('New1');
147
+ c2.dispose();
148
+ });
149
+ });
150
+
151
+ describe('multiple collections', () => {
152
+ it('different collections use different object stores in same DB', async () => {
153
+ const msgs = new MessagesCollection();
154
+ const other = new OtherCollection();
155
+
156
+ msgs.add({ id: '1', text: 'Message', ts: 100 });
157
+ await tick(); // Let msgs flush before other opens a new store
158
+ other.add({ id: '1', text: 'Other', ts: 200 });
159
+ await tick();
160
+
161
+ msgs.dispose();
162
+ other.dispose();
163
+ closeAllConnections();
164
+
165
+ const msgs2 = new MessagesCollection();
166
+ const other2 = new OtherCollection();
167
+ await msgs2.hydrate();
168
+ await other2.hydrate();
169
+
170
+ expect(msgs2.get('1')!.text).toBe('Message');
171
+ expect(other2.get('1')!.text).toBe('Other');
172
+
173
+ msgs2.dispose();
174
+ other2.dispose();
175
+ });
176
+ });
177
+
178
+ describe('custom DB_NAME', () => {
179
+ it('uses custom database name', async () => {
180
+ class CustomDBCollection extends IndexedDBCollection<Message> {
181
+ static override DB_NAME = 'custom-db';
182
+ protected readonly storageKey = 'custom-store';
183
+ }
184
+
185
+ const c = new CustomDBCollection();
186
+ c.add({ id: '1', text: 'Custom', ts: 100 });
187
+ await tick();
188
+ c.dispose();
189
+ closeAllConnections();
190
+
191
+ const c2 = new CustomDBCollection();
192
+ await c2.hydrate();
193
+ expect(c2.get('1')!.text).toBe('Custom');
194
+ c2.dispose();
195
+ });
196
+ });
197
+
198
+ describe('clearStorage', () => {
199
+ it('clears the object store and in-memory items', async () => {
200
+ const c = new MessagesCollection();
201
+ c.add({ id: '1', text: 'Test', ts: 100 });
202
+ await tick();
203
+
204
+ await c.clearStorage();
205
+ expect(c.length).toBe(0);
206
+ c.dispose();
207
+ closeAllConnections();
208
+
209
+ const c2 = new MessagesCollection();
210
+ await c2.hydrate();
211
+ expect(c2.length).toBe(0);
212
+ c2.dispose();
213
+ });
214
+ });
215
+
216
+ describe('persistGet', () => {
217
+ it('returns single item by ID via hydrate', async () => {
218
+ const c = new MessagesCollection();
219
+ c.add(
220
+ { id: '1', text: 'A', ts: 100 },
221
+ { id: '2', text: 'B', ts: 200 },
222
+ );
223
+ await tick();
224
+ c.dispose();
225
+ closeAllConnections();
226
+
227
+ const c2 = new MessagesCollection();
228
+ await c2.hydrate();
229
+ expect(c2.get('1')!.text).toBe('A');
230
+ expect(c2.get('2')!.text).toBe('B');
231
+ expect(c2.get('3')).toBeUndefined();
232
+ c2.dispose();
233
+ });
234
+ });
235
+ });
@@ -0,0 +1,66 @@
1
+ import { PersistentCollection } from '../PersistentCollection';
2
+ import { getStore, idbGetAll, idbGet, idbPut, idbDelete, idbClear } from './idb';
3
+
4
+ /**
5
+ * PersistentCollection backed by IndexedDB. Stores items individually by `id`
6
+ * in a dedicated object store (named by `storageKey`).
7
+ *
8
+ * **Requires manual `hydrate()` call** (async storage).
9
+ * Typically called in ViewModel's `onInit()`.
10
+ *
11
+ * Uses per-item strategy: each item is stored as a separate entry in the object store.
12
+ * No JSON serialization needed — IndexedDB uses structured cloning.
13
+ *
14
+ * ```ts
15
+ * class MessagesCollection extends IndexedDBCollection<Message> {
16
+ * protected readonly storageKey = 'messages';
17
+ * }
18
+ *
19
+ * // In ViewModel:
20
+ * async onInit() {
21
+ * await this.collection.hydrate();
22
+ * if (this.collection.length === 0) this.load();
23
+ * }
24
+ * ```
25
+ */
26
+ export abstract class IndexedDBCollection<
27
+ T extends { id: string | number },
28
+ > extends PersistentCollection<T> {
29
+ /** IndexedDB database name. Override to use a separate database. */
30
+ static DB_NAME = 'mvc-kit';
31
+
32
+ private get _dbName(): string {
33
+ return (this.constructor as typeof IndexedDBCollection).DB_NAME;
34
+ }
35
+
36
+ private _getStore(mode: IDBTransactionMode) {
37
+ return getStore(this._dbName, this.storageKey, mode);
38
+ }
39
+
40
+ // ── Persist interface (per-item strategy) ──
41
+
42
+ protected async persistGetAll(): Promise<T[]> {
43
+ const store = await this._getStore('readonly');
44
+ return idbGetAll<T>(store);
45
+ }
46
+
47
+ protected async persistGet(id: T['id']): Promise<T | null> {
48
+ const store = await this._getStore('readonly');
49
+ return idbGet<T>(store, id as IDBValidKey);
50
+ }
51
+
52
+ protected async persistSet(items: T[]): Promise<void> {
53
+ const store = await this._getStore('readwrite');
54
+ return idbPut(store, items);
55
+ }
56
+
57
+ protected async persistRemove(ids: T['id'][]): Promise<void> {
58
+ const store = await this._getStore('readwrite');
59
+ return idbDelete(store, ids as IDBValidKey[]);
60
+ }
61
+
62
+ protected async persistClear(): Promise<void> {
63
+ const store = await this._getStore('readwrite');
64
+ return idbClear(store);
65
+ }
66
+ }
@@ -0,0 +1,214 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
+ import { WebStorageCollection } from './WebStorageCollection';
4
+ import { teardownAll } from '../singleton';
5
+
6
+ interface CartItem {
7
+ id: string;
8
+ name: string;
9
+ qty: number;
10
+ }
11
+
12
+ class CartCollection extends WebStorageCollection<CartItem> {
13
+ protected readonly storageKey = 'cart';
14
+ }
15
+
16
+ class SessionCartCollection extends WebStorageCollection<CartItem> {
17
+ static override STORAGE = 'session' as const;
18
+ protected readonly storageKey = 'session-cart';
19
+ }
20
+
21
+ describe('WebStorageCollection', () => {
22
+ beforeEach(() => {
23
+ teardownAll();
24
+ localStorage.clear();
25
+ sessionStorage.clear();
26
+ vi.useFakeTimers();
27
+ });
28
+
29
+ afterEach(() => {
30
+ vi.useRealTimers();
31
+ });
32
+
33
+ describe('auto-hydration', () => {
34
+ it('loads data from localStorage on first access', () => {
35
+ localStorage.setItem('cart', JSON.stringify([
36
+ { id: '1', name: 'Widget', qty: 2 },
37
+ { id: '2', name: 'Gadget', qty: 1 },
38
+ ]));
39
+
40
+ const collection = new CartCollection();
41
+
42
+ // Hydration is lazy — triggered by first access
43
+ expect(collection.length).toBe(2);
44
+ expect(collection.hydrated).toBe(true);
45
+ expect(collection.get('1')!.name).toBe('Widget');
46
+ collection.dispose();
47
+ });
48
+
49
+ it('starts empty when no stored data', () => {
50
+ const collection = new CartCollection();
51
+ expect(collection.length).toBe(0);
52
+ expect(collection.hydrated).toBe(true);
53
+ collection.dispose();
54
+ });
55
+ });
56
+
57
+ describe('persistence (blob strategy)', () => {
58
+ it('mutations persist to localStorage', () => {
59
+ const collection = new CartCollection();
60
+ collection.add({ id: '1', name: 'Widget', qty: 2 });
61
+ vi.runAllTimers();
62
+
63
+ const stored = JSON.parse(localStorage.getItem('cart')!);
64
+ expect(stored).toEqual([{ id: '1', name: 'Widget', qty: 2 }]);
65
+ collection.dispose();
66
+ });
67
+
68
+ it('writes full state (blob) on each flush', () => {
69
+ const collection = new CartCollection();
70
+ collection.add({ id: '1', name: 'Widget', qty: 2 });
71
+ vi.runAllTimers();
72
+
73
+ collection.add({ id: '2', name: 'Gadget', qty: 1 });
74
+ vi.runAllTimers();
75
+
76
+ const stored = JSON.parse(localStorage.getItem('cart')!);
77
+ expect(stored).toHaveLength(2);
78
+ collection.dispose();
79
+ });
80
+
81
+ it('remove persists correctly', () => {
82
+ const collection = new CartCollection();
83
+ collection.add(
84
+ { id: '1', name: 'Widget', qty: 2 },
85
+ { id: '2', name: 'Gadget', qty: 1 },
86
+ );
87
+ vi.runAllTimers();
88
+
89
+ collection.remove('1');
90
+ vi.runAllTimers();
91
+
92
+ const stored = JSON.parse(localStorage.getItem('cart')!);
93
+ expect(stored).toEqual([{ id: '2', name: 'Gadget', qty: 1 }]);
94
+ collection.dispose();
95
+ });
96
+
97
+ it('clear removes from localStorage', () => {
98
+ const collection = new CartCollection();
99
+ collection.add({ id: '1', name: 'Widget', qty: 2 });
100
+ vi.runAllTimers();
101
+ expect(localStorage.getItem('cart')).not.toBeNull();
102
+
103
+ collection.clear();
104
+ vi.runAllTimers();
105
+
106
+ expect(localStorage.getItem('cart')).toBeNull();
107
+ collection.dispose();
108
+ });
109
+ });
110
+
111
+ describe('sessionStorage', () => {
112
+ it('STORAGE = session uses sessionStorage', () => {
113
+ const collection = new SessionCartCollection();
114
+ collection.add({ id: '1', name: 'Widget', qty: 2 });
115
+ vi.runAllTimers();
116
+
117
+ expect(sessionStorage.getItem('session-cart')).not.toBeNull();
118
+ expect(localStorage.getItem('session-cart')).toBeNull();
119
+
120
+ const stored = JSON.parse(sessionStorage.getItem('session-cart')!);
121
+ expect(stored).toEqual([{ id: '1', name: 'Widget', qty: 2 }]);
122
+ collection.dispose();
123
+ });
124
+
125
+ it('auto-hydrates from sessionStorage', () => {
126
+ sessionStorage.setItem('session-cart', JSON.stringify([
127
+ { id: '1', name: 'Widget', qty: 3 },
128
+ ]));
129
+
130
+ const collection = new SessionCartCollection();
131
+ expect(collection.length).toBe(1);
132
+ expect(collection.get('1')!.qty).toBe(3);
133
+ collection.dispose();
134
+ });
135
+ });
136
+
137
+ describe('JSON round-trip', () => {
138
+ it('preserves data through serialize/deserialize cycle', () => {
139
+ const collection = new CartCollection();
140
+ collection.add({ id: '1', name: 'Widget', qty: 99 });
141
+ vi.runAllTimers();
142
+ collection.dispose();
143
+
144
+ // Recreate from storage
145
+ const collection2 = new CartCollection();
146
+ expect(collection2.get('1')).toEqual({ id: '1', name: 'Widget', qty: 99 });
147
+ collection2.dispose();
148
+ });
149
+ });
150
+
151
+ describe('error handling', () => {
152
+ it('handles QuotaExceededError gracefully', () => {
153
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
154
+ const errorSpy = vi.fn();
155
+ let shouldThrow = false;
156
+
157
+ class QuotaCollection extends WebStorageCollection<CartItem> {
158
+ protected readonly storageKey = 'quota-test';
159
+ protected onPersistError = errorSpy;
160
+ protected override persistSet(items: CartItem[]): void {
161
+ if (shouldThrow) {
162
+ const err = new DOMException('Quota exceeded', 'QuotaExceededError');
163
+ warnSpy.call(console,
164
+ `[mvc-kit] QuotaExceededError writing to "${this.storageKey}". ` +
165
+ `Consider using IndexedDBCollection for larger datasets.`,
166
+ );
167
+ throw err;
168
+ }
169
+ super.persistSet(items);
170
+ }
171
+ }
172
+
173
+ const collection = new QuotaCollection();
174
+ shouldThrow = true;
175
+
176
+ collection.add({ id: '1', name: 'Widget', qty: 2 });
177
+
178
+ expect(errorSpy).toHaveBeenCalled();
179
+ expect(collection.length).toBe(1); // In-memory still works
180
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('QuotaExceededError'));
181
+
182
+ warnSpy.mockRestore();
183
+ collection.dispose();
184
+ });
185
+
186
+ it('handles corrupted JSON in storage', () => {
187
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
188
+
189
+ localStorage.setItem('cart', 'not-valid-json{{{');
190
+
191
+ const collection = new CartCollection();
192
+ expect(collection.length).toBe(0);
193
+ expect(collection.hydrated).toBe(true);
194
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Corrupted data'));
195
+
196
+ warnSpy.mockRestore();
197
+ collection.dispose();
198
+ });
199
+ });
200
+
201
+ describe('clearStorage', () => {
202
+ it('removes storage AND clears in-memory', () => {
203
+ const collection = new CartCollection();
204
+ collection.add({ id: '1', name: 'Widget', qty: 2 });
205
+ vi.runAllTimers();
206
+
207
+ collection.clearStorage();
208
+
209
+ expect(collection.length).toBe(0);
210
+ expect(localStorage.getItem('cart')).toBeNull();
211
+ collection.dispose();
212
+ });
213
+ });
214
+ });