mvc-kit 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +83 -2
  2. package/agent-config/claude-code/agents/mvc-kit-architect.md +7 -5
  3. package/agent-config/claude-code/skills/guide/anti-patterns.md +114 -1
  4. package/agent-config/claude-code/skills/guide/api-reference.md +74 -2
  5. package/agent-config/claude-code/skills/guide/patterns.md +65 -0
  6. package/agent-config/copilot/copilot-instructions.md +7 -2
  7. package/agent-config/cursor/cursorrules +7 -2
  8. package/dist/PersistentCollection-B8kNECDj.cjs +2 -0
  9. package/dist/PersistentCollection-B8kNECDj.cjs.map +1 -0
  10. package/dist/PersistentCollection-BFrgskju.js +542 -0
  11. package/dist/PersistentCollection-BFrgskju.js.map +1 -0
  12. package/dist/PersistentCollection.d.ts +69 -0
  13. package/dist/PersistentCollection.d.ts.map +1 -0
  14. package/dist/Resource.d.ts +60 -0
  15. package/dist/Resource.d.ts.map +1 -0
  16. package/dist/index.d.ts +3 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/mvc-kit.cjs +1 -1
  19. package/dist/mvc-kit.cjs.map +1 -1
  20. package/dist/mvc-kit.js +328 -442
  21. package/dist/mvc-kit.js.map +1 -1
  22. package/dist/react-native/NativeCollection.d.ts +65 -0
  23. package/dist/react-native/NativeCollection.d.ts.map +1 -0
  24. package/dist/react-native/index.d.ts +2 -0
  25. package/dist/react-native/index.d.ts.map +1 -0
  26. package/dist/react-native.cjs +2 -0
  27. package/dist/react-native.cjs.map +1 -0
  28. package/dist/react-native.js +63 -0
  29. package/dist/react-native.js.map +1 -0
  30. package/dist/web/IndexedDBCollection.d.ts +37 -0
  31. package/dist/web/IndexedDBCollection.d.ts.map +1 -0
  32. package/dist/web/WebStorageCollection.d.ts +33 -0
  33. package/dist/web/WebStorageCollection.d.ts.map +1 -0
  34. package/dist/web/idb.d.ts +16 -0
  35. package/dist/web/idb.d.ts.map +1 -0
  36. package/dist/web/index.d.ts +3 -0
  37. package/dist/web/index.d.ts.map +1 -0
  38. package/dist/web.cjs +2 -0
  39. package/dist/web.cjs.map +1 -0
  40. package/dist/web.js +181 -0
  41. package/dist/web.js.map +1 -0
  42. package/package.json +22 -1
package/README.md CHANGED
@@ -162,6 +162,84 @@ todos.sorted((a, b) => a.text.localeCompare(b.text));
162
162
  todos.map(t => t.text); // Map to new array
163
163
  ```
164
164
 
165
+ ### Persistent Collections
166
+
167
+ Collections that cache/repopulate from browser or device storage. Three adapters for different environments:
168
+
169
+ ```typescript
170
+ // Web — localStorage (auto-hydrates on first access)
171
+ import { WebStorageCollection } from 'mvc-kit/web';
172
+
173
+ class CartCollection extends WebStorageCollection<CartItem> {
174
+ protected readonly storageKey = 'cart';
175
+ }
176
+
177
+ // Web — IndexedDB (per-item storage, requires hydrate())
178
+ import { IndexedDBCollection } from 'mvc-kit/web';
179
+
180
+ class MessagesCollection extends IndexedDBCollection<Message> {
181
+ protected readonly storageKey = 'messages';
182
+ }
183
+
184
+ // React Native — configurable backend (requires hydrate())
185
+ import { NativeCollection } from 'mvc-kit/react-native';
186
+
187
+ NativeCollection.configure({
188
+ getItem: (key) => AsyncStorage.getItem(key),
189
+ setItem: (key, value) => AsyncStorage.setItem(key, value),
190
+ removeItem: (key) => AsyncStorage.removeItem(key),
191
+ });
192
+
193
+ class TodosCollection extends NativeCollection<Todo> {
194
+ protected readonly storageKey = 'todos';
195
+ }
196
+ ```
197
+
198
+ All adapters inherit Collection's full API (CRUD, query, optimistic updates, eviction, TTL). Mutations are automatically persisted via debounced writes. See `src/PersistentCollection.md` for details.
199
+
200
+ ### Resource
201
+
202
+ Collection + async tracking toolkit. Extends Collection with lifecycle and automatic async method tracking.
203
+
204
+ ```typescript
205
+ class UsersResource extends Resource<User> {
206
+ private api = singleton(UserService);
207
+
208
+ async loadAll() {
209
+ const data = await this.api.getAll(this.disposeSignal);
210
+ this.reset(data);
211
+ }
212
+
213
+ async loadById(id: number) {
214
+ const user = await this.api.getById(id, this.disposeSignal);
215
+ this.upsert(user);
216
+ }
217
+ }
218
+
219
+ const users = singleton(UsersResource);
220
+ await users.init();
221
+
222
+ users.loadAll();
223
+ users.async.loadAll.loading; // true while loading
224
+ users.async.loadAll.error; // error message, or null
225
+ users.async.loadAll.errorCode; // 'not_found', 'network', etc.
226
+
227
+ // Inherits all Collection methods
228
+ users.items; // readonly User[]
229
+ users.get(1); // User | undefined
230
+ users.filter(u => u.active);
231
+ ```
232
+
233
+ Supports external Collection injection for shared data scenarios:
234
+
235
+ ```typescript
236
+ class UsersResource extends Resource<User> {
237
+ constructor() {
238
+ super(singleton(SharedUsersCollection)); // All mutations go to the shared collection
239
+ }
240
+ }
241
+ ```
242
+
165
243
  ### Controller
166
244
 
167
245
  Stateless orchestrator for complex logic. Component-scoped, auto-disposed.
@@ -664,6 +742,7 @@ function App() {
664
742
  | `ViewModel<S, E?>` | Reactive state container with optional typed events |
665
743
  | `Model<S>` | Reactive entity with validation/dirty tracking |
666
744
  | `Collection<T>` | Reactive typed array with CRUD |
745
+ | `Resource<T>` | Collection + async tracking + external Collection injection |
667
746
  | `Controller` | Stateless orchestrator (Disposable) |
668
747
  | `Service` | Non-reactive infrastructure service (Disposable) |
669
748
  | `EventBus<E>` | Typed pub/sub event bus |
@@ -679,7 +758,8 @@ function App() {
679
758
  | `Updater<S>` | `(state: Readonly<S>) => Partial<S>` |
680
759
  | `ValidationErrors<S>` | `Partial<Record<keyof S, string>>` |
681
760
  | `TaskState` | `{ loading: boolean; error: string \| null }` |
682
- | `AsyncMethodKeys<T>` | Union of method names on `T` that return `Promise` |
761
+ | `AsyncMethodKeys<T>` | Union of method names on `T` that return `Promise` (ViewModel) |
762
+ | `ResourceAsyncMethodKeys<T>` | Union of method names on `T` that return `Promise` (Resource) |
683
763
 
684
764
  ### Singleton Functions
685
765
 
@@ -734,7 +814,7 @@ function App() {
734
814
  - After `init()`, all subclass methods are wrapped for automatic async tracking; `vm.async.methodName` returns `TaskState`
735
815
  - Sync methods are auto-pruned on first call — zero overhead after detection
736
816
  - Async errors are re-thrown (preserves standard Promise rejection); AbortErrors are silently swallowed by the wrapper (but internal catch blocks do receive them — use `isAbortError()` to guard shared-state side effects like Collection rollbacks)
737
- - `async` and `subscribeAsync` are reserved property names on ViewModel
817
+ - `async` and `subscribeAsync` are reserved property names on ViewModel and Resource
738
818
  - React hooks (`useLocal`, `useModel`, `useSingleton`) auto-call `init()` after mount
739
819
  - `singleton()` does **not** auto-call `init()` — call it manually outside React
740
820
  - StrictMode safe: the `_initialized` guard prevents double-init during React's double-mount cycle; `disposeSignal` is not aborted during StrictMode's fake unmount/remount cycle
@@ -751,6 +831,7 @@ Each core class and React hook has a dedicated reference doc with full API detai
751
831
  | [ViewModel](src/ViewModel.md) | State management, computed getters, async tracking, typed events, lifecycle hooks |
752
832
  | [Model](src/Model.md) | Validation, dirty tracking, commit/rollback for entity forms |
753
833
  | [Collection](src/Collection.md) | Reactive typed array, CRUD, optimistic updates, shared data cache |
834
+ | [Resource](src/Resource.md) | Collection + async tracking toolkit with external Collection injection |
754
835
  | [Controller](src/Controller.md) | Stateless orchestrator for multi-ViewModel coordination |
755
836
  | [Service](src/Service.md) | Non-reactive infrastructure adapters (HTTP, storage, SDKs) |
756
837
  | [EventBus](src/EventBus.md) | Typed pub/sub for cross-cutting event communication |
@@ -13,6 +13,7 @@ You are an architecture planning agent for applications built with **mvc-kit**,
13
13
  | `ViewModel<S, E?>` | Reactive state + computed getters + async tracking + typed events | Component-scoped (`useLocal`) |
14
14
  | `Model<S>` | Entity with validation + dirty tracking + commit/rollback | Component-scoped (`useModel`) |
15
15
  | `Collection<T>` | Reactive typed array, shared data cache, optimistic updates | Singleton |
16
+ | `Resource<T>` | Collection + async tracking, transparent Collection injection | Singleton |
16
17
  | `Service` | Stateless infrastructure adapter (HTTP, storage) | Singleton |
17
18
  | `EventBus<E>` | Typed pub/sub for cross-cutting events | Singleton |
18
19
  | `Channel<M>` | Persistent connection (WebSocket/SSE) with auto-reconnect | Singleton |
@@ -24,11 +25,12 @@ You are an architecture planning agent for applications built with **mvc-kit**,
24
25
  1. Holds UI state for a component? → **ViewModel**
25
26
  2. Single entity with validation? → **Model**
26
27
  3. List of entities with CRUD? → **Collection**
27
- 4. Fetches external data? → **Service**
28
- 5. Broadcasts cross-cutting events? → **EventBus**
29
- 6. Persistent connection? → **Channel**
30
- 7. Coordinates multiple ViewModels? → **Controller** (rare)
31
- 8. None of the above plain utility function
28
+ 4. List of entities with CRUD + API loading + loading/error tracking? → **Resource**
29
+ 5. Wraps raw `fetch()` with error handling? → **Service** (skip if you have a typed API client)
30
+ 6. Broadcasts cross-cutting events? → **EventBus**
31
+ 7. Persistent connection? → **Channel**
32
+ 8. Coordinates multiple ViewModels?**Controller** (rare)
33
+ 9. None of the above → plain utility function
32
34
 
33
35
  ### Which sharing pattern?
34
36
  - "Can the parent own one ViewModel and pass props?" → **Pattern A** (default)
@@ -341,7 +341,81 @@ async loadPage(page: number) {
341
341
 
342
342
  ---
343
343
 
344
- ## 16. Missing disposeSignal on Async Calls
344
+ ## 16. Empty Collection + Service When Resource Would Suffice
345
+
346
+ ```typescript
347
+ // BAD — boilerplate: empty Collection subclass + Service + manual cache check
348
+ class UsersCollection extends Collection<User> {}
349
+ class UsersViewModel extends ViewModel<State> {
350
+ private collection = singleton(UsersCollection);
351
+ private service = singleton(UserService);
352
+
353
+ onInit() {
354
+ if (this.collection.length === 0) this.load();
355
+ }
356
+
357
+ async load() {
358
+ const data = await this.service.getAll(this.disposeSignal);
359
+ this.collection.reset(data);
360
+ }
361
+ }
362
+
363
+ // GOOD — Resource combines Collection + async tracking
364
+ class UsersResource extends Resource<User> {
365
+ private api = singleton(UserService);
366
+
367
+ async loadAll() {
368
+ const data = await this.api.getAll(this.disposeSignal);
369
+ this.reset(data);
370
+ }
371
+ }
372
+ class UsersViewModel extends ViewModel<State> {
373
+ private users = singleton(UsersResource);
374
+
375
+ onInit() {
376
+ if (this.users.length === 0) this.users.loadAll();
377
+ }
378
+ }
379
+ ```
380
+
381
+ ---
382
+
383
+ ## 17. Pass-Through Service Wrapping a Typed API Client
384
+
385
+ ```typescript
386
+ // BAD — Service just proxies an RPC/tRPC/GraphQL client with no added value
387
+ class ArticleService extends Service {
388
+ async get(id: string, signal?: AbortSignal) {
389
+ const { result } = await rpcClient.articles.get({ id }, { abortSignal: signal });
390
+ return result;
391
+ }
392
+ async create(input: CreateInput, signal?: AbortSignal) {
393
+ const { result } = await rpcClient.articles.create(input, { abortSignal: signal });
394
+ return result;
395
+ }
396
+ }
397
+ class ArticleResource extends Resource<Article> {
398
+ private api = singleton(ArticleService);
399
+ async loadById(id: string) {
400
+ const article = await this.api.get(id, this.disposeSignal);
401
+ this.upsert(article);
402
+ }
403
+ }
404
+
405
+ // GOOD — Resource calls the typed client directly
406
+ class ArticleResource extends Resource<Article> {
407
+ async loadById(id: string) {
408
+ const { result } = await rpcClient.articles.get({ id }, { abortSignal: this.disposeSignal });
409
+ this.upsert(result);
410
+ }
411
+ }
412
+ ```
413
+
414
+ A Service earns its place when it wraps raw `fetch()` with `HttpError`, composes multiple HTTP calls, handles retries, or manages auth headers. If your API client already does this, skip the Service.
415
+
416
+ ---
417
+
418
+ ## 18. Missing disposeSignal on Async Calls
345
419
 
346
420
  ```typescript
