mvc-kit 2.10.1 → 2.11.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.
- package/agent-config/claude-code/agents/mvc-kit-architect.md +37 -6
- package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
- package/agent-config/claude-code/skills/guide/patterns.md +149 -0
- package/agent-config/claude-code/skills/review/checklist.md +67 -0
- package/agent-config/claude-code/skills/scaffold/SKILL.md +5 -2
- package/agent-config/claude-code/skills/scaffold/templates/persistent-collection.md +111 -0
- package/agent-config/claude-code/skills/scaffold/templates/resource.md +129 -0
- package/agent-config/copilot/copilot-instructions.md +29 -2
- package/agent-config/cursor/cursorrules +29 -2
- package/package.json +1 -1
|
@@ -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.
|
|
30
|
-
6.
|
|
31
|
-
7.
|
|
32
|
-
8.
|
|
33
|
-
9.
|
|
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__`)
|
|
@@ -561,6 +561,155 @@ test('filtered getter applies search', () => {
|
|
|
561
561
|
|
|
562
562
|
---
|
|
563
563
|
|
|
564
|
+
## useInstance Pattern
|
|
565
|
+
|
|
566
|
+
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.
|
|
567
|
+
|
|
568
|
+
```tsx
|
|
569
|
+
function StatusIndicator({ vm }: { vm: DashboardViewModel }) {
|
|
570
|
+
const state = useInstance(vm);
|
|
571
|
+
return <span>{state.status}</span>;
|
|
572
|
+
}
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
No init/dispose — the caller manages the instance lifecycle. For singletons, prefer `useSingleton` which handles both lifecycle and subscription.
|
|
576
|
+
|
|
577
|
+
---
|
|
578
|
+
|
|
579
|
+
## useEmit Pattern
|
|
580
|
+
|
|
581
|
+
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`.
|
|
582
|
+
|
|
583
|
+
```tsx
|
|
584
|
+
function QuickAction({ bus, itemId }: { bus: AppEventBus; itemId: string }) {
|
|
585
|
+
const emit = useEmit(bus);
|
|
586
|
+
|
|
587
|
+
return (
|
|
588
|
+
<button onClick={() => emit('item:selected', { id: itemId })}>
|
|
589
|
+
Select
|
|
590
|
+
</button>
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
---
|
|
596
|
+
|
|
597
|
+
## useModelRef + useField Pattern
|
|
598
|
+
|
|
599
|
+
For large forms, `useModelRef` avoids re-rendering the parent on every keystroke. Each `useField` subscribes to a single field — only that input re-renders.
|
|
600
|
+
|
|
601
|
+
```tsx
|
|
602
|
+
function ProfileForm() {
|
|
603
|
+
const model = useModelRef(() => new ProfileModel({ name: '', email: '', bio: '' }));
|
|
604
|
+
|
|
605
|
+
return (
|
|
606
|
+
<form onSubmit={() => model.commit()}>
|
|
607
|
+
<NameField model={model} />
|
|
608
|
+
<EmailField model={model} />
|
|
609
|
+
<BioField model={model} />
|
|
610
|
+
<button disabled={!model.valid}>Save</button>
|
|
611
|
+
</form>
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function NameField({ model }: { model: ProfileModel }) {
|
|
616
|
+
const { value, error, set } = useField(model, 'name');
|
|
617
|
+
return (
|
|
618
|
+
<div>
|
|
619
|
+
<input value={value} onChange={e => set(e.target.value)} />
|
|
620
|
+
{error && <span className="error">{error}</span>}
|
|
621
|
+
</div>
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
`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.
|
|
627
|
+
|
|
628
|
+
---
|
|
629
|
+
|
|
630
|
+
## useTeardown Pattern
|
|
631
|
+
|
|
632
|
+
Teardown singletons when a route or page unmounts. Frees resources for ViewModels that are no longer needed.
|
|
633
|
+
|
|
634
|
+
```tsx
|
|
635
|
+
function AdminPage() {
|
|
636
|
+
useTeardown(AdminViewModel, AdminStatsResource);
|
|
637
|
+
|
|
638
|
+
const [state, vm] = useSingleton(AdminViewModel);
|
|
639
|
+
return <AdminDashboard state={state} vm={vm} />;
|
|
640
|
+
}
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
When `AdminPage` unmounts, both singletons are disposed and removed from the registry. Next mount creates fresh instances.
|
|
644
|
+
|
|
645
|
+
---
|
|
646
|
+
|
|
647
|
+
## Controller Pattern
|
|
648
|
+
|
|
649
|
+
Stateless orchestrator for coordinating shared singletons (Resources, Collections, EventBus). Use when cross-cutting logic doesn't belong to any single ViewModel.
|
|
650
|
+
|
|
651
|
+
```typescript
|
|
652
|
+
class DashboardController extends Controller {
|
|
653
|
+
private ordersResource = singleton(OrdersResource);
|
|
654
|
+
private notificationsResource = singleton(NotificationsResource);
|
|
655
|
+
private bus = singleton(AppEventBus);
|
|
656
|
+
|
|
657
|
+
protected onInit() {
|
|
658
|
+
this.listenTo(this.bus, 'order:completed', ({ orderId }) => {
|
|
659
|
+
this.ordersResource.reload();
|
|
660
|
+
this.notificationsResource.addNotification(`Order ${orderId} completed`);
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
```tsx
|
|
667
|
+
function DashboardPage() {
|
|
668
|
+
useLocal(() => new DashboardController());
|
|
669
|
+
return (
|
|
670
|
+
<div>
|
|
671
|
+
<OrdersPanel />
|
|
672
|
+
<NotificationsPanel />
|
|
673
|
+
</div>
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
Controllers are rare — prefer parent ViewModel with props (Pattern A) when possible.
|
|
679
|
+
|
|
680
|
+
---
|
|
681
|
+
|
|
682
|
+
## PersistentCollection Lifecycle
|
|
683
|
+
|
|
684
|
+
```typescript
|
|
685
|
+
// WebStorageCollection: auto-hydrates (synchronous localStorage)
|
|
686
|
+
class CartCollection extends WebStorageCollection<CartItem> {
|
|
687
|
+
protected readonly storageKey = 'cart';
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ViewModel — no hydrate() needed
|
|
691
|
+
protected onInit() {
|
|
692
|
+
if (this.collection.length === 0) this.loadFromServer();
|
|
693
|
+
}
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
```typescript
|
|
697
|
+
// IndexedDBCollection: requires async hydrate()
|
|
698
|
+
class MessagesCollection extends IndexedDBCollection<Message> {
|
|
699
|
+
protected readonly storageKey = 'messages';
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ViewModel — must hydrate before accessing data
|
|
703
|
+
protected async onInit() {
|
|
704
|
+
await this.collection.hydrate();
|
|
705
|
+
if (this.collection.length === 0) this.loadFromServer();
|
|
706
|
+
}
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
Mutations persist automatically after hydration. Override `serialize()`/`deserialize()` for custom encoding. Override `onPersistError()` for error handling.
|
|
710
|
+
|
|
711
|
+
---
|
|
712
|
+
|
|
564
713
|
## Error Handling Layers
|
|
565
714
|
|
|
566
715
|
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
|
+
```
|
|
@@ -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
|
|
@@ -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
|