mvc-kit 2.10.1 → 2.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,19 +18,50 @@ You are an architecture planning agent for applications built with **mvc-kit**,
18
18
  | `EventBus<E>` | Typed pub/sub for cross-cutting events | Singleton |
19
19
  | `Channel<M>` | Persistent connection (WebSocket/SSE) with auto-reconnect | Singleton |
20
20
  | `Controller` | Stateless multi-ViewModel orchestrator (rare) | Component-scoped |
21
+ | `PersistentCollection<T>` | Collection + storage persistence (WebStorage, IndexedDB, RN) | Singleton |
22
+
23
+ ### Composable Helpers
24
+
25
+ | Helper | Role | Ownership |
26
+ |--------|------|-----------|
27
+ | `Sorting<T>` | Multi-column sort state + `apply()` pipeline | ViewModel property |
28
+ | `Pagination` | Page/pageSize state + `apply()` slicing | ViewModel property |
29
+ | `Selection<K>` | Key-based selection set with toggle/select-all | ViewModel property |
30
+ | `Feed<T>` | Cursor + hasMore + item accumulation for server pagination | ViewModel property |
31
+ | `Pending<K, Meta?>` | Per-item operation queue with retry + status tracking | Resource property |
21
32
 
22
33
  ## Decision Framework
23
34
 
24
35
  ### Which class?
25
36
  1. Holds UI state for a component? → **ViewModel**
26
37
  2. Single entity with validation? → **Model**
27
- 3. List of entities with CRUD? → **Collection**
38
+ 3. List of entities with CRUD (no API calls)? → **Collection**
28
39
  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
40
+ 5. Need to persist Collection to storage? → **PersistentCollection** (WebStorage, IndexedDB, or NativeCollection)
41
+ 6. Wraps raw `fetch()` with error handling? → **Service** (skip if you have a typed API client)
42
+ 7. Broadcasts cross-cutting events? → **EventBus**
43
+ 8. Persistent connection? → **Channel**
44
+ 9. Coordinates multiple ViewModels?**Controller** (rare)
45
+ 10. None of the above → plain utility function
46
+
47
+ ### Collection vs Resource vs Collection + Service?
48
+ - **Collection alone** — in-memory reactive list with no server interaction (e.g., local cart, selected items)
49
+ - **Resource** — replaces the Collection + Service + manual cache check pattern. Preferred when you need a shared data cache with API loading. Each async method gets independent tracking.
50
+ - **Collection + separate Service** — only when the Collection is shared across multiple Resources or a Channel, or when you need the Service for composing calls, retries, or auth management
51
+ - **Resource + external Collection** — when Resource and Channel feed the same store: `super(singleton(SharedCollection))`
52
+
53
+ ### PersistentCollection vs Collection?
54
+ - Need data to survive page refresh? → **PersistentCollection**
55
+ - High-churn data (search results, real-time feeds)? → **Collection** (persistence adds I/O overhead)
56
+ - Entity caches, settings, cart? → **PersistentCollection**
57
+ - Which adapter? localStorage (auto-hydrate, blocking) → **WebStorageCollection**. Large datasets → **IndexedDBCollection**. React Native → **NativeCollection**.
58
+
59
+ ### Which composable helper?
60
+ - Need sortable columns? → **Sorting\<T\>** (ViewModel property, auto-tracked)
61
+ - Need client-side paging? → **Pagination** (ViewModel property, auto-tracked)
62
+ - Need multi-select? → **Selection\<K\>** (ViewModel property, auto-tracked)
63
+ - Need cursor-based server pagination? → **Feed\<T\>** (ViewModel property, auto-tracked)
64
+ - Need per-item retry queue? → **Pending\<K, Meta?\>** (Resource property, survives component unmount)
34
65
 
35
66
  ### Which sharing pattern?
36
67
  - "Can the parent own one ViewModel and pass props?" → **Pattern A** (default)