347
421
  // BAD — no cancellation on unmount
@@ -356,3 +430,42 @@ async load() {
356
430
  this.set({ items: data });
357
431
  }
358
432
  ```
433
+
434
+ ---
435
+
436
+ ## 18. Persisting Ephemeral UI State
437
+
438
+ ```typescript
439
+ // BAD — persisting search results, selections, or high-churn data
440
+ class SearchResultsCollection extends WebStorageCollection<Result> {
441
+ protected readonly storageKey = 'search-results';
442
+ }
443
+
444
+ // GOOD — persist entity caches, not ephemeral state
445
+ class CartCollection extends WebStorageCollection<CartItem> {
446
+ protected readonly storageKey = 'cart';
447
+ }
448
+ ```
449
+
450
+ Persistence adds I/O overhead on every mutation. For high-churn data, this causes jank (localStorage blocks main thread) or quota exhaustion.
451
+
452
+ ---
453
+
454
+ ## 19. Missing hydrate() for Async Adapters
455
+
456
+ ```typescript
457
+ // BAD — accessing items before hydrate() on async adapter
458
+ class VM extends ViewModel {
459
+ private msgs = singleton(MessagesCollection); // IndexedDBCollection
460
+ get messages() { return this.msgs.items; } // empty — not hydrated yet!
461
+ }
462
+
463
+ // GOOD — hydrate in onInit before accessing data
464
+ class VM extends ViewModel {
465
+ private msgs = singleton(MessagesCollection);
466
+ async onInit() {
467
+ await this.msgs.hydrate();
468
+ if (this.msgs.length === 0) this.load();
469
+ }
470
+ }
471
+ ```
@@ -4,14 +4,20 @@
4
4
 
5
5
  ```typescript
6
6
  // Core classes and utilities
7
- import { ViewModel, Model, Collection, Controller, Service, EventBus, Channel } from 'mvc-kit';
7
+ import { ViewModel, Model, Collection, PersistentCollection, Resource, Controller, Service, EventBus, Channel } from 'mvc-kit';
8
8
  import { singleton, hasSingleton, teardown, teardownAll } from 'mvc-kit';
9
9
  import { HttpError, isAbortError, classifyError } from 'mvc-kit';
10
- import type { Subscribable, Disposable, Initializable, Listener, Updater, ValidationErrors, TaskState, AppError, AsyncMethodKeys, ChannelStatus } from 'mvc-kit';
10
+ import type { Subscribable, Disposable, Initializable, Listener, Updater, ValidationErrors, TaskState, AppError, AsyncMethodKeys, ResourceAsyncMethodKeys, ChannelStatus } from 'mvc-kit';
11
11
 
12
12
  // React hooks
13
13
  import { useLocal, useSingleton, useInstance, useModel, useField, useEvent, useEmit, useResolve, useTeardown, Provider } from 'mvc-kit/react';
14
14
  import type { StateOf, ItemOf, SingletonClass, ProviderRegistry, ModelHandle, FieldHandle, ProviderProps } from 'mvc-kit/react';
