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
package/src/Pending.ts ADDED
@@ -0,0 +1,390 @@
1
+ import { classifyError, isAbortError } from './errors';
2
+ import type { AppError } from './errors';
3
+ import { Trackable } from './Trackable';
4
+
5
+ const __DEV__ = typeof __MVC_KIT_DEV__ !== 'undefined' && __MVC_KIT_DEV__;
6
+
7
+ // ── Types ─────────────────────────────────────────────────────────
8
+
9
+ /** Frozen snapshot of a single pending operation's state. */
10
+ export interface PendingOperation<Meta = unknown> {
11
+ readonly status: 'active' | 'retrying' | 'failed';
12
+ readonly operation: string;
13
+ readonly attempts: number;
14
+ readonly maxRetries: number;
15
+ readonly error: string | null;
16
+ readonly errorCode: AppError['code'] | null;
17
+ readonly nextRetryAt: number | null;
18
+ readonly createdAt: number;
19
+ readonly meta: Meta | null;
20
+ }
21
+
22
+ /** A PendingOperation snapshot paired with its key, for iteration. */
23
+ export interface PendingEntry<K extends string | number, Meta = unknown>
24
+ extends PendingOperation<Meta> {
25
+ readonly id: K;
26
+ }
27
+
28
+ /** Mutable internal state for a pending operation. */
29
+ interface InternalOp<K, Meta> {
30
+ id: K;
31
+ operation: string;
32
+ execute: (signal: AbortSignal) => Promise<void>;
33
+ status: PendingOperation['status'];
34
+ attempts: number;
35
+ error: string | null;
36
+ errorCode: AppError['code'] | null;
37
+ nextRetryAt: number | null;
38
+ createdAt: number;
39
+ abortController: AbortController;
40
+ retryTimer: ReturnType<typeof setTimeout> | null;
41
+ meta: Meta | null;
42
+ }
43
+
44
+ // ── Pending ───────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Per-item operation queue with retry and status tracking.
48
+ * Tracks operations by key with exponential backoff retry on transient errors.
49
+ * Subscribable — auto-tracked when used as a ViewModel/Resource property.
50
+ */
51
+ export class Pending<K extends string | number = string | number, Meta = unknown> extends Trackable {
52
+ // ── Static config (Channel pattern — override via subclass) ──
53
+
54
+ /** Maximum number of retry attempts before marking as failed. */
55
+ static MAX_RETRIES = 5;
56
+ /** Base delay (ms) for retry backoff. */
57
+ static RETRY_BASE = 1000;
58
+ /** Maximum delay cap (ms) for retry backoff. */
59
+ static RETRY_MAX = 30000;
60
+ /** Exponential backoff multiplier for retry delay. */
61
+ static RETRY_FACTOR = 2;
62
+
63
+ // ── Private state ──
64
+
65
+ private _operations = new Map<K, InternalOp<K, Meta>>();
66
+ private _snapshots = new Map<K, PendingOperation<Meta>>();
67
+ private _entriesCache: readonly PendingEntry<K, Meta>[] | null = null;
68
+
69
+ constructor() {
70
+ super();
71
+ }
72
+
73
+ // ── Readable state (reactive — auto-tracked by ViewModel getters) ──
74
+
75
+ /** Get the frozen status snapshot for an operation by ID, or null if not found. */
76
+ getStatus(id: K): PendingOperation<Meta> | null {
77
+ return this._snapshots.get(id) ?? null;
78
+ }
79
+
80
+ /** Whether an operation exists for the given ID. */
81
+ has(id: K): boolean {
82
+ return this._operations.has(id);
83
+ }
84
+
85
+ /** Number of operations (all statuses). */
86
+ get count(): number {
87
+ return this._operations.size;
88
+ }
89
+
90
+ /** Whether any operations are in-flight (active or retrying). */
91
+ get hasPending(): boolean {
92
+ for (const op of this._operations.values()) {
93
+ if (op.status !== 'failed') return true;
94
+ }
95
+ return false;
96
+ }
97
+
98
+ /** Whether any operations are in a failed state. */
99
+ get hasFailed(): boolean {
100
+ for (const op of this._operations.values()) {
101
+ if (op.status === 'failed') return true;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ /** Number of operations in a failed state. */
107
+ get failedCount(): number {
108
+ let n = 0;
109
+ for (const op of this._operations.values()) {
110
+ if (op.status === 'failed') n++;
111
+ }
112
+ return n;
113
+ }
114
+
115
+ /** All operations as a frozen array of entries (id + snapshot). Cached until next mutation. */
116
+ get entries(): readonly PendingEntry<K, Meta>[] {
117
+ if (this._entriesCache === null) {
118
+ const result: PendingEntry<K, Meta>[] = [];
119
+ for (const [id, snapshot] of this._snapshots) {
120
+ result.push(Object.freeze({ ...snapshot, id }));
121
+ }
122
+ this._entriesCache = Object.freeze(result) as readonly PendingEntry<K, Meta>[];
123
+ }
124
+ return this._entriesCache;
125
+ }
126
+
127
+ // ── Core API ──
128
+
129
+ /**
130
+ * Enqueue an operation for the given ID. Fire-and-forget (synchronous return).
131
+ * If the same ID already has a pending operation, it is superseded (aborted).
132
+ */
133
+ enqueue(id: K, operation: string, execute: (signal: AbortSignal) => Promise<void>, meta?: Meta): void {
134
+ if (this.disposed) {
135
+ if (__DEV__) {
136
+ console.warn('[mvc-kit] Pending.enqueue() called after dispose — ignored.');
137
+ }
138
+ return;
139
+ }
140
+
141
+ // Supersede existing operation for this ID
142
+ const existing = this._operations.get(id);
143
+ if (existing) {
144
+ existing.abortController.abort();
145
+ if (existing.retryTimer !== null) {
146
+ clearTimeout(existing.retryTimer);
147
+ }
148
+ }
149
+
150
+ const op: InternalOp<K, Meta> = {
151
+ id,
152
+ operation,
153
+ execute,
154
+ status: 'active',
155
+ attempts: 0,
156
+ error: null,
157
+ errorCode: null,
158
+ nextRetryAt: null,
159
+ createdAt: Date.now(),
160
+ abortController: new AbortController(),
161
+ retryTimer: null,
162
+ meta: meta ?? null,
163
+ };
164
+
165
+ this._operations.set(id, op);
166
+ this._snapshot(op);
167
+ this.notify();
168
+
169
+ // Schedule processing via microtask (allows batching multiple enqueues)
170
+ queueMicrotask(() => this._process(id));
171
+ }
172
+
173
+ // ── Controls ──
174
+
175
+ /** Retry a failed operation. No-op if the operation is not in 'failed' status. */
176
+ retry(id: K): void {
177
+ if (this.disposed) {
178
+ if (__DEV__) {
179
+ console.warn('[mvc-kit] Pending.retry() called after dispose — ignored.');
180
+ }
181
+ return;
182
+ }
183
+
184
+ const op = this._operations.get(id);
185
+ if (!op || op.status !== 'failed') return;
186
+
187
+ op.attempts = 0;
188
+ op.error = null;
189
+ op.errorCode = null;
190
+ op.nextRetryAt = null;
191
+ op.abortController = new AbortController();
192
+ this._process(id);
193
+ }
194
+
195
+ /** Retry all failed operations. */
196
+ retryAll(): void {
197
+ if (this.disposed) {
198
+ if (__DEV__) {
199
+ console.warn('[mvc-kit] Pending.retryAll() called after dispose — ignored.');
200
+ }
201
+ return;
202
+ }
203
+
204
+ const failedIds: K[] = [];
205
+ for (const op of this._operations.values()) {
206
+ if (op.status === 'failed') failedIds.push(op.id);
207
+ }
208
+ for (const id of failedIds) {
209
+ this.retry(id);
210
+ }
211
+ }
212
+
213
+ /** Cancel an in-flight operation by ID. Aborts the signal, clears timers, and removes it. */
214
+ cancel(id: K): void {
215
+ const op = this._operations.get(id);
216
+ if (!op) return;
217
+
218
+ op.abortController.abort();
219
+ if (op.retryTimer !== null) {
220
+ clearTimeout(op.retryTimer);
221
+ }
222
+ this._operations.delete(id);
223
+ this._snapshots.delete(id);
224
+ this.notify();
225
+ }
226
+
227
+ /** Cancel all operations. */
228
+ cancelAll(): void {
229
+ for (const op of this._operations.values()) {
230
+ op.abortController.abort();
231
+ if (op.retryTimer !== null) {
232
+ clearTimeout(op.retryTimer);
233
+ }
234
+ }
235
+ this._operations.clear();
236
+ this._snapshots.clear();
237
+ this.notify();
238
+ }
239
+
240
+ /** Remove a failed operation without retrying. No-op if the operation is not in 'failed' status. */
241
+ dismiss(id: K): void {
242
+ const op = this._operations.get(id);
243
+ if (!op || op.status !== 'failed') return;
244
+
245
+ this._operations.delete(id);
246
+ this._snapshots.delete(id);
247
+ this.notify();
248
+ }
249
+
250
+ /** Remove all failed operations without retrying. */
251
+ dismissAll(): void {
252
+ const failedIds: K[] = [];
253
+ for (const op of this._operations.values()) {
254
+ if (op.status === 'failed') failedIds.push(op.id);
255
+ }
256
+ if (failedIds.length === 0) return;
257
+ for (const id of failedIds) {
258
+ this._operations.delete(id);
259
+ this._snapshots.delete(id);
260
+ }
261
+ this.notify();
262
+ }
263
+
264
+ // ── Hooks (overridable in subclass) ──
265
+
266
+ /**
267
+ * Determines whether an error is retryable. Override in a subclass to customize.
268
+ * Default: retries on network, timeout, and server_error codes.
269
+ * @protected
270
+ */
271
+ protected isRetryable(error: unknown): boolean {
272
+ const code = classifyError(error).code;
273
+ return code === 'network' || code === 'timeout' || code === 'server_error';
274
+ }
275
+
276
+ /** Called when an operation succeeds. Override in a subclass for side effects. @protected */
277
+ protected onConfirmed?(id: K, operation: string): void;
278
+ /** Called when an operation fails permanently. Override in a subclass for side effects. @protected */
279
+ protected onFailed?(id: K, operation: string, error: unknown): void;
280
+
281
+ // ── Lifecycle ──
282
+
283
+ /** Dispose: cancels all operations, then runs Trackable cleanup. */
284
+ dispose(): void {
285
+ if (this.disposed) return;
286
+ this.cancelAll();
287
+ super.dispose();
288
+ }
289
+
290
+ // ── Notification override ──
291
+
292
+ /** @internal Invalidates entries cache before notifying subscribers. */
293
+ protected notify(): void {
294
+ this._entriesCache = null;
295
+ super.notify();
296
+ }
297
+
298
+ // ── Internals ──
299
+
300
+ private _snapshot(op: InternalOp<K, Meta>): void {
301
+ const ctor = this.constructor as typeof Pending;
302
+ this._snapshots.set(op.id, Object.freeze({
303
+ status: op.status,
304
+ operation: op.operation,
305
+ attempts: op.attempts,
306
+ maxRetries: ctor.MAX_RETRIES,
307
+ error: op.error,
308
+ errorCode: op.errorCode,
309
+ nextRetryAt: op.nextRetryAt,
310
+ createdAt: op.createdAt,
311
+ meta: op.meta,
312
+ }));
313
+ }
314
+
315
+ private _process(id: K): void {
316
+ const op = this._operations.get(id);
317
+ if (!op || this.disposed) return;
318
+
319
+ // Set active status
320
+ op.status = 'active';
321
+ op.attempts++;
322
+ op.error = null;
323
+ op.errorCode = null;
324
+ op.nextRetryAt = null;
325
+ this._snapshot(op);
326
+ this.notify();
327
+
328
+ op.execute(op.abortController.signal).then(
329
+ () => {
330
+ // Identity check: ignore if this op was superseded or cancelled
331
+ if (this._operations.get(id) !== op) return;
332
+ const operation = op.operation;
333
+ this._operations.delete(id);
334
+ this._snapshots.delete(id);
335
+ this.notify();
336
+ this.onConfirmed?.(id, operation);
337
+ },
338
+ (error: unknown) => {
339
+ // Identity check: ignore if this op was superseded or cancelled
340
+ if (this._operations.get(id) !== op) return;
341
+
342
+ // AbortError — remove silently (cancel or supersede)
343
+ if (isAbortError(error)) {
344
+ this._operations.delete(id);
345
+ this._snapshots.delete(id);
346
+ this.notify();
347
+ return;
348
+ }
349
+
350
+ const ctor = this.constructor as typeof Pending;
351
+ const classified = classifyError(error);
352
+
353
+ // Check if retryable and under max retries
354
+ if (this.isRetryable(error) && op.attempts < ctor.MAX_RETRIES) {
355
+ op.status = 'retrying';
356
+ const delay = this._calculateDelay(op.attempts - 1);
357
+ op.nextRetryAt = Date.now() + delay;
358
+ op.error = classified.message;
359
+ op.errorCode = classified.code;
360
+ this._snapshot(op);
361
+ this.notify();
362
+
363
+ op.retryTimer = setTimeout(() => {
364
+ op.retryTimer = null;
365
+ this._process(id);
366
+ }, delay);
367
+ } else {
368
+ // Non-retryable or max retries exceeded
369
+ op.status = 'failed';
370
+ op.error = classified.message;
371
+ op.errorCode = classified.code;
372
+ op.nextRetryAt = null;
373
+ this._snapshot(op);
374
+ this.notify();
375
+ this.onFailed?.(id, op.operation, error);
376
+ }
377
+ },
378
+ );
379
+ }
380
+
381
+ /** Computes retry backoff delay with jitter (Channel formula). @private */
382
+ private _calculateDelay(attempt: number): number {
383
+ const ctor = this.constructor as typeof Pending;
384
+ const capped = Math.min(
385
+ ctor.RETRY_BASE * Math.pow(ctor.RETRY_FACTOR, attempt),
386
+ ctor.RETRY_MAX,
387
+ );
388
+ return Math.random() * capped;
389
+ }
390
+ }
@@ -0,0 +1,183 @@
1
+ # PersistentCollection
2
+
3
+ Abstract base for Collections that persist to external storage. Extends `Collection<T>` with delta tracking, debounced writes, and hydration.
4
+
5
+ ## Hierarchy
6
+
7
+ ```
8
+ Collection<T>
9
+ └── PersistentCollection<T> (abstract — src/PersistentCollection.ts)
10
+ ├── WebStorageCollection<T> (mvc-kit/web — localStorage/sessionStorage)
11
+ ├── IndexedDBCollection<T> (mvc-kit/web — IndexedDB)
12
+ └── NativeCollection<T> (mvc-kit/react-native — configurable backend)
13
+ ```
14
+
15
+ ## When to Use
16
+
17
+ Use a PersistentCollection subclass when a singleton Collection should cache/repopulate from browser storage or device storage across sessions. Typical use cases:
18
+ - Shopping carts (WebStorageCollection)
19
+ - Offline message caches (IndexedDBCollection)
20
+ - User preferences (NativeCollection)
21
+
22
+ **Don't persist** ephemeral UI state (search results, selections) or high-churn data — persistence adds I/O overhead on every mutation.
23
+
24
+ ## Abstract Members (Subclasses Implement)
25
+
26
+ ```typescript
27
+ protected abstract readonly storageKey: string;
28
+ protected abstract persistGet(id: T['id']): T | null | Promise<T | null>;
29
+ protected abstract persistGetAll(): T[] | Promise<T[]>;
30
+ protected abstract persistSet(items: T[]): void | Promise<void>;
31
+ protected abstract persistRemove(ids: T['id'][]): void | Promise<void>;
32
+ protected abstract persistClear(): void | Promise<void>;
33
+ ```
34
+
35
+ ## Static Config
36
+
37
+ ```typescript
38
+ static WRITE_DELAY = 0; // Debounce ms for saves. 0 = immediate (default).
39
+ ```
40
+
41
+ ## Public API
42
+
43
+ ```typescript
44
+ hydrate(): Promise<T[]>; // Load from storage, idempotent
45
+ get hydrated(): boolean; // Whether storage data has been loaded
46
+ clearStorage(): void | Promise<void>; // Remove from storage AND clear in-memory
47
+ ```
48
+
49
+ ## Serialization Hooks
50
+
51
+ ```typescript
52
+ protected serialize(items: T[]): string; // Default: JSON.stringify
53
+ protected deserialize(raw: string): T[]; // Default: JSON.parse
54
+ ```
55
+
56
+ Used by string-based adapters (WebStorage, NativeCollection). IndexedDB uses structured cloning directly.
57
+
58
+ ## Error Handling
59
+
60
+ ```typescript
61
+ protected onPersistError?(error: unknown): void;
62
+ ```
63
+
64
+ Storage errors are caught internally. In DEV mode, errors are logged via `console.warn`. Override `onPersistError` for custom error handling.
65
+
66
+ ## Internal Mechanics
67
+
68
+ ### Delta Tracking
69
+
70
+ PersistentCollection self-subscribes via `this.subscribe()`. The subscriber diffs `(current, prev)` to determine added, updated, and removed items using a Map-based O(n) comparison. Reference equality identifies unchanged items.
71
+
72
+ | Collection Mutation | Persist Operation |
73
+ |---|---|
74
+ | `add(items)` | `persistSet(new items)` |
75
+ | `upsert(items)` | `persistSet(changed/new items)` |
76
+ | `update(id, changes)` | `persistSet([updated item])` |
77
+ | `remove(ids)` | `persistRemove(removed IDs)` |
78
+ | `reset(items)` | `persistClear()` + `persistSet(all new items)` |
79
+ | `clear()` | `persistClear()` |
80
+
81
+ ### Debounce + Flush
82
+
83
+ By default, `WRITE_DELAY = 0` flushes immediately (synchronously for sync adapters, microtask for async). Override to a positive value (e.g., `static override WRITE_DELAY = 100`) to coalesce rapid mutations into a single write — useful for high-frequency updates like drag-and-drop or real-time collaboration.
84
+
85
+ ### Hydration
86
+
87
+ - **Sync adapters** (WebStorageCollection): Auto-hydrate lazily on first access. No `hydrate()` call needed.
88
+ - **Async adapters** (IndexedDBCollection, NativeCollection): Require manual `hydrate()` call, typically in ViewModel's `onInit()`.
89
+
90
+ `hydrate()` is idempotent — subsequent calls return current items.
91
+
92
+ ### Dispose
93
+
94
+ Flushes any pending saves before calling `super.dispose()`.
95
+
96
+ ## Concrete Adapters
97
+
98
+ ### WebStorageCollection (`mvc-kit/web`)
99
+
100
+ localStorage/sessionStorage. Auto-hydrates on first access. Blob strategy (full state as single JSON string).
101
+
102
+ ```typescript
103
+ import { WebStorageCollection } from 'mvc-kit/web';
104
+
105
+ class CartCollection extends WebStorageCollection<CartItem> {
106
+ protected readonly storageKey = 'cart';
107
+ }
108
+
109
+ class SessionCart extends WebStorageCollection<CartItem> {
110
+ static override STORAGE = 'session' as const;
111
+ protected readonly storageKey = 'session-cart';
112
+ }
113
+ ```
114
+
115
+ ### IndexedDBCollection (`mvc-kit/web`)
116
+
117
+ IndexedDB with per-item storage. Requires manual `hydrate()`. Each collection gets its own object store.
118
+
119
+ ```typescript
120
+ import { IndexedDBCollection } from 'mvc-kit/web';
121
+
122
+ class MessagesCollection extends IndexedDBCollection<Message> {
123
+ protected readonly storageKey = 'messages';
124
+ static MAX_SIZE = 500;
125
+ }
126
+
127
+ // ViewModel must call hydrate()
128
+ class MessagesVM extends ViewModel {
129
+ private collection = singleton(MessagesCollection);
130
+ async onInit() {
131
+ await this.collection.hydrate();
132
+ if (this.collection.length === 0) this.load();
133
+ }
134
+ }
135
+ ```
136
+
137
+ ### NativeCollection (`mvc-kit/react-native`)
138
+
139
+ Configurable async key-value backend. Requires manual `hydrate()`. Blob strategy.
140
+
141
+ ```typescript
142
+ import { NativeCollection } from 'mvc-kit/react-native';
143
+ import AsyncStorage from '@react-native-async-storage/async-storage';
144
+
145
+ // Once at app startup:
146
+ NativeCollection.configure({
147
+ getItem: (key) => AsyncStorage.getItem(key),
148
+ setItem: (key, value) => AsyncStorage.setItem(key, value),
149
+ removeItem: (key) => AsyncStorage.removeItem(key),
150
+ });
151
+
152
+ // Then:
153
+ class TodosCollection extends NativeCollection<Todo> {
154
+ protected readonly storageKey = 'todos';
155
+ }
156
+ ```
157
+
158
+ Per-class override for edge cases (e.g., one collection uses SecureStore):
159
+
160
+ ```typescript
161
+ class SecureCollection extends NativeCollection<Secret> {
162
+ protected readonly storageKey = 'secrets';
163
+ protected async getItem(key: string) { return SecureStore.getItem(key); }
164
+ protected async setItem(key: string, value: string) { await SecureStore.setItem(key, value); }
165
+ protected async removeItem(key: string) { await SecureStore.removeItem(key); }
166
+ }
167
+ ```
168
+
169
+ ## Gotchas
170
+
171
+ 1. **Async hydration**: For IndexedDB and NativeCollection, `hydrate()` must be called manually (typically in ViewModel's `onInit()`). DEV warning fires on `items` access before hydration.
172
+ 2. **Non-singleton persistence**: Every mount triggers a full read and every mutation triggers a write. Use non-singleton only for lightweight, infrequently-changing data.
173
+ 3. **Optimistic timing**: With immediate writes (default), optimistic state is persisted before the server round-trip completes. If the app crashes between the write and rollback, storage will contain the optimistic data. If this matters, consider `static override WRITE_DELAY = 100` to delay the write past typical rollback timing.
174
+ 4. **WebStorage quota**: localStorage has a ~5MB limit. DEV warning on `QuotaExceededError`. Use IndexedDBCollection for larger datasets.
175
+
176
+ ## Method Binding
177
+
178
+ All public methods are auto-bound in the constructor. You can pass them point-free as callbacks without losing `this` context:
179
+
180
+ ```tsx
181
+ const { add, upsert } = collection;
182
+ channel.on("update", upsert); // point-free works
183
+ ```