@@ -8,12 +8,12 @@ import { ViewModel, Model, Collection, PersistentCollection, Resource, Controlle
8
8
  import { Sorting, Pagination, Selection, Feed, Pending } from 'mvc-kit';
9
9
  import { singleton, hasSingleton, teardown, teardownAll } from 'mvc-kit';
10
10
  import { HttpError, isAbortError, classifyError } from 'mvc-kit';
11
- import type { Subscribable, Disposable, Initializable, Listener, Updater, ValidationErrors, TaskState, AppError, AsyncMethodKeys, ResourceAsyncMethodKeys, ChannelStatus } from 'mvc-kit';
11
+ import type { Subscribable, Disposable, Initializable, Listener, Updater, ValidationErrors, TaskState, EventSource, EventPayload, AppError, AsyncMethodKeys, ResourceAsyncMethodKeys, ChannelStatus, SortDescriptor, FeedPage, PendingOperation, PendingEntry } from 'mvc-kit';
12
12
 
13
13
  // React hooks and headless components
14
14
  import { useLocal, useSingleton, useInstance, useModel, useModelRef, useField, useEvent, useEmit, useResolve, useTeardown, Provider } from 'mvc-kit/react';
15
15
  import { DataTable, CardList, InfiniteScroll } from 'mvc-kit/react';
16
- import type { StateOf, ItemOf, SingletonClass, ProviderRegistry, ModelHandle, FieldHandle, ProviderProps } from 'mvc-kit/react';
16
+ import type { StateOf, ItemOf, SingletonClass, ProviderRegistry, ModelHandle, FieldHandle, ProviderProps, Column, SortHeaderProps, SelectionState, SelectionHelper, PaginationState, PaginationHelper, PaginationInfo, SortingHelper, AsyncStateProps, DataTableProps, CardListProps, InfiniteScrollProps } from 'mvc-kit/react';
17
17
 
18
18
  // Web storage adapters
19
19
  import { WebStorageCollection, IndexedDBCollection } from 'mvc-kit/web';
@@ -558,6 +558,7 @@ useTeardown(UserViewModel, CartViewModel);
558
558
  ## Interfaces
559
559
 
560
560
  ```typescript
561
+ // Core interfaces
561
562
  interface Subscribable<S> { state: S; subscribe(listener: Listener<S>): () => void; dispose(): void; disposeSignal: AbortSignal; }
562
563
  interface Disposable { disposed: boolean; disposeSignal: AbortSignal; dispose(): void; }
563
564
  interface Initializable { initialized: boolean; init(): void; }
@@ -565,6 +566,27 @@ type Listener<S> = (state: S, prev: S) => void;
565
566
  type Updater<S> = (prev: S) => Partial<S>;
566
567
  type ValidationErrors<S> = Partial<Record<keyof S, string>>;
567
568
  interface TaskState { readonly loading: boolean; readonly error: string | null; readonly errorCode: string | null; }
569
+
570
+ // Event types
571
+ type EventSource = Record<string, any>;
572
+ type EventPayload<E extends EventSource, K extends keyof E> = E[K];
573
+
574
+ // Helper types
575
+ interface SortDescriptor { key: string; direction: 'asc' | 'desc'; }
576
+ interface FeedPage<T> { items: T[]; cursor: string | null; hasMore: boolean; }
577
+ interface PendingOperation<Meta = unknown> { status: 'active' | 'retrying' | 'failed'; attempts: number; error: unknown | null; meta: Meta | null; }
578
+ interface PendingEntry<K = string, Meta = unknown> extends PendingOperation<Meta> { readonly id: K; }
579
+
580
+ // React component types (duck-typed interfaces for DataTable helper pass-through)
581
+ interface Column<T> { key: string; header: ReactNode; render: (item: T, index: number) => ReactNode; sortable?: boolean; width?: string; align?: 'left' | 'center' | 'right'; }
582
+ interface SortHeaderProps { active: boolean; direction: 'asc' | 'desc'; index: number; onToggle: () => void; }
583
+ interface SelectionState<K = string | number> { selected: ReadonlySet<K>; onToggle: (key: K) => void; onToggleAll: (allKeys: K[]) => void; }
584
+ interface SelectionHelper { readonly selected: ReadonlySet<any>; toggle(key: any): void; toggleAll(allKeys: any[]): void; }
585
+ interface PaginationState { page: number; total: number; onPageChange: (page: number) => void; }
586
+ interface PaginationHelper { readonly page: number; readonly pageSize: number; setPage(page: number): void; }
587
+ interface PaginationInfo { page: number; pageCount: number; total: number; pageSize: number; hasPrev: boolean; hasNext: boolean; goToPage: (p: number) => void; goPrev: () => void; goNext: () => void; }
588
+ interface SortingHelper { readonly sorts: readonly SortDescriptor[]; toggle(key: string): void; }
589
+ interface AsyncStateProps { loading?: boolean; error?: string | null; }
568
590
  ```
569
591
 
570
592
  ## Dev Mode (`__MVC_KIT_DEV__`)
@@ -495,6 +495,18 @@ class CartViewModel extends ViewModel<CartState> {
495
495
  const [state, vm] = useSingleton(CartViewModel);
496
496
  ```
497
497
 
498
+ **Convenience hooks** (optional): For singletons used in many components, co-export a hook from the ViewModel file:
499
+ ```typescript
500
+ // viewmodels/AuthViewModel.ts
501
+ export class AuthViewModel extends ViewModel<AuthState> { ... }
502
+ export const useAuth = () => useSingleton(AuthViewModel);
503
+
504
+ // components/Header.tsx — single import
505
+ import { useAuth } from '../viewmodels/AuthViewModel';
506
+ const [state, vm] = useAuth();
507
+ ```
508
+ Use sparingly (2–3 app-wide singletons). The class stays framework-agnostic.
509
+
498
510
  **Pattern C — Shared Collection** (different views of same data):
499
511
  ```tsx
500
512
  // UsersTable uses UsersViewModel → subscribes to UsersCollection
@@ -561,6 +573,155 @@ test('filtered getter applies search', () => {
561
573
 
562
574
  ---
563
575
 
576
+ ## useInstance Pattern
577
+
578
+ Subscribe to an existing ViewModel or Subscribable without managing its lifecycle. Useful when a parent passes a ViewModel as a prop to a child that needs reactive updates.
579
+
580
+ ```tsx
581
+ function StatusIndicator({ vm }: { vm: DashboardViewModel }) {
582
+ const state = useInstance(vm);
583
+ return <span>{state.status}</span>;
584
+ }
585
+ ```
586
+
587
+ No init/dispose — the caller manages the instance lifecycle. For singletons, prefer `useSingleton` which handles both lifecycle and subscription.
588
+
589
+ ---
590
+
591
+ ## useEmit Pattern
592
+
593
+ Get a stable emit function for an EventBus. Useful in components that only emit events without subscribing. The bus typically comes from a ViewModel property or `useResolve`.
594
+
595
+ ```tsx
596
+ function QuickAction({ bus, itemId }: { bus: AppEventBus; itemId: string }) {
597
+ const emit = useEmit(bus);
598
+
599
+ return (
600
+ <button onClick={() => emit('item:selected', { id: itemId })}>
601
+ Select
602
+ </button>
603
+ );
604
+ }
605
+ ```
606
+
607
+ ---
608
+
609
+ ## useModelRef + useField Pattern
610
+
611
+ For large forms, `useModelRef` avoids re-rendering the parent on every keystroke. Each `useField` subscribes to a single field — only that input re-renders.
612
+
613
+ ```tsx
614
+ function ProfileForm() {
615
+ const model = useModelRef(() => new ProfileModel({ name: '', email: '', bio: '' }));
616
+
617
+ return (
618
+ <form onSubmit={() => model.commit()}>
619
+ <NameField model={model} />
620
+ <EmailField model={model} />
621
+ <BioField model={model} />
622
+ <button disabled={!model.valid}>Save</button>
623
+ </form>
624
+ );
625
+ }
626
+
627
+ function NameField({ model }: { model: ProfileModel }) {
628
+ const { value, error, set } = useField(model, 'name');
629
+ return (
630
+ <div>
631
+ <input value={value} onChange={e => set(e.target.value)} />
632
+ {error && <span className="error">{error}</span>}
633
+ </div>
634
+ );
635
+ }
636
+ ```
637
+
638
+ `useModelRef` returns the model directly (no `[state, model]` tuple) — it manages lifecycle (init/dispose) but doesn't subscribe. The parent never re-renders from model changes.
639
+
640
+ ---
641
+
642
+ ## useTeardown Pattern
643
+
644
+ Teardown singletons when a route or page unmounts. Frees resources for ViewModels that are no longer needed.
645
+
646
+ ```tsx
647
+ function AdminPage() {
648
+ useTeardown(AdminViewModel, AdminStatsResource);
649
+
650
+ const [state, vm] = useSingleton(AdminViewModel);
651
+ return <AdminDashboard state={state} vm={vm} />;
652
+ }
653
+ ```
654
+
655
+ When `AdminPage` unmounts, both singletons are disposed and removed from the registry. Next mount creates fresh instances.
656
+
657
+ ---
658
+
659
+ ## Controller Pattern
660
+
661
+ Stateless orchestrator for coordinating shared singletons (Resources, Collections, EventBus). Use when cross-cutting logic doesn't belong to any single ViewModel.
662
+
663
+ ```typescript
664
+ class DashboardController extends Controller {
665
+ private ordersResource = singleton(OrdersResource);
666
+ private notificationsResource = singleton(NotificationsResource);
667
+ private bus = singleton(AppEventBus);
668
+
669
+ protected onInit() {
670
+ this.listenTo(this.bus, 'order:completed', ({ orderId }) => {
671
+ this.ordersResource.reload();
672
+ this.notificationsResource.addNotification(`Order ${orderId} completed`);
673
+ });
674
+ }
675
+ }
676
+ ```
677
+
678
+ ```tsx
679
+ function DashboardPage() {
680
+ useLocal(() => new DashboardController());
681
+ return (
682
+ <div>
683
+ <OrdersPanel />
684
+ <NotificationsPanel />
685
+ </div>
686
+ );
687
+ }
688
+ ```
689
+
690
+ Controllers are rare — prefer parent ViewModel with props (Pattern A) when possible.
691
+
692
+ ---
693
+
694
+ ## PersistentCollection Lifecycle
695
+
696
+ ```typescript
697
+ // WebStorageCollection: auto-hydrates (synchronous localStorage)
698
+ class CartCollection extends WebStorageCollection<CartItem> {
699
+ protected readonly storageKey = 'cart';
700
+ }
701
+
702
+ // ViewModel — no hydrate() needed
703
+ protected onInit() {
704
+ if (this.collection.length === 0) this.loadFromServer();
705
+ }
706
+ ```
707
+
708
+ ```typescript
709
+ // IndexedDBCollection: requires async hydrate()
710
+ class MessagesCollection extends IndexedDBCollection<Message> {
711
+ protected readonly storageKey = 'messages';
712
+ }
713
+
714
+ // ViewModel — must hydrate before accessing data
715
+ protected async onInit() {
716
+ await this.collection.hydrate();
717
+ if (this.collection.length === 0) this.loadFromServer();
718
+ }
719
+ ```
720
+
721
+ Mutations persist automatically after hydration. Override `serialize()`/`deserialize()` for custom encoding. Override `onPersistError()` for error handling.
722
+
723
+ ---
724
+
564
725
  ## Error Handling Layers
565
726
 
566
727
  1. **Async tracking** (automatic) — write happy path, read `vm.async.method.error`
@@ -75,6 +75,73 @@
75
75
 
76
76
  ---
77
77
 
78
+ ## Resource Checks (6)
79
+
80
+ ### Critical
81
+ 1. **Thin subclass** — Resource should define async methods for CRUD. Use inherited Collection mutations (`reset`, `upsert`, `add`, `update`, `remove`) — don't re-implement them.
82
+
83
+ ### Warning
84
+ 2. **No pass-through Service** — If wrapping a typed API client (RPC, tRPC, GraphQL codegen) with zero added value, call it directly from Resource.
85
+ 3. **`onInit()` for initial load** — Resource should load data in `onInit()` with smart-init pattern (`if (this.length === 0)`).
86
+ 4. **Pending on Resource, not ViewModel** — `Pending` helper must live on the singleton Resource so operations survive component unmount.
87
+ 5. **Dispose owned helpers** — If Resource owns a `Pending`, dispose it in `onDispose()`.
88
+
89
+ ### Suggestion
90
+ 6. **External Collection injection** — When Resource + Channel feed the same data store, inject a shared Collection via `super(singleton(SharedCollection))`.
91
+
92
+ ---
93
+
94
+ ## Model Checks (4)
95
+
96
+ ### Critical
97
+ 1. **Validation in `validate()`** — All validation logic belongs in the `validate(state)` override, not in setters or external code.
98
+
99
+ ### Warning
100
+ 2. **Field-level errors** — `validate()` should return `Partial<Record<keyof S, string>>` with per-field messages, not a single error string.
101
+ 3. **Dirty tracking** — Use `commit()` to set baseline after save, `rollback()` to revert. Don't manually track dirty state in a separate field.
102
+
103
+ ### Suggestion
104
+ 4. **`useModelRef` + `useField` for large forms** — For forms with many fields, use `useModelRef` (no subscription) with `useField` per input for surgical re-renders.
105
+
106
+ ---
107
+
108
+ ## EventBus Checks (4)
109
+
110
+ ### Critical
111
+ 1. **Typed event map** — EventBus must have a typed generic `EventBus<E>` with explicit event names and payloads.
112
+ 2. **Use `listenTo()` over `addCleanup(bus.on(...))`** — `listenTo` is reset-safe and auto-cleans up on dispose.
113
+
114
+ ### Warning
115
+ 3. **No state in EventBus** — EventBus is for fire-and-forget signals (toasts, navigation, analytics). State belongs in ViewModel or Collection.
116
+ 4. **Prefer ViewModel events for component-scoped signals** — Use the second generic `ViewModel<S, E>` for events that belong to a single ViewModel. Use EventBus only for cross-cutting, app-wide events.
117
+
118
+ ---
119
+
120
+ ## Channel Checks (4)
121
+
122
+ ### Critical
123
+ 1. **Implement `connect()` and `disconnect()`** — Channel requires abstract method overrides for connection management.
124
+ 2. **Use `listenTo()` for subscriptions** — ViewModel should use `listenTo(channel, event, handler)`, not `addCleanup(channel.on(...))`.
125
+
126
+ ### Warning
127
+ 3. **Auto-reconnect config** — Review static overrides (`RECONNECT_DELAY`, `MAX_RECONNECT_DELAY`, `MAX_RECONNECT_ATTEMPTS`) for production suitability.
128
+ 4. **`pipeChannel()` for Collection bridging** — When a Channel event should upsert into a Collection, use `pipeChannel(channel, event, collection)` instead of manual `listenTo` + `upsert`.
129
+
130
+ ---
131
+
132
+ ## Composable Helper Checks (5)
133
+
134
+ ### Critical
135
+ 1. **Correct ownership** — Sorting, Pagination, Selection belong on ViewModel. Pending belongs on singleton Resource (survives unmount).
136
+ 2. **No `init()` on helpers** — Helpers are plain classes with `subscribe()`. They don't need lifecycle management.
137
+
138
+ ### Warning
139
+ 3. **`apply()` in getters** — Helpers should be composed via `apply()` in getter pipelines: `pagination.apply(sorting.apply(filtered))`.
140
+ 4. **Feed uses `setResult()` with Resource** — When items live in a Resource, Feed tracks only cursor/hasMore via `setResult()`. Use `appendPage()` only for component-scoped item accumulation.
141
+ 5. **No `disposeSignal` in Pending execute** — Pending's `enqueue` callback receives its own signal. Do not pass `vm.disposeSignal` (would abort on component unmount).
142
+
143
+ ---
144
+
78
145
  ## Test Checks (5)
79
146
 
80
147
  ### Critical
@@ -7,7 +7,7 @@ invocable_by:
7
7
  user_instructions: |
8
8
  Usage: /mvc-kit:scaffold <type> <Name>
9
9
 
10
- Types: viewmodel, model, collection, service, eventbus, channel, controller, page-component
10
+ Types: viewmodel, model, collection, persistent-collection, resource, service, eventbus, channel, controller, page-component
11
11
 
12
12
  Examples:
13
13
  /mvc-kit:scaffold viewmodel OrderList
@@ -34,6 +34,8 @@ Parse `$ARGUMENTS` as `<type> <Name>` (case-insensitive type, PascalCase Name).
34
34
  | `viewmodel` | `templates/viewmodel.md` | `{{Name}}ViewModel.ts`, `{{Name}}ViewModel.test.ts` |
35
35
  | `model` | `templates/model.md` | `{{Name}}Model.ts`, `{{Name}}Model.test.ts` |
36
36
  | `collection` | `templates/collection.md` | `{{Name}}Collection.ts` |
37
+ | `persistent-collection` | `templates/persistent-collection.md` | `{{Name}}Collection.ts` |
38
+ | `resource` | `templates/resource.md` | `{{Name}}Resource.ts`, `{{Name}}Resource.test.ts` |
37
39
  | `service` | `templates/service.md` | `{{Name}}Service.ts`, `{{Name}}Service.test.ts` |
38
40
  | `eventbus` | `templates/eventbus.md` | `{{Name}}EventBus.ts` |
39
41
  | `channel` | `templates/channel.md` | `{{Name}}Channel.ts` |
@@ -45,7 +47,8 @@ Parse `$ARGUMENTS` as `<type> <Name>` (case-insensitive type, PascalCase Name).
45
47
  - Follow the ViewModel section order: Private fields → Computed getters → Lifecycle → Actions → Setters.
46
48
  - State interfaces hold only source-of-truth values. No derived values, no loading/error flags.
47
49
  - Services are stateless, accept `AbortSignal`, throw `HttpError`.
48
- - Collections are thin subclasses — no custom methods.
50
+ - Collections are thin subclasses — no custom methods. PersistentCollection subclasses set `storageKey` and optional static config.
51
+ - Resources are thin subclasses with async methods for CRUD — use inherited Collection mutations.
49
52
  - Use `singleton()` for dependency resolution in ViewModels.
50
53
  - Getters read from collections directly — auto-tracking handles reactivity. Use `subscribeTo()` only for imperative side effects.
51
54
  - Pass `this.disposeSignal` to every async call.
@@ -0,0 +1,111 @@
1
+ # PersistentCollection Template: {{Name}}
2
+
3
+ Ask the user which storage adapter to use if not specified:
4
+ - **WebStorageCollection** (`mvc-kit/web`) — localStorage/sessionStorage, auto-hydrates
5
+ - **IndexedDBCollection** (`mvc-kit/web`) — IndexedDB per-item storage, requires `hydrate()`
6
+ - **NativeCollection** (`mvc-kit/react-native`) — configurable async backend, requires `hydrate()`
7
+
8
+ ## {{Name}}Collection.ts
9
+
10
+ ### WebStorageCollection variant
11
+
12
+ ```typescript
13
+ import { WebStorageCollection } from 'mvc-kit/web';
14
+
15
+ export interface {{Name}}Item {
16
+ id: string;
17
+ // TODO: Add your fields
18
+ }
19
+
20
+ export class {{Name}}Collection extends WebStorageCollection<{{Name}}Item> {
21
+ protected readonly storageKey = 'TODO_set_storage_key';
22
+
23
+ // Override for custom storage type (default: 'local')
24
+ // static STORAGE: 'local' | 'session' = 'session';
25
+
26
+ // Override for debounced writes (default: 0 = immediate)
27
+ // static WRITE_DELAY = 300;
28
+ }
29
+ ```
30
+
31
+ ### IndexedDBCollection variant
32
+
33
+ ```typescript
34
+ import { IndexedDBCollection } from 'mvc-kit/web';
35
+
36
+ export interface {{Name}}Item {
37
+ id: string;
38
+ // TODO: Add your fields
39
+ }
40
+
41
+ export class {{Name}}Collection extends IndexedDBCollection<{{Name}}Item> {
42
+ protected readonly storageKey = 'TODO_set_storage_key';
43
+
44
+ // Override for custom database name (default: 'mvc-kit')
45
+ // static DB_NAME = 'my-app';
46
+
47
+ // Override for debounced writes (default: 0 = immediate)
48
+ // static WRITE_DELAY = 300;
49
+ }
50
+ ```
51
+
52
+ ### NativeCollection variant
53
+
54
+ ```typescript
55
+ import { NativeCollection } from 'mvc-kit/react-native';
56
+
57
+ export interface {{Name}}Item {
58
+ id: string;
59
+ // TODO: Add your fields
60
+ }
61
+
62
+ export class {{Name}}Collection extends NativeCollection<{{Name}}Item> {
63
+ protected readonly storageKey = 'TODO_set_storage_key';
64
+
65
+ // Override for debounced writes (default: 0 = immediate)
66
+ // static WRITE_DELAY = 300;
67
+ }
68
+ ```
69
+
70
+ ## Rules
71
+
72
+ - **Generate only the variant the user selected** — do not output all three.
73
+ - **Thin subclass** — only set `storageKey` and optional static config. No custom methods.
74
+ - **Persist entity caches** — users, todos, settings, cart. NOT ephemeral UI state (search results, selections).
75
+ - **WebStorageCollection auto-hydrates** — no `hydrate()` call needed. localStorage blocks main thread, so avoid high-churn data.
76
+ - **IndexedDB/Native require `hydrate()`** — call `await collection.hydrate()` in ViewModel `onInit()` before accessing data.
77
+ - **Serialization hooks** — override `serialize(items)` and `deserialize(raw)` for custom encoding (default: JSON).
78
+ - **Error hook** — override `onPersistError(error)` for custom error handling.
79
+
80
+ ## Usage in a ViewModel
81
+
82
+ ```typescript
83
+ import { ViewModel, singleton } from 'mvc-kit';
84
+ import { {{Name}}Collection, {{Name}}Item } from '../collections/{{Name}}Collection';
85
+
86
+ class {{Name}}ViewModel extends ViewModel<{ filter: string }> {
87
+ private collection = singleton({{Name}}Collection);
88
+
89
+ get items(): {{Name}}Item[] {
90
+ return this.collection.items as {{Name}}Item[];
91
+ }
92
+
93
+ // Required for async adapters (IndexedDB, NativeCollection):
94
+ protected async onInit() {
95
+ await this.collection.hydrate();
96
+ if (this.collection.length === 0) this.load();
97
+ }
98
+
99
+ // For WebStorageCollection, hydrate() is not needed:
100
+ // protected onInit() {
101
+ // if (this.collection.length === 0) this.load();
102
+ // }
103
+
104
+ async load() {
105
+ // const data = await this.service.getAll(this.disposeSignal);
106
+ // this.collection.reset(data);
107
+ }
108
+
109
+ setFilter(filter: string) { this.set({ filter }); }
110
+ }
111
+ ```
@@ -0,0 +1,129 @@
1
+ # Resource Template: {{Name}}
2
+
3
+ ## {{Name}}Resource.ts
4
+
5
+ ```typescript
6
+ import { Resource, singleton, HttpError } from 'mvc-kit';
7
+ // import { {{Name}}Service } from '../services/{{Name}}Service';
8
+
9
+ export interface {{Name}}Item {
10
+ id: string;
11
+ // TODO: Add your fields
12
+ }
13
+
14
+ export class {{Name}}Resource extends Resource<{{Name}}Item> {
15
+ // private api = singleton({{Name}}Service);
16
+
17
+ // --- Lifecycle ---
18
+ protected onInit() {
19
+ if (this.length === 0) this.loadAll();
20
+ }
21
+
22
+ // --- Actions ---
23
+ async loadAll() {
24
+ // const data = await this.api.getAll(this.disposeSignal);
25
+ // this.reset(data);
26
+ // TODO: implement data loading
27
+ }
28
+
29
+ async loadById(id: string) {
30
+ // const item = await this.api.getById(id, this.disposeSignal);
31
+ // this.upsert(item);
32
+ // TODO: implement single-item loading
33
+ }
34
+
35
+ async save(item: Omit<{{Name}}Item, 'id'>) {
36
+ // const created = await this.api.create(item, this.disposeSignal);
37
+ // this.add(created);
38
+ // return created;
39
+ // TODO: implement create
40
+ }
41
+
42
+ async deleteItem(id: string) {
43
+ // this.optimistic(() => this.remove(id));
44
+ // await this.api.delete(id, this.disposeSignal);
45
+ // TODO: implement delete
46
+ }
47
+
48
+ protected override onDispose() {
49
+ // Dispose any owned helpers (e.g., Pending) here
50
+ }
51
+ }
52
+ ```
53
+
54
+ ## {{Name}}Resource.test.ts
55
+
56
+ ```typescript
57
+ import { describe, test, expect, beforeEach } from 'vitest';
58
+ import { teardownAll, singleton } from 'mvc-kit';
59
+ import { {{Name}}Resource } from './{{Name}}Resource';
60
+
61
+ beforeEach(() => teardownAll());
62
+
63
+ describe('{{Name}}Resource', () => {
64
+ test('starts empty', () => {
65
+ const resource = singleton({{Name}}Resource);
66
+ expect(resource.length).toBe(0);
67
+ expect(resource.items).toEqual([]);
68
+ });
69
+
70
+ test('loadAll populates items', async () => {
71
+ // TODO: set up API mocks BEFORE init() — onInit() triggers loadAll()
72
+ // vi.spyOn(service, 'getAll').mockResolvedValue([{ id: '1' }, { id: '2' }]);
73
+
74
+ const resource = singleton({{Name}}Resource);
75
+ resource.init();
76
+
77
+ // await vi.waitFor(() => expect(resource.items).toHaveLength(2));
78
+ });
79
+
80
+ test('async tracking reports loading state', async () => {
81
+ const resource = singleton({{Name}}Resource);
82
+ resource.init();
83
+
84
+ expect(resource.async.loadAll.loading).toBe(false);
85
+ expect(resource.async.loadAll.error).toBeNull();
86
+ });
87
+ });
88
+ ```
89
+
90
+ ## Rules
91
+
92
+ - **Singleton** — access via `singleton({{Name}}Resource)` from ViewModels
93
+ - **Thin subclass** — define async methods for CRUD; use inherited Collection mutations (`reset`, `upsert`, `add`, `update`, `remove`)
94
+ - **Eliminates boilerplate** — replaces empty Collection subclass + Service + manual cache check pattern
95
+ - **Async tracking** — each method gets independent `resource.async.methodName` tracking after `init()`
96
+ - **Skip Service if you have a typed API client** — call RPC/tRPC/GraphQL codegen directly from Resource
97
+ - **Use Service only** when wrapping raw `fetch()` with `HttpError`, composing calls, or managing auth/retries
98
+ - **External Collection injection** — pass a shared Collection to the constructor when Resource + Channel feed the same store
99
+
100
+ ## Usage in a ViewModel
101
+
102
+ ```typescript
103
+ import { ViewModel, singleton } from 'mvc-kit';
104
+ import { {{Name}}Resource, {{Name}}Item } from '../resources/{{Name}}Resource';
105
+
106
+ class {{Name}}ViewModel extends ViewModel<{ search: string }> {
107
+ private resource = singleton({{Name}}Resource);
108
+
109
+ // Getter reads from resource — auto-tracked
110
+ get items(): {{Name}}Item[] {
111
+ return this.resource.items as {{Name}}Item[];
112
+ }
113
+
114
+ get filtered(): {{Name}}Item[] {
115
+ const { search } = this.state;
116
+ if (!search) return this.items;
117
+ const q = search.toLowerCase();
118
+ return this.items.filter(item =>
119
+ JSON.stringify(item).toLowerCase().includes(q),
120
+ );
121
+ }
122
+
123
+ protected onInit() {
124
+ // Resource handles its own init via singleton — just read from it
125
+ }
126
+
127
+ setSearch(search: string) { this.set({ search }); }
128
+ }
129
+ ```
@@ -213,7 +213,7 @@ class UsersListVM extends ViewModel<FilterState> {
213
213
  ## Sharing Patterns
214
214
 
215
215
  1. **Pattern A** (default): Parent ViewModel passes props to presentational children
216
- 2. **Pattern B**: Singleton ViewModel via `useSingleton` for app-wide state (define `static DEFAULT_STATE` for arg-free calls)
216
+ 2. **Pattern B**: Singleton ViewModel via `useSingleton` for app-wide state (define `static DEFAULT_STATE` for arg-free calls). For singletons used in many components, optionally co-export a convenience hook: `export const useAuth = () => useSingleton(AuthViewModel);` -- keeps the class framework-agnostic while reducing imports at call sites. Use sparingly (2--3 app-wide singletons).
217
217
  3. **Pattern C**: Separate ViewModels sharing a singleton Collection
218
218
 
219
219
  ## ViewModel Events
@@ -289,18 +289,45 @@ test('example', () => {
289
289
  - `addCleanup` for `channel.on()`/`bus.on()` subscriptions → use `listenTo()` (auto-cleanup on dispose and reset)
290
290
  - Missing `hydrate()` for async adapters (IndexedDB, NativeCollection) → call `hydrate()` in `onInit()` before accessing data
291
291
 
292
+ ## Resource Pattern
293
+
294
+ ```typescript
295
+ class UsersResource extends Resource<User> {
296
+ private api = singleton(UserService);
297
+
298
+ async loadAll() {
299
+ const data = await this.api.getAll(this.disposeSignal);
300
+ this.reset(data);
301
+ }
302
+ }
303
+ // ViewModel reads from Resource via getter -- auto-tracked
304
+ class UsersVM extends ViewModel<{ search: string }> {
305
+ private users = singleton(UsersResource);
306
+ get items() { return this.users.items as User[]; }
307
+ }
308
+ ```
309
+
310
+ **Resource vs Collection + Service:** Resource eliminates boilerplate (empty Collection subclass + Service + manual cache check). Use Resource when you need a shared data cache with API loading. Use Collection + Service only when the Collection is shared across multiple Resources/Channels.
311
+
312
+ ## PersistentCollection Lifecycle
313
+
314
+ - **WebStorageCollection** -- auto-hydrates, no `hydrate()` needed. Avoid for high-churn data.
315
+ - **IndexedDBCollection / NativeCollection** -- require `await collection.hydrate()` in ViewModel `onInit()` before accessing data.
316
+
292
317
  ## Decision Framework
293
318
 
294
319
  - Holds UI state for a component → **ViewModel**
295
320
  - Single entity with validation → **Model**
296
- - List of entities with CRUD → **Collection**
321
+ - List of entities with CRUD (no API) → **Collection**
322
+ - List + API loading + async tracking → **Resource** (replaces Collection + Service + cache check)
323
+ - Need persistence across page refresh → **PersistentCollection** (WebStorage, IndexedDB, NativeCollection)
297
324
  - Wraps raw `fetch()` with error handling → **Service** (skip if you have a typed API client)
298
325
  - Cross-cutting events → **EventBus**
299
326
  - Persistent connection → **Channel**
300
327
  - Coordinates multiple ViewModels → **Controller** (rare)
301
328
  - Sort/paginate/select on a list → **Sorting/Pagination/Selection** helpers
302
329
  - Cursor-based server pagination → **Feed** helper
303
- - Per-item operation retry with status → **Pending** helper (on Resource)
330
+ - Per-item operation retry with status → **Pending** helper (on Resource, not ViewModel)
304
331
  - Unstyled table/list/infinite scroll → **DataTable/CardList/InfiniteScroll** components
305
332
 
306
333
  ## Dev Mode
@@ -213,7 +213,7 @@ class UsersListVM extends ViewModel<FilterState> {
213
213
  ## Sharing Patterns
214
214
 
215
215
  1. **Pattern A** (default): Parent ViewModel passes props to presentational children
216
- 2. **Pattern B**: Singleton ViewModel via `useSingleton` for app-wide state (define `static DEFAULT_STATE` for arg-free calls)
216
+ 2. **Pattern B**: Singleton ViewModel via `useSingleton` for app-wide state (define `static DEFAULT_STATE` for arg-free calls). For singletons used in many components, optionally co-export a convenience hook: `export const useAuth = () => useSingleton(AuthViewModel);` — keeps the class framework-agnostic while reducing imports at call sites. Use sparingly (2–3 app-wide singletons).
217
217
  3. **Pattern C**: Separate ViewModels sharing a singleton Collection
218
218
 
219
219
  ## ViewModel Events
@@ -289,18 +289,45 @@ test('example', () => {
289
289
  - `addCleanup` for `channel.on()`/`bus.on()` subscriptions → use `listenTo()` (auto-cleanup on dispose and reset)
290
290
  - Missing `hydrate()` for async adapters (IndexedDB, NativeCollection) → call `hydrate()` in `onInit()` before accessing data
291
291
 
292
+ ## Resource Pattern
293
+
294
+ ```typescript
295
+ class UsersResource extends Resource<User> {
296
+ private api = singleton(UserService);
297
+
298
+ async loadAll() {
299
+ const data = await this.api.getAll(this.disposeSignal);
300
+ this.reset(data);
301
+ }
302
+ }
303
+ // ViewModel reads from Resource via getter — auto-tracked
304
+ class UsersVM extends ViewModel<{ search: string }> {
305
+ private users = singleton(UsersResource);
306
+ get items() { return this.users.items as User[]; }
307
+ }
308
+ ```
309
+
310
+ **Resource vs Collection + Service:** Resource eliminates boilerplate (empty Collection subclass + Service + manual cache check). Use Resource when you need a shared data cache with API loading. Use Collection + Service only when the Collection is shared across multiple Resources/Channels.
311
+
312
+ ## PersistentCollection Lifecycle
313
+
314
+ - **WebStorageCollection** — auto-hydrates, no `hydrate()` needed. Avoid for high-churn data.
315
+ - **IndexedDBCollection / NativeCollection** — require `await collection.hydrate()` in ViewModel `onInit()` before accessing data.
316
+
292
317
  ## Decision Framework
293
318
 
294
319
  - Holds UI state for a component → **ViewModel**
295
320
  - Single entity with validation → **Model**
296
- - List of entities with CRUD → **Collection**
321
+ - List of entities with CRUD (no API) → **Collection**
322
+ - List + API loading + async tracking → **Resource** (replaces Collection + Service + cache check)
323
+ - Need persistence across page refresh → **PersistentCollection** (WebStorage, IndexedDB, NativeCollection)
297
324
  - Wraps raw `fetch()` with error handling → **Service** (skip if you have a typed API client)
298
325
  - Cross-cutting events → **EventBus**
299
326
  - Persistent connection → **Channel**
300
327
  - Coordinates multiple ViewModels → **Controller** (rare)
301
328
  - Sort/paginate/select on a list → **Sorting/Pagination/Selection** helpers
302
329
  - Cursor-based server pagination → **Feed** helper
303
- - Per-item operation retry with status → **Pending** helper (on Resource)
330
+ - Per-item operation retry with status → **Pending** helper (on Resource, not ViewModel)
304
331
  - Unstyled table/list/infinite scroll → **DataTable/CardList/InfiniteScroll** components
305
332
 
306
333
  ## Dev Mode
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mvc-kit",
3
- "version": "2.10.1",
3
+ "version": "2.11.1",
4
4
  "description": "Zero-magic, class-based reactive ViewModel library",
5
5
  "type": "module",
6
6
  "main": "./dist/mvc-kit.cjs",
@@ -11,6 +11,7 @@
11
11
  "dev:fullapp": "vite --config examples/react/FullApp/vite.config.ts",
12
12
  "dev:complexapp": "vite --config examples/react/ComplexApp/vite.config.ts",
13
13
  "dev:workerapp": "vite --config examples/react/WorkerApp/vite.config.ts",
14
+ "dev:authexample": "vite --config examples/react/AuthExample/vite.config.ts",
14
15
  "build": "rm -rf dist && tsc -p tsconfig.build.json && vite build",
15
16
  "postinstall": "node agent-config/bin/postinstall.mjs",
16
17
  "test": "vitest run",