15
+
16
+ // Web storage adapters
17
+ import { WebStorageCollection, IndexedDBCollection } from 'mvc-kit/web';
18
+
19
+ // React Native storage adapter
20
+ import { NativeCollection } from 'mvc-kit/react-native';
15
21
  ```
16
22
 
17
23
  ---
@@ -144,6 +150,72 @@ Static overrides for auto-eviction (zero-cost when not configured):
144
150
 
145
151
  ---
146
152
 
153
+ ## PersistentCollection<T extends { id: string | number }>
154
+
155
+ Abstract base for Collections that persist to external storage. Extends Collection with delta tracking, debounced writes, and hydration.
156
+
157
+ ### Static Config
158
+ - `static WRITE_DELAY = 100` — Debounce ms for storage writes. `0` = immediate.
159
+
160
+ ### Public API
161
+ - `hydrate(): Promise<readonly T[]>` — Load from storage, idempotent. Returns items.
162
+ - `hydrated: boolean` — Whether storage data has been loaded.
163
+ - `clearStorage(): void | Promise<void>` — Remove from storage AND clear in-memory.
164
+
165
+ ### Serialization Hooks (overridable)
166
+ - `serialize(items: readonly T[]): string` — Default: `JSON.stringify`.
167
+ - `deserialize(raw: string): T[]` — Default: `JSON.parse`.
168
+
169
+ ### Error Hook
170
+ - `onPersistError?(error: unknown): void` — Called on storage errors. Default: DEV warning.
171
+
172
+ ### Concrete Adapters
173
+
174
+ **WebStorageCollection** (`mvc-kit/web`): localStorage/sessionStorage. Auto-hydrates on first access. Blob strategy.
175
+ - `static STORAGE: 'local' | 'session' = 'local'`
176
+
177
+ **IndexedDBCollection** (`mvc-kit/web`): IndexedDB per-item storage. Requires manual `hydrate()`.
178
+ - `static DB_NAME = 'mvc-kit'`
179
+
180
+ **NativeCollection** (`mvc-kit/react-native`): Configurable async backend. Requires manual `hydrate()`. Blob strategy.
181
+ - `static configure({ getItem, setItem, removeItem })` — Set default adapter.
182
+ - Per-class override: `getItem()`, `setItem()`, `removeItem()`.
183
+
184
+ ---
185
+
186
+ ## Resource<T extends { id: string | number }>
187
+
188
+ Collection + async tracking toolkit. Extends Collection with lifecycle and automatic async method tracking.
189
+
190
+ ### Constructor
191
+ ```typescript
192
+ new MyResource() // Default: Resource IS the collection
193
+ new MyResource([item1, item2]) // With initial items
194
+ new MyResource(externalCollection) // Inject external Collection
195
+ ```
196
+
197
+ ### Lifecycle
198
+ - `initialized: boolean` — Whether `init()` has been called.
199
+ - `init(): void | Promise<void>` — Wraps methods for async tracking, calls `onInit()`. Idempotent.
200
+ - `onInit(): void | Promise<void>` — Override for initial data loading.
201
+ - `onDispose(): void` — Override for custom teardown.
202
+
203
+ ### Async Tracking
204
+ After `init()`, every subclass method is wrapped. Async methods get automatic loading/error tracking.
205
+ - `async: Record<string, TaskState>` — `resource.async.methodName` returns `{ loading, error, errorCode }`.
206
+ - `subscribeAsync(listener: () => void): () => void` — Subscribe to async state changes.
207
+
208
+ ### External Collection Injection
209
+ When constructor receives a Collection instance, ALL inherited Collection methods (add, upsert, reset, items, subscribe, etc.) transparently delegate to the external collection. Resource disposal does NOT dispose the shared collection.
210
+
211
+ ### Inherits All Collection API
212
+ CRUD, query, subscribe, optimistic, MAX_SIZE, TTL — all from Collection.
213
+
214
+ ### Static
215
+ - `static GHOST_TIMEOUT = 3000` — DEV-only ghost detection delay.
216
+
217
+ ---
218
+
147
219
  ## Service
148
220
 
149
221
  Stateless infrastructure adapter. Singleton-scoped.
@@ -229,6 +229,71 @@ Thin subclass for singleton identity. No custom methods — query logic goes in
229
229
 
230
230
  ---
231
231
 
232
+ ## Persistence Pattern
233
+
234
+ ```typescript
235
+ // Web — localStorage (auto-hydrates)
236
+ import { WebStorageCollection } from 'mvc-kit/web';
237
+ class CartCollection extends WebStorageCollection<CartItem> {
238
+ protected readonly storageKey = 'cart';
239
+ }
240
+
241
+ // Web — IndexedDB (requires hydrate)
242
+ import { IndexedDBCollection } from 'mvc-kit/web';
243
+ class MessagesCollection extends IndexedDBCollection<Message> {
244
+ protected readonly storageKey = 'messages';
245
+ }
246
+
247
+ // React Native (requires hydrate)
248
+ import { NativeCollection } from 'mvc-kit/react-native';
249
+ class TodosCollection extends NativeCollection<Todo> {
250
+ protected readonly storageKey = 'todos';
251
+ }
252
+ ```
253
+
254
+ Persist entity caches (users, todos, settings) — not ephemeral UI state. Call `hydrate()` in ViewModel's `onInit()` for async adapters. Mutations persist automatically via debounced writes.
255
+
256
+ ---
257
+
258
+ ## Resource Pattern
259
+
260
+ If you have a typed API client (RPC, tRPC, GraphQL codegen), call it directly — no Service wrapper needed:
261
+
262
+ ```typescript
263
+ class UsersResource extends Resource<UserState> {
264
+ async loadAll() {
265
+ const data = await apiClient.users.list({ signal: this.disposeSignal });
266
+ this.reset(data);
267
+ }
268
+ }
269
+ ```
270
+
271
+ Use a Service only when wrapping raw `fetch()` with `HttpError`, composing calls, or managing auth/retries:
272
+
273
+ ```typescript
274
+ class UsersResource extends Resource<UserState> {
275
+ private api = singleton(UserService); // Service earns its place here
276
+
277
+ async loadAll() {
278
+ const data = await this.api.getAll(this.disposeSignal);
279
+ this.reset(data);
280
+ }
281
+ }
282
+ ```
283
+
284
+ Use Resource when you need a Collection with built-in async tracking. Define your own async methods; use inherited Collection mutations. Each method gets independent tracking: `resource.async.loadAll.loading`.
285
+
286
+ For shared data (Resource + Channel feeding same store), inject an external Collection:
287
+ ```typescript
288
+ class UsersResource extends Resource<UserState> {
289
+ constructor() {
290
+ super(singleton(SharedUsersCollection));
291
+ }
292
+ }
293
+ ```
294
+
295
+ ---
296
+
232
297
  ## Optimistic Updates
233
298
 
234
299
  ```typescript
@@ -9,6 +9,8 @@ This project uses **mvc-kit**, a zero-dependency TypeScript-first reactive state
9
9
  | `ViewModel<S, E?>` | Reactive state + computed getters + async tracking + typed events | Component-scoped (`useLocal`) |
10
10
  | `Model<S>` | Entity with validation + dirty tracking + commit/rollback | Component-scoped (`useModel`) |
11
11
  | `Collection<T>` | Reactive typed array, shared data cache, optimistic updates | Singleton |
12
+ | `PersistentCollection<T>` | Collection + storage persistence (WebStorage, IndexedDB, RN) | Singleton |
13
+ | `Resource<T>` | Collection + async tracking, transparent Collection injection | Singleton |
12
14
  | `Service` | Stateless infrastructure adapter (HTTP, storage) | Singleton |
13
15
  | `EventBus<E>` | Typed pub/sub for cross-cutting events | Singleton |
14
16
  | `Channel<M>` | Persistent connection (WebSocket/SSE) with auto-reconnect | Singleton |
@@ -17,9 +19,11 @@ This project uses **mvc-kit**, a zero-dependency TypeScript-first reactive state
17
19
  ## Imports
18
20
 
19
21
  ```typescript
20
- import { ViewModel, Model, Collection, Controller, Service, EventBus, Channel } from 'mvc-kit';
22
+ import { ViewModel, Model, Collection, PersistentCollection, Resource, Controller, Service, EventBus, Channel } from 'mvc-kit';
21
23
  import { singleton, teardownAll, HttpError, isAbortError, classifyError } from 'mvc-kit';
22
24
  import { useLocal, useSingleton, useInstance, useModel, useField, useEvent, useEmit, useResolve, useTeardown, Provider } from 'mvc-kit/react';
25
+ import { WebStorageCollection, IndexedDBCollection } from 'mvc-kit/web';
26
+ import { NativeCollection } from 'mvc-kit/react-native';
23
27
  ```
24
28
 
25
29
  ## Architecture Rules
@@ -230,13 +234,14 @@ test('example', () => {
230
234
  - Manual optimistic snapshot/restore → use `collection.optimistic()`
231
235
  - `reset()` for paginated/incremental loads → use `upsert()` to accumulate data
232
236
  - `useState`/`useMemo`/`useCallback` in connected components → ViewModel handles it
237
+ - Pass-through Service wrapping a typed API client → call the client directly from Resource
233
238
 
234
239
  ## Decision Framework
235
240
 
236
241
  - Holds UI state for a component → **ViewModel**
237
242
  - Single entity with validation → **Model**
238
243
  - List of entities with CRUD → **Collection**
239
- - Fetches external data → **Service**
244
+ - Wraps raw `fetch()` with error handling → **Service** (skip if you have a typed API client)
240
245
  - Cross-cutting events → **EventBus**
241
246
  - Persistent connection → **Channel**
242
247
  - Coordinates multiple ViewModels → **Controller** (rare)
@@ -9,6 +9,8 @@ This project uses **mvc-kit**, a zero-dependency TypeScript-first reactive state
9
9
  | `ViewModel<S, E?>` | Reactive state + computed getters + async tracking + typed events | Component-scoped (`useLocal`) |
10
10
  | `Model<S>` | Entity with validation + dirty tracking + commit/rollback | Component-scoped (`useModel`) |
11
11
  | `Collection<T>` | Reactive typed array, shared data cache, optimistic updates | Singleton |
12
+ | `PersistentCollection<T>` | Collection + storage persistence (WebStorage, IndexedDB, RN) | Singleton |
13
+ | `Resource<T>` | Collection + async tracking, transparent Collection injection | Singleton |
12
14
  | `Service` | Stateless infrastructure adapter (HTTP, storage) | Singleton |
13
15
  | `EventBus<E>` | Typed pub/sub for cross-cutting events | Singleton |
14
16
  | `Channel<M>` | Persistent connection (WebSocket/SSE) with auto-reconnect | Singleton |
@@ -17,9 +19,11 @@ This project uses **mvc-kit**, a zero-dependency TypeScript-first reactive state
17
19
  ## Imports
18
20
 
19
21
  ```typescript
20
- import { ViewModel, Model, Collection, Controller, Service, EventBus, Channel } from 'mvc-kit';
22
+ import { ViewModel, Model, Collection, PersistentCollection, Resource, Controller, Service, EventBus, Channel } from 'mvc-kit';
21
23
  import { singleton, teardownAll, HttpError, isAbortError, classifyError } from 'mvc-kit';
22
24
  import { useLocal, useSingleton, useInstance, useModel, useField, useEvent, useEmit, useResolve, useTeardown, Provider } from 'mvc-kit/react';
25
+ import { WebStorageCollection, IndexedDBCollection } from 'mvc-kit/web';
26
+ import { NativeCollection } from 'mvc-kit/react-native';
23
27
  ```
24
28
 
25
29
  ## Architecture Rules
@@ -230,13 +234,14 @@ test('example', () => {
230
234
  - Manual optimistic snapshot/restore → use `collection.optimistic()`
231
235
  - `reset()` for paginated/incremental loads → use `upsert()` to accumulate data
232
236
  - `useState`/`useMemo`/`useCallback` in connected components → ViewModel handles it
237
+ - Pass-through Service wrapping a typed API client → call the client directly from Resource
233
238
 
234
239
  ## Decision Framework
235
240
 
236
241
  - Holds UI state for a component → **ViewModel**
237
242
  - Single entity with validation → **Model**
238
243
  - List of entities with CRUD → **Collection**
239
- - Fetches external data → **Service**
244
+ - Wraps raw `fetch()` with error handling → **Service** (skip if you have a typed API client)
240
245
  - Cross-cutting events → **EventBus**
241
246
  - Persistent connection → **Channel**
242
247
  - Coordinates multiple ViewModels → **Controller** (rare)
@@ -0,0 +1,2 @@
1
+ "use strict";const u=typeof __MVC_KIT_DEV__<"u"&&__MVC_KIT_DEV__;class _{static MAX_SIZE=0;static TTL=0;_items=[];_disposed=!1;_listeners=new Set;_index=new Map;_abortController=null;_cleanups=null;_timestamps=null;_evictionTimer=null;constructor(t=[]){let e=[...t];if(this._ttl>0){this._timestamps=new Map;const i=Date.now();for(const s of e)this._timestamps.set(s.id,i)}if(this._maxSize>0&&e.length>this._maxSize){const i=e.length-this._maxSize,s=e.slice(0,i);e=e.slice(i);for(const r of s)this._timestamps?.delete(r.id)}this._items=Object.freeze(e),this.rebuildIndex(),this._scheduleEvictionTimer()}get state(){return this._items}get items(){return this._items}get length(){return this._items.length}get disposed(){return this._disposed}get disposeSignal(){return this._abortController||(this._abortController=new AbortController),this._abortController.signal}get _maxSize(){return this.constructor.MAX_SIZE}get _ttl(){return this.constructor.TTL}add(...t){if(this._disposed)throw new Error("Cannot add to disposed Collection");if(t.length===0)return;const e=new Set,i=[];for(const o of t)!this._index.has(o.id)&&!e.has(o.id)&&(i.push(o),e.add(o.id));if(i.length===0)return;const s=this._items;let r=[...s,...i];for(const o of i)this._index.set(o.id,o);if(this._timestamps){const o=Date.now();for(const n of i)this._timestamps.set(n.id,o)}this._maxSize>0&&r.length>this._maxSize&&(r=this._evictForCapacity(r)),this._items=Object.freeze(r),this.notify(s),this._scheduleEvictionTimer()}upsert(...t){if(this._disposed)throw new Error("Cannot upsert on disposed Collection");if(t.length===0)return;const e=new Map;for(const h of t)e.set(h.id,h);const i=this._items;let s=!1;const r=new Set,o=[];for(const h of i)if(e.has(h.id)){const a=e.get(h.id);a!==h&&(s=!0),o.push(a),r.add(h.id)}else o.push(h);for(const[h,a]of e)r.has(h)||(o.push(a),s=!0);if(!s)return;if(this._timestamps){const h=Date.now();for(const[a]of e)this._timestamps.set(a,h)}for(const[h,a]of e)this._index.set(h,a);let n=o;this._maxSize>0&&n.length>this._maxSize&&(n=this._evictForCapacity(n)),this._items=Object.freeze(n),this.notify(i),this._scheduleEvictionTimer()}remove(...t){if(this._disposed)throw new Error("Cannot remove from disposed Collection");if(t.length===0)return;const e=new Set(t),i=this._items.filter(r=>!e.has(r.id));if(i.length===this._items.length)return;const s=this._items;this._items=Object.freeze(i);for(const r of t)this._index.delete(r),this._timestamps?.delete(r);this.notify(s),this._scheduleEvictionTimer()}update(t,e){if(this._disposed)throw new Error("Cannot update disposed Collection");const i=this._items.findIndex(d=>d.id===t);if(i===-1)return;const s=this._items[i],r={...s,...e,id:t};if(!Object.keys(e).some(d=>e[d]!==s[d]))return;const h=this._items,a=[...h];a[i]=r,this._items=Object.freeze(a),this._index.set(t,r),this.notify(h)}reset(t){if(this._disposed)throw new Error("Cannot reset disposed Collection");const e=this._items;if(this._timestamps){this._timestamps.clear();const s=Date.now();for(const r of t)this._timestamps.set(r.id,s)}let i=[...t];this._maxSize>0&&i.length>this._maxSize&&(i=this._evictForCapacity(i)),this._items=Object.freeze(i),this.rebuildIndex(),this.notify(e),this._scheduleEvictionTimer()}clear(){if(this._disposed)throw new Error("Cannot clear disposed Collection");if(this._items.length===0)return;const t=this._items;this._items=Object.freeze([]),this._index.clear(),this._timestamps?.clear(),this._clearEvictionTimer(),this.notify(t)}optimistic(t){if(this._disposed)throw new Error("Cannot perform optimistic update on disposed Collection");const e=this._items,i=this._timestamps?new Map(this._timestamps):null;t();let s=!1;return()=>{if(s||this._disposed)return;s=!0;const r=this._items;this._items=e,i&&(this._timestamps=i),this.rebuildIndex(),this.notify(r),this._scheduleEvictionTimer()}}get(t){return this._index.get(t)}has(t){return this._index.has(t)}find(t){return this._items.find(t)}filter(t){return this._items.filter(t)}sorted(t){return[...this._items].sort(t)}map(t){return this._items.map(t)}subscribe(t){return this._disposed?()=>{}:(this._listeners.add(t),()=>{this._listeners.delete(t)})}dispose(){if(!this._disposed){if(this._disposed=!0,this._clearEvictionTimer(),this._abortController?.abort(),this._cleanups){for(const t of this._cleanups)t();this._cleanups=null}this.onDispose?.(),this._listeners.clear(),this._index.clear(),this._timestamps?.clear()}}addCleanup(t){this._cleanups||(this._cleanups=[]),this._cleanups.push(t)}notify(t){for(const e of this._listeners)e(this._items,t)}rebuildIndex(){this._index.clear();for(const t of this._items)this._index.set(t.id,t)}_evictForCapacity(t){const e=t.length-this._maxSize;if(e<=0)return t;const i=t.slice(0,e),s=this._applyOnEvict(i,"capacity");if(s===!1||s.length===0)return t;const r=new Set(s.map(n=>n.id)),o=t.filter(n=>!r.has(n.id));for(const n of s)this._index.delete(n.id),this._timestamps?.delete(n.id);return o}_applyOnEvict(t,e){if(!this.onEvict)return t;const i=this.onEvict(t,e);if(i===!1){if(u&&e==="capacity"&&this._maxSize>0){const s=this._items.length+t.length;s>this._maxSize*2&&console.warn(`[mvc-kit] Collection exceeded 2x MAX_SIZE (${s}/${this._maxSize}). onEvict is vetoing eviction — this may cause unbounded growth.`)}return!1}if(Array.isArray(i)){const s=new Set(t.map(r=>r.id));return i.filter(r=>s.has(r.id))}return t}_sweepExpired(){if(this._disposed||!this._timestamps||this._ttl<=0)return;const t=Date.now(),e=this._ttl,i=[];for(const n of this._items){const h=this._timestamps.get(n.id);h!==void 0&&t-h>=e&&i.push(n)}if(i.length===0){this._scheduleEvictionTimer();return}const s=this._applyOnEvict(i,"ttl");if(s===!1){this._scheduleEvictionTimer();return}if(s.length===0){this._scheduleEvictionTimer();return}const r=new Set(s.map(n=>n.id)),o=this._items;this._items=Object.freeze(o.filter(n=>!r.has(n.id)));for(const n of s)this._index.delete(n.id),this._timestamps.delete(n.id);this.notify(o),this._scheduleEvictionTimer()}_scheduleEvictionTimer(){if(this._clearEvictionTimer(),this._disposed||!this._timestamps||this._ttl<=0||this._timestamps.size===0)return;const t=Date.now(),e=this._ttl;let i=1/0;for(const r of this._timestamps.values())r<i&&(i=r);const s=Math.max(0,i+e-t);this._evictionTimer=setTimeout(()=>this._sweepExpired(),s)}_clearEvictionTimer(){this._evictionTimer!==null&&(clearTimeout(this._evictionTimer),this._evictionTimer=null)}}const l=typeof __MVC_KIT_DEV__<"u"&&__MVC_KIT_DEV__,c=l?new Map:null;class m extends _{static WRITE_DELAY=100;serialize(t){return JSON.stringify(t)}deserialize(t){return JSON.parse(t)}_hydrated=!1;_hydrating=!1;_persistenceReady=!1;_preHydrationWarned=!1;_pendingWrites=new Map;_pendingRemoves=new Set;_pendingClear=!1;_flushTimer=null;constructor(t=[]){super(t);const e=this.subscribe((i,s)=>{this._hydrating||(this._ensurePersistenceReady(),this._diffAndQueue(i,s),this._scheduleSave())});this.addCleanup(e)}_ensurePersistenceReady(){if(!this._persistenceReady&&(this._persistenceReady=!0,l&&c)){const t=this.constructor.name,e=c.get(this.storageKey);e&&e!==t&&console.warn(`[mvc-kit] Duplicate storageKey "${this.storageKey}" used by "${t}" and "${e}". Each PersistentCollection should have a unique storageKey.`),c.set(this.storageKey,t)}}get hydrated(){return this._hydrated}async hydrate(){if(this._hydrated)return this.items;this._ensurePersistenceReady(),this._hydrating=!0;try{const t=await this.persistGetAll();return t.length>0&&super.reset(t),this._hydrated=!0,this.items}catch(t){return this._handlePersistError(t),this._hydrated=!0,this.items}finally{this._hydrating=!1}}_hydrateSync(){if(!this._hydrated){this._ensurePersistenceReady(),this._hydrating=!0;try{const t=this.persistGetAll();if(t instanceof Promise)throw new Error("[mvc-kit] _hydrateSync called with async persistGetAll");t.length>0&&super.reset(t),this._hydrated=!0}catch(t){this._handlePersistError(t),this._hydrated=!0}finally{this._hydrating=!1}}}clearStorage(){this._ensurePersistenceReady(),this._pendingWrites.clear(),this._pendingRemoves.clear(),this._pendingClear=!1,this._cancelSave(),this.length>0&&super.clear();try{const t=this.persistClear();if(t instanceof Promise)return t.catch(e=>this._handlePersistError(e))}catch(t){this._handlePersistError(t)}}reset(t){this._pendingClear=!0,this._pendingWrites.clear(),this._pendingRemoves.clear();for(const e of t)this._pendingWrites.set(e.id,e);this._hydrating=!0;try{super.reset(t)}finally{this._hydrating=!1}this._scheduleSave()}clear(){this._pendingClear=!0,this._pendingWrites.clear(),this._pendingRemoves.clear(),this._hydrating=!0;try{super.clear()}finally{this._hydrating=!1}this._scheduleSave()}get items(){return l&&!this._hydrated&&!this._hydrating&&!this._preHydrationWarned&&(this._preHydrationWarned=!0,console.warn(`[mvc-kit] Accessing items on "${this.constructor.name}" before hydrate() has been called. Data may be incomplete. Call hydrate() first.`)),super.items}get state(){return this.items}dispose(){if(!this.disposed){if(this._cancelSave(),this._hasPending())try{const t=this._flush();t instanceof Promise&&t.catch(e=>this._handlePersistError(e))}catch(t){this._handlePersistError(t)}l&&c&&this._persistenceReady&&c.delete(this.storageKey),super.dispose()}}_diffAndQueue(t,e){const i=new Map;for(const r of e)i.set(r.id,r);const s=new Map;for(const r of t)s.set(r.id,r);for(const r of t){const o=i.get(r.id);(!o||o!==r)&&(this._pendingWrites.set(r.id,r),this._pendingRemoves.delete(r.id))}for(const r of e)s.has(r.id)||(this._pendingRemoves.add(r.id),this._pendingWrites.delete(r.id))}_hasPending(){return this._pendingClear||this._pendingWrites.size>0||this._pendingRemoves.size>0}_scheduleSave(){this._cancelSave();const t=this.constructor.WRITE_DELAY;if(t<=0){this._doFlush();return}this._flushTimer=setTimeout(()=>this._doFlush(),t)}_cancelSave(){this._flushTimer!==null&&(clearTimeout(this._flushTimer),this._flushTimer=null)}_doFlush(){if(this._hasPending())try{const t=this._flush();t instanceof Promise&&t.catch(e=>this._handlePersistError(e))}catch(t){this._handlePersistError(t)}}_flush(){const t=this._pendingClear,e=this._pendingWrites.size>0?[...this._pendingWrites.values()]:null,i=this._pendingRemoves.size>0?[...this._pendingRemoves]:null;if(this._pendingClear=!1,this._pendingWrites.clear(),this._pendingRemoves.clear(),t){const s=this.persistClear();return s instanceof Promise?s.then(()=>{if(e)return this.persistSet(e)}):e?this.persistSet(e):void 0}if(i){const s=this.persistRemove(i);if(s instanceof Promise)return s.then(()=>{if(e)return this.persistSet(e)})}if(e)return this.persistSet(e)}_handlePersistError(t){if(this.onPersistError){this.onPersistError(t);return}l&&console.warn("[mvc-kit] Storage error:",t)}}exports.Collection=_;exports.PersistentCollection=m;
2
+ //# sourceMappingURL=PersistentCollection-B8kNECDj.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PersistentCollection-B8kNECDj.cjs","sources":["../src/Collection.ts","../src/PersistentCollection.ts"],"sourcesContent":["import type { Listener, Subscribable } from './types';\n\nconst __DEV__ = typeof __MVC_KIT_DEV__ !== 'undefined' && __MVC_KIT_DEV__;\n\ntype CollectionState<T> = readonly T[];\ntype CollectionListener<T> = Listener<CollectionState<T>>;\n\n/**\n * Reactive typed array with CRUD and query methods.\n */\nexport class Collection<T extends { id: string | number }> implements Subscribable<CollectionState<T>> {\n /** Maximum number of items before FIFO eviction. 0 = unlimited. */\n static MAX_SIZE = 0;\n /** Time-to-live in milliseconds. 0 = no expiry. */\n static TTL = 0;\n\n private _items: readonly T[] = [];\n private _disposed = false;\n private _listeners = new Set<CollectionListener<T>>();\n private _index = new Map<T['id'], T>();\n private _abortController: AbortController | null = null;\n private _cleanups: (() => void)[] | null = null;\n private _timestamps: Map<T['id'], number> | null = null;\n private _evictionTimer: ReturnType<typeof setTimeout> | null = null;\n\n constructor(initialItems: T[] = []) {\n let result = [...initialItems];\n\n if (this._ttl > 0) {\n this._timestamps = new Map();\n const now = Date.now();\n for (const item of result) {\n this._timestamps.set(item.id, now);\n }\n }\n\n if (this._maxSize > 0 && result.length > this._maxSize) {\n // FIFO: trim from the front (oldest items)\n const excess = result.length - this._maxSize;\n const evicted = result.slice(0, excess);\n result = result.slice(excess);\n for (const item of evicted) {\n this._timestamps?.delete(item.id);\n }\n }\n\n this._items = Object.freeze(result);\n this.rebuildIndex();\n this._scheduleEvictionTimer();\n }\n\n /**\n * Alias for Subscribable compatibility.\n */\n get state(): readonly T[] {\n return this._items;\n }\n\n /** The raw readonly array of items. */\n get items(): readonly T[] {\n return this._items;\n }\n\n /** Number of items in the collection. */\n get length(): number {\n return this._items.length;\n }\n\n /** Whether this instance has been disposed. */\n get disposed(): boolean {\n return this._disposed;\n }\n\n /** AbortSignal that fires when this instance is disposed. Lazily created. */\n get disposeSignal(): AbortSignal {\n if (!this._abortController) {\n this._abortController = new AbortController();\n }\n return this._abortController.signal;\n }\n\n // ── Config Accessors ──\n\n private get _maxSize(): number {\n return (this.constructor as typeof Collection).MAX_SIZE;\n }\n\n private get _ttl(): number {\n return (this.constructor as typeof Collection).TTL;\n }\n\n // ── CRUD Methods (notify listeners) ──\n\n /**\n * Add one or more items. Items with existing IDs are silently skipped.\n */\n add(...items: T[]): void {\n if (this._disposed) {\n throw new Error('Cannot add to disposed Collection');\n }\n\n if (items.length === 0) {\n return;\n }\n\n const seen = new Set<T['id']>();\n const newItems: T[] = [];\n for (const item of items) {\n if (!this._index.has(item.id) && !seen.has(item.id)) {\n newItems.push(item);\n seen.add(item.id);\n }\n }\n if (newItems.length === 0) return;\n\n const prev = this._items;\n let result = [...prev, ...newItems];\n\n for (const item of newItems) {\n this._index.set(item.id, item);\n }\n\n // Record timestamps for TTL\n if (this._timestamps) {\n const now = Date.now();\n for (const item of newItems) {\n this._timestamps.set(item.id, now);\n }\n }\n\n // Enforce capacity before freeze/notify\n if (this._maxSize > 0 && result.length > this._maxSize) {\n result = this._evictForCapacity(result);\n }\n\n this._items = Object.freeze(result);\n this.notify(prev);\n this._scheduleEvictionTimer();\n }\n\n /**\n * Add or replace items by ID. Existing items are replaced in-place\n * (preserving array position); new items are appended. Deduplicates\n * input — last occurrence wins. No-op if nothing changed (reference\n * comparison).\n */\n upsert(...items: T[]): void {\n if (this._disposed) {\n throw new Error('Cannot upsert on disposed Collection');\n }\n if (items.length === 0) return;\n\n // Deduplicate input — last occurrence wins\n const incoming = new Map<T['id'], T>();\n for (const item of items) {\n incoming.set(item.id, item);\n }\n\n const prev = this._items;\n let changed = false;\n const replaced = new Set<T['id']>();\n const newArray: T[] = [];\n\n // Replace existing items in-place\n for (const existing of prev) {\n if (incoming.has(existing.id)) {\n const replacement = incoming.get(existing.id)!;\n if (replacement !== existing) changed = true;\n newArray.push(replacement);\n replaced.add(existing.id);\n } else {\n newArray.push(existing);\n }\n }\n\n // Append genuinely new items\n for (const [id, item] of incoming) {\n if (!replaced.has(id)) {\n newArray.push(item);\n changed = true;\n }\n }\n\n if (!changed) return;\n\n // Record/refresh timestamps for TTL (upsert refreshes existing)\n if (this._timestamps) {\n const now = Date.now();\n for (const [id] of incoming) {\n this._timestamps.set(id, now);\n }\n }\n\n for (const [id, item] of incoming) {\n this._index.set(id, item);\n }\n\n // Enforce capacity before freeze/notify\n let result = newArray;\n if (this._maxSize > 0 && result.length > this._maxSize) {\n result = this._evictForCapacity(result);\n }\n\n this._items = Object.freeze(result);\n this.notify(prev);\n this._scheduleEvictionTimer();\n }\n\n /**\n * Remove items by id(s).\n */\n remove(...ids: T['id'][]): void {\n if (this._disposed) {\n throw new Error('Cannot remove from disposed Collection');\n }\n\n if (ids.length === 0) {\n return;\n }\n\n const idSet = new Set(ids);\n const filtered = this._items.filter(item => !idSet.has(item.id));\n\n if (filtered.length === this._items.length) {\n return; // No items removed\n }\n\n const prev = this._items;\n this._items = Object.freeze(filtered);\n\n for (const id of ids) {\n this._index.delete(id);\n this._timestamps?.delete(id);\n }\n\n this.notify(prev);\n this._scheduleEvictionTimer();\n }\n\n /**\n * Update an item by id with partial changes.\n */\n update(id: T['id'], changes: Partial<T>): void {\n if (this._disposed) {\n throw new Error('Cannot update disposed Collection');\n }\n\n const idx = this._items.findIndex(item => item.id === id);\n if (idx === -1) {\n return;\n }\n\n const existing = this._items[idx];\n const updated = { ...existing, ...changes, id }; // Ensure id is preserved\n\n // Check if anything actually changed\n const keys = Object.keys(changes) as (keyof T)[];\n const hasChanges = keys.some(key => changes[key] !== existing[key]);\n if (!hasChanges) {\n return;\n }\n\n const prev = this._items;\n const newItems = [...prev];\n newItems[idx] = updated;\n this._items = Object.freeze(newItems);\n this._index.set(id, updated);\n\n this.notify(prev);\n }\n\n /**\n * Replace all items.\n */\n reset(items: T[]): void {\n if (this._disposed) {\n throw new Error('Cannot reset disposed Collection');\n }\n\n const prev = this._items;\n\n // Record timestamps for TTL\n if (this._timestamps) {\n this._timestamps.clear();\n const now = Date.now();\n for (const item of items) {\n this._timestamps.set(item.id, now);\n }\n }\n\n let result = [...items];\n\n // Enforce capacity before freeze/notify\n if (this._maxSize > 0 && result.length > this._maxSize) {\n result = this._evictForCapacity(result);\n }\n\n this._items = Object.freeze(result);\n this.rebuildIndex();\n\n this.notify(prev);\n this._scheduleEvictionTimer();\n }\n\n /**\n * Remove all items.\n */\n clear(): void {\n if (this._disposed) {\n throw new Error('Cannot clear disposed Collection');\n }\n\n if (this._items.length === 0) {\n return;\n }\n\n const prev = this._items;\n this._items = Object.freeze([]);\n this._index.clear();\n this._timestamps?.clear();\n this._clearEvictionTimer();\n\n this.notify(prev);\n }\n\n /**\n * Snapshot current state, apply callback mutations, and return a rollback function.\n * Rollback restores items to pre-callback state regardless of later mutations.\n */\n optimistic(callback: () => void): () => void {\n if (this._disposed) {\n throw new Error('Cannot perform optimistic update on disposed Collection');\n }\n\n const snapshot = this._items;\n const timestampSnapshot = this._timestamps ? new Map(this._timestamps) : null;\n callback();\n\n let rolledBack = false;\n return () => {\n if (rolledBack || this._disposed) return;\n rolledBack = true;\n\n const prev = this._items;\n this._items = snapshot;\n if (timestampSnapshot) {\n this._timestamps = timestampSnapshot;\n }\n this.rebuildIndex();\n this.notify(prev);\n this._scheduleEvictionTimer();\n };\n }\n\n // ── Query Methods (pure, no notification) ──\n\n /**\n * Get item by id.\n */\n get(id: T['id']): T | undefined {\n return this._index.get(id);\n }\n\n /**\n * Check if item exists by id.\n */\n has(id: T['id']): boolean {\n return this._index.has(id);\n }\n\n /**\n * Find first item matching predicate.\n */\n find(predicate: (item: T) => boolean): T | undefined {\n return this._items.find(predicate);\n }\n\n /**\n * Filter items matching predicate.\n */\n filter(predicate: (item: T) => boolean): readonly T[] {\n return this._items.filter(predicate);\n }\n\n /**\n * Return sorted copy.\n */\n sorted(compareFn: (a: T, b: T) => number): readonly T[] {\n return [...this._items].sort(compareFn);\n }\n\n /**\n * Map items to new array.\n */\n map<U>(fn: (item: T) => U): readonly U[] {\n return this._items.map(fn);\n }\n\n // ── Subscribable interface ──\n\n /** Subscribes to state changes. Returns an unsubscribe function. */\n subscribe(listener: CollectionListener<T>): () => void {\n if (this._disposed) {\n return () => {};\n }\n\n this._listeners.add(listener);\n\n return () => {\n this._listeners.delete(listener);\n };\n }\n\n /** Tears down the instance, releasing all subscriptions and resources. */\n dispose(): void {\n if (this._disposed) {\n return;\n }\n\n this._disposed = true;\n this._clearEvictionTimer();\n this._abortController?.abort();\n if (this._cleanups) {\n for (const fn of this._cleanups) fn();\n this._cleanups = null;\n }\n this.onDispose?.();\n this._listeners.clear();\n this._index.clear();\n this._timestamps?.clear();\n }\n\n /** Registers a cleanup function to be called on dispose. @protected */\n protected addCleanup(fn: () => void): void {\n if (!this._cleanups) {\n this._cleanups = [];\n }\n this._cleanups.push(fn);\n }\n\n /** Lifecycle hook called during dispose(). Override for custom teardown. @protected */\n protected onDispose?(): void;\n\n /**\n * Called before items are auto-evicted by capacity or TTL.\n * Override to filter which items get evicted, or veto entirely.\n *\n * @param items - Candidates for eviction\n * @param reason - Why eviction is happening\n * @returns void to proceed with all, false to veto, or T[] subset to evict only those\n */\n protected onEvict?(items: T[], reason: 'capacity' | 'ttl'): T[] | false | void;\n\n private notify(prev: readonly T[]): void {\n for (const listener of this._listeners) {\n listener(this._items, prev);\n }\n }\n\n private rebuildIndex(): void {\n this._index.clear();\n for (const item of this._items) {\n this._index.set(item.id, item);\n }\n }\n\n // ── Eviction Internals ──\n\n private _evictForCapacity(items: T[]): T[] {\n const excess = items.length - this._maxSize;\n if (excess <= 0) return items;\n\n const candidates = items.slice(0, excess);\n const toEvict = this._applyOnEvict(candidates, 'capacity');\n\n if (toEvict === false) return items; // veto\n\n if (toEvict.length === 0) return items; // nothing to evict\n\n const evictIds = new Set(toEvict.map(item => item.id));\n const result = items.filter(item => !evictIds.has(item.id));\n\n // Clean up index and timestamps for evicted items\n for (const item of toEvict) {\n this._index.delete(item.id);\n this._timestamps?.delete(item.id);\n }\n\n return result;\n }\n\n private _applyOnEvict(candidates: T[], reason: 'capacity' | 'ttl'): T[] | false {\n if (!this.onEvict) return candidates;\n\n const result = this.onEvict(candidates, reason);\n if (result === false) {\n // DEV warning when veto causes collection to exceed 2x MAX_SIZE\n if (__DEV__ && reason === 'capacity' && this._maxSize > 0) {\n const currentSize = this._items.length + candidates.length;\n if (currentSize > this._maxSize * 2) {\n console.warn(\n `[mvc-kit] Collection exceeded 2x MAX_SIZE (${currentSize}/${this._maxSize}). ` +\n `onEvict is vetoing eviction — this may cause unbounded growth.`\n );\n }\n }\n return false;\n }\n if (Array.isArray(result)) {\n // Only include items that are actually in the current items\n const candidateIds = new Set(candidates.map(c => c.id));\n return result.filter(item => candidateIds.has(item.id));\n }\n return candidates; // void = proceed with all\n }\n\n private _sweepExpired(): void {\n if (this._disposed || !this._timestamps || this._ttl <= 0) return;\n\n const now = Date.now();\n const ttl = this._ttl;\n const expired: T[] = [];\n\n for (const item of this._items) {\n const ts = this._timestamps.get(item.id);\n if (ts !== undefined && (now - ts) >= ttl) {\n expired.push(item);\n }\n }\n\n if (expired.length === 0) {\n this._scheduleEvictionTimer();\n return;\n }\n\n const toEvict = this._applyOnEvict(expired, 'ttl');\n\n if (toEvict === false) {\n this._scheduleEvictionTimer();\n return;\n }\n\n if (toEvict.length === 0) {\n this._scheduleEvictionTimer();\n return;\n }\n\n const evictIds = new Set(toEvict.map(item => item.id));\n const prev = this._items;\n this._items = Object.freeze(\n (prev as T[]).filter((item: T) => !evictIds.has(item.id))\n );\n\n for (const item of toEvict) {\n this._index.delete(item.id);\n this._timestamps.delete(item.id);\n }\n\n this.notify(prev);\n this._scheduleEvictionTimer();\n }\n\n private _scheduleEvictionTimer(): void {\n this._clearEvictionTimer();\n\n if (this._disposed || !this._timestamps || this._ttl <= 0 || this._timestamps.size === 0) return;\n\n const now = Date.now();\n const ttl = this._ttl;\n let earliest = Infinity;\n\n for (const ts of this._timestamps.values()) {\n if (ts < earliest) earliest = ts;\n }\n\n const delay = Math.max(0, (earliest + ttl) - now);\n this._evictionTimer = setTimeout(() => this._sweepExpired(), delay);\n }\n\n private _clearEvictionTimer(): void {\n if (this._evictionTimer !== null) {\n clearTimeout(this._evictionTimer);\n this._evictionTimer = null;\n }\n }\n}\n","import { Collection } from './Collection';\n\nconst __DEV__ = typeof __MVC_KIT_DEV__ !== 'undefined' && __MVC_KIT_DEV__;\n\n// Track storageKey uniqueness in DEV\nconst _registeredKeys: Map<string, string> | null = __DEV__ ? new Map() : null;\n\n/**\n * Abstract base for Collections that persist to external storage.\n * Tracks deltas per mutation and flushes via debounced writes.\n * Subclasses implement the storage-specific `persist*` methods.\n */\nexport abstract class PersistentCollection<\n T extends { id: string | number },\n> extends Collection<T> {\n /** Debounce delay in ms for storage writes. 0 = immediate. */\n static WRITE_DELAY = 100;\n\n /** Unique key identifying this collection in storage. */\n protected abstract readonly storageKey: string;\n\n // ── Abstract persistence methods ──\n\n protected abstract persistGet(id: T['id']): T | null | Promise<T | null>;\n protected abstract persistGetAll(): T[] | Promise<T[]>;\n /** Upsert semantics — insert or replace the given items. */\n protected abstract persistSet(items: T[]): void | Promise<void>;\n protected abstract persistRemove(ids: T['id'][]): void | Promise<void>;\n protected abstract persistClear(): void | Promise<void>;\n\n // ── Serialization hooks ──\n\n /** Serialize items to a string. Used by string-based adapters (WebStorage, NativeCollection). */\n protected serialize(items: readonly T[]): string {\n return JSON.stringify(items);\n }\n\n /** Deserialize a string back to items. Used by string-based adapters. */\n protected deserialize(raw: string): T[] {\n return JSON.parse(raw);\n }\n\n // ── Error hook ──\n\n /** Called when a storage operation fails. Override for custom error handling. */\n protected onPersistError?(error: unknown): void;\n\n // ── Internal state ──\n\n private _hydrated = false;\n private _hydrating = false;\n private _persistenceReady = false;\n private _preHydrationWarned = false;\n private _pendingWrites = new Map<T['id'], T>();\n private _pendingRemoves = new Set<T['id']>();\n private _pendingClear = false;\n private _flushTimer: ReturnType<typeof setTimeout> | null = null;\n\n constructor(initialItems: T[] = []) {\n super(initialItems);\n\n // Self-subscribe to detect mutations via diff.\n // storageKey may not be available yet (class field initializers run after super()),\n // but that's fine — the subscriber only fires on mutations, not during construction.\n const unsub = this.subscribe((current, prev) => {\n if (this._hydrating) return;\n this._ensurePersistenceReady();\n this._diffAndQueue(current, prev);\n this._scheduleSave();\n });\n this.addCleanup(unsub);\n }\n\n /**\n * DEV check for duplicate storageKey. Called lazily since storageKey is an abstract\n * field that isn't available during the parent constructor chain.\n */\n private _ensurePersistenceReady(): void {\n if (this._persistenceReady) return;\n this._persistenceReady = true;\n\n if (__DEV__ && _registeredKeys) {\n const className = this.constructor.name;\n const existing = _registeredKeys.get(this.storageKey);\n if (existing && existing !== className) {\n console.warn(\n `[mvc-kit] Duplicate storageKey \"${this.storageKey}\" used by \"${className}\" ` +\n `and \"${existing}\". Each PersistentCollection should have a unique storageKey.`,\n );\n }\n _registeredKeys.set(this.storageKey, className);\n }\n }\n\n // ── Public API ──\n\n /** Whether storage data has been loaded. */\n get hydrated(): boolean {\n return this._hydrated;\n }\n\n /**\n * Load data from storage into the collection. Idempotent — subsequent calls return current items.\n * Returns the items after hydration.\n */\n async hydrate(): Promise<readonly T[]> {\n if (this._hydrated) return this.items;\n this._ensurePersistenceReady();\n\n this._hydrating = true;\n try {\n const stored = await this.persistGetAll();\n if (stored.length > 0) {\n super.reset(stored);\n }\n this._hydrated = true;\n return this.items;\n } catch (err) {\n this._handlePersistError(err);\n this._hydrated = true;\n return this.items;\n } finally {\n this._hydrating = false;\n }\n }\n\n /**\n * Synchronous hydration for sync adapters (e.g., WebStorage).\n * Call from the **leaf class** constructor (after field initializers have run).\n */\n protected _hydrateSync(): void {\n if (this._hydrated) return;\n this._ensurePersistenceReady();\n\n this._hydrating = true;\n try {\n const stored = this.persistGetAll();\n if (stored instanceof Promise) {\n throw new Error('[mvc-kit] _hydrateSync called with async persistGetAll');\n }\n if (stored.length > 0) {\n super.reset(stored);\n }\n this._hydrated = true;\n } catch (err) {\n this._handlePersistError(err);\n this._hydrated = true;\n } finally {\n this._hydrating = false;\n }\n }\n\n /**\n * Clear all data from storage AND from the in-memory collection.\n */\n clearStorage(): void | Promise<void> {\n this._ensurePersistenceReady();\n\n // Clear pending queues — we're wiping everything\n this._pendingWrites.clear();\n this._pendingRemoves.clear();\n this._pendingClear = false;\n this._cancelSave();\n\n // Clear in-memory\n if (this.length > 0) {\n super.clear();\n }\n\n try {\n const result = this.persistClear();\n if (result instanceof Promise) {\n return result.catch((err) => this._handlePersistError(err));\n }\n } catch (err) {\n this._handlePersistError(err);\n }\n }\n\n // ── Overrides for clear/reset tracking ──\n\n reset(items: T[]): void {\n this._pendingClear = true;\n this._pendingWrites.clear();\n this._pendingRemoves.clear();\n for (const item of items) {\n this._pendingWrites.set(item.id, item);\n }\n this._hydrating = true; // Re-use flag to skip subscriber\n try {\n super.reset(items);\n } finally {\n this._hydrating = false;\n }\n this._scheduleSave();\n }\n\n clear(): void {\n this._pendingClear = true;\n this._pendingWrites.clear();\n this._pendingRemoves.clear();\n this._hydrating = true;\n try {\n super.clear();\n } finally {\n this._hydrating = false;\n }\n this._scheduleSave();\n }\n\n // ── Override items getter for DEV pre-hydration warning ──\n\n get items(): readonly T[] {\n if (__DEV__ && !this._hydrated && !this._hydrating && !this._preHydrationWarned) {\n this._preHydrationWarned = true;\n console.warn(\n `[mvc-kit] Accessing items on \"${this.constructor.name}\" before hydrate() has been called. ` +\n `Data may be incomplete. Call hydrate() first.`,\n );\n }\n return super.items;\n }\n\n get state(): readonly T[] {\n return this.items;\n }\n\n // ── Dispose ──\n\n dispose(): void {\n if (this.disposed) return;\n\n // Flush any pending saves before disposing\n this._cancelSave();\n if (this._hasPending()) {\n try {\n const result = this._flush();\n if (result instanceof Promise) {\n result.catch((err) => this._handlePersistError(err));\n }\n } catch (err) {\n this._handlePersistError(err);\n }\n }\n\n // DEV: unregister storageKey\n if (__DEV__ && _registeredKeys && this._persistenceReady) {\n _registeredKeys.delete(this.storageKey);\n }\n\n super.dispose();\n }\n\n // ── Private: delta tracking ──\n\n private _diffAndQueue(current: readonly T[], prev: readonly T[]): void {\n const prevMap = new Map<T['id'], T>();\n for (const item of prev) {\n prevMap.set(item.id, item);\n }\n\n const currentMap = new Map<T['id'], T>();\n for (const item of current) {\n currentMap.set(item.id, item);\n }\n\n // Added or updated: in current but not in prev, or different reference\n for (const item of current) {\n const prevItem = prevMap.get(item.id);\n if (!prevItem || prevItem !== item) {\n this._pendingWrites.set(item.id, item);\n this._pendingRemoves.delete(item.id);\n }\n }\n\n // Removed: in prev but not in current\n for (const item of prev) {\n if (!currentMap.has(item.id)) {\n this._pendingRemoves.add(item.id);\n this._pendingWrites.delete(item.id);\n }\n }\n }\n\n private _hasPending(): boolean {\n return this._pendingClear || this._pendingWrites.size > 0 || this._pendingRemoves.size > 0;\n }\n\n // ── Private: debounce + flush ──\n\n private _scheduleSave(): void {\n this._cancelSave();\n const delay = (this.constructor as typeof PersistentCollection).WRITE_DELAY;\n if (delay <= 0) {\n this._doFlush();\n return;\n }\n this._flushTimer = setTimeout(() => this._doFlush(), delay);\n }\n\n private _cancelSave(): void {\n if (this._flushTimer !== null) {\n clearTimeout(this._flushTimer);\n this._flushTimer = null;\n }\n }\n\n private _doFlush(): void {\n if (!this._hasPending()) return;\n try {\n const result = this._flush();\n if (result instanceof Promise) {\n result.catch((err) => this._handlePersistError(err));\n }\n } catch (err) {\n this._handlePersistError(err);\n }\n }\n\n private _flush(): void | Promise<void> {\n const doClear = this._pendingClear;\n const writes = this._pendingWrites.size > 0 ? [...this._pendingWrites.values()] : null;\n const removes = this._pendingRemoves.size > 0 ? [...this._pendingRemoves] : null;\n\n // Clear queues\n this._pendingClear = false;\n this._pendingWrites.clear();\n this._pendingRemoves.clear();\n\n if (doClear) {\n const clearResult = this.persistClear();\n if (clearResult instanceof Promise) {\n return clearResult.then(() => {\n if (writes) return this.persistSet(writes);\n });\n }\n if (writes) {\n return this.persistSet(writes);\n }\n return;\n }\n\n // Non-clear: removes then writes\n if (removes) {\n const removeResult = this.persistRemove(removes);\n if (removeResult instanceof Promise) {\n return removeResult.then(() => {\n if (writes) return this.persistSet(writes);\n });\n }\n }\n if (writes) {\n return this.persistSet(writes);\n }\n }\n\n // ── Private: error handling ──\n\n private _handlePersistError(err: unknown): void {\n if (this.onPersistError) {\n this.onPersistError(err);\n return;\n }\n if (__DEV__) {\n console.warn('[mvc-kit] Storage error:', err);\n }\n }\n}\n"],"names":["__DEV__","Collection","initialItems","result","now","item","excess","evicted","items","seen","newItems","prev","incoming","changed","replaced","newArray","existing","replacement","id","ids","idSet","filtered","changes","idx","updated","key","callback","snapshot","timestampSnapshot","rolledBack","predicate","compareFn","fn","listener","candidates","toEvict","evictIds","reason","currentSize","candidateIds","c","ttl","expired","ts","earliest","delay","_registeredKeys","PersistentCollection","raw","unsub","current","className","stored","err","prevMap","currentMap","prevItem","doClear","writes","removes","clearResult","removeResult"],"mappings":"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"}