mvc-kit 2.12.0 → 2.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-config/bin/postinstall.mjs +5 -3
- package/agent-config/bin/setup.mjs +3 -4
- package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
- package/agent-config/lib/install-claude.mjs +19 -33
- package/dist/Model.cjs +9 -1
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.d.ts +1 -1
- package/dist/Model.d.ts.map +1 -1
- package/dist/Model.js +9 -1
- package/dist/Model.js.map +1 -1
- package/dist/ViewModel.cjs +9 -1
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.d.ts +1 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/ViewModel.js +9 -1
- package/dist/ViewModel.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +3 -0
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +3 -0
- package/dist/mvc-kit.js.map +1 -1
- package/dist/produceDraft.cjs +105 -0
- package/dist/produceDraft.cjs.map +1 -0
- package/dist/produceDraft.d.ts +19 -0
- package/dist/produceDraft.d.ts.map +1 -0
- package/dist/produceDraft.js +105 -0
- package/dist/produceDraft.js.map +1 -0
- package/package.json +4 -2
- package/src/Channel.md +408 -0
- package/src/Channel.test.ts +957 -0
- package/src/Channel.ts +429 -0
- package/src/Collection.md +533 -0
- package/src/Collection.test.ts +1559 -0
- package/src/Collection.ts +653 -0
- package/src/Controller.md +306 -0
- package/src/Controller.test.ts +380 -0
- package/src/Controller.ts +90 -0
- package/src/EventBus.md +308 -0
- package/src/EventBus.test.ts +295 -0
- package/src/EventBus.ts +110 -0
- package/src/Feed.md +218 -0
- package/src/Feed.test.ts +442 -0
- package/src/Feed.ts +101 -0
- package/src/Model.md +524 -0
- package/src/Model.test.ts +642 -0
- package/src/Model.ts +260 -0
- package/src/Pagination.md +168 -0
- package/src/Pagination.test.ts +244 -0
- package/src/Pagination.ts +92 -0
- package/src/Pending.md +380 -0
- package/src/Pending.test.ts +1719 -0
- package/src/Pending.ts +390 -0
- package/src/PersistentCollection.md +183 -0
- package/src/PersistentCollection.test.ts +649 -0
- package/src/PersistentCollection.ts +375 -0
- package/src/Resource.ViewModel.test.ts +503 -0
- package/src/Resource.md +239 -0
- package/src/Resource.test.ts +786 -0
- package/src/Resource.ts +231 -0
- package/src/Selection.md +155 -0
- package/src/Selection.test.ts +326 -0
- package/src/Selection.ts +117 -0
- package/src/Service.md +440 -0
- package/src/Service.test.ts +241 -0
- package/src/Service.ts +72 -0
- package/src/Sorting.md +170 -0
- package/src/Sorting.test.ts +334 -0
- package/src/Sorting.ts +135 -0
- package/src/Trackable.md +166 -0
- package/src/Trackable.test.ts +236 -0
- package/src/Trackable.ts +129 -0
- package/src/ViewModel.async.test.ts +813 -0
- package/src/ViewModel.derived.test.ts +1583 -0
- package/src/ViewModel.md +1111 -0
- package/src/ViewModel.test.ts +1236 -0
- package/src/ViewModel.ts +800 -0
- package/src/bindPublicMethods.test.ts +126 -0
- package/src/bindPublicMethods.ts +48 -0
- package/src/env.d.ts +5 -0
- package/src/errors.test.ts +155 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +49 -0
- package/src/produceDraft.md +90 -0
- package/src/produceDraft.test.ts +394 -0
- package/src/produceDraft.ts +168 -0
- package/src/react/components/CardList.md +97 -0
- package/src/react/components/CardList.test.tsx +142 -0
- package/src/react/components/CardList.tsx +68 -0
- package/src/react/components/DataTable.md +179 -0
- package/src/react/components/DataTable.test.tsx +599 -0
- package/src/react/components/DataTable.tsx +267 -0
- package/src/react/components/InfiniteScroll.md +116 -0
- package/src/react/components/InfiniteScroll.test.tsx +218 -0
- package/src/react/components/InfiniteScroll.tsx +70 -0
- package/src/react/components/types.ts +90 -0
- package/src/react/derived.test.tsx +261 -0
- package/src/react/guards.ts +24 -0
- package/src/react/index.ts +40 -0
- package/src/react/provider.test.tsx +143 -0
- package/src/react/provider.tsx +55 -0
- package/src/react/strict-mode.test.tsx +266 -0
- package/src/react/types.ts +25 -0
- package/src/react/use-event-bus.md +214 -0
- package/src/react/use-event-bus.test.tsx +168 -0
- package/src/react/use-event-bus.ts +40 -0
- package/src/react/use-instance.md +204 -0
- package/src/react/use-instance.test.tsx +350 -0
- package/src/react/use-instance.ts +60 -0
- package/src/react/use-local.md +457 -0
- package/src/react/use-local.rapid-remount.test.tsx +503 -0
- package/src/react/use-local.test.tsx +692 -0
- package/src/react/use-local.ts +165 -0
- package/src/react/use-model.md +364 -0
- package/src/react/use-model.test.tsx +394 -0
- package/src/react/use-model.ts +161 -0
- package/src/react/use-singleton.md +415 -0
- package/src/react/use-singleton.test.tsx +296 -0
- package/src/react/use-singleton.ts +69 -0
- package/src/react/use-subscribe-only.ts +39 -0
- package/src/react/use-teardown.md +169 -0
- package/src/react/use-teardown.test.tsx +86 -0
- package/src/react/use-teardown.ts +27 -0
- package/src/react-native/NativeCollection.test.ts +250 -0
- package/src/react-native/NativeCollection.ts +138 -0
- package/src/react-native/index.ts +1 -0
- package/src/singleton.md +310 -0
- package/src/singleton.test.ts +204 -0
- package/src/singleton.ts +70 -0
- package/src/types.ts +70 -0
- package/src/walkPrototypeChain.ts +22 -0
- package/src/web/IndexedDBCollection.test.ts +235 -0
- package/src/web/IndexedDBCollection.ts +66 -0
- package/src/web/WebStorageCollection.test.ts +214 -0
- package/src/web/WebStorageCollection.ts +116 -0
- package/src/web/idb.ts +184 -0
- package/src/web/index.ts +2 -0
- package/src/wrapAsyncMethods.ts +249 -0
package/src/EventBus.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { Disposable } from './types';
|
|
2
|
+
import { bindPublicMethods } from './bindPublicMethods';
|
|
3
|
+
|
|
4
|
+
const PROTECTED_KEYS = new Set(['addCleanup']);
|
|
5
|
+
type Handler<T> = (payload: T) => void;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Typed pub/sub event bus.
|
|
9
|
+
*/
|
|
10
|
+
export class EventBus<E extends Record<string, any>> implements Disposable {
|
|
11
|
+
/** Phantom type brand — enables correct inference of E in generic helpers like useEvent(). */
|
|
12
|
+
declare readonly _types: E;
|
|
13
|
+
|
|
14
|
+
private _disposed = false;
|
|
15
|
+
private _handlers = new Map<keyof E, Set<Handler<unknown>>>();
|
|
16
|
+
private _abortController: AbortController | null = null;
|
|
17
|
+
private _cleanups: (() => void)[] | null = null;
|
|
18
|
+
|
|
19
|
+
constructor() {
|
|
20
|
+
bindPublicMethods(this, Object.prototype, PROTECTED_KEYS);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Whether this instance has been disposed. */
|
|
24
|
+
get disposed(): boolean {
|
|
25
|
+
return this._disposed;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** AbortSignal that fires when this instance is disposed. Lazily created. */
|
|
29
|
+
get disposeSignal(): AbortSignal {
|
|
30
|
+
if (!this._abortController) {
|
|
31
|
+
this._abortController = new AbortController();
|
|
32
|
+
}
|
|
33
|
+
return this._abortController.signal;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Emit an event with a payload.
|
|
38
|
+
*/
|
|
39
|
+
emit<K extends keyof E>(event: K, payload: E[K]): void {
|
|
40
|
+
if (this._disposed) {
|
|
41
|
+
throw new Error('Cannot emit on disposed EventBus');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const handlers = this._handlers.get(event);
|
|
45
|
+
if (handlers) {
|
|
46
|
+
for (const handler of handlers) {
|
|
47
|
+
handler(payload);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Subscribe to an event. Returns unsubscribe function.
|
|
54
|
+
*/
|
|
55
|
+
on<K extends keyof E>(event: K, handler: Handler<E[K]>): () => void {
|
|
56
|
+
if (this._disposed) {
|
|
57
|
+
return () => {};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let handlers = this._handlers.get(event);
|
|
61
|
+
if (!handlers) {
|
|
62
|
+
handlers = new Set();
|
|
63
|
+
this._handlers.set(event, handlers);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
handlers.add(handler as Handler<unknown>);
|
|
67
|
+
|
|
68
|
+
return () => {
|
|
69
|
+
handlers!.delete(handler as Handler<unknown>);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Subscribe to an event once. Auto-unsubscribes after first invocation.
|
|
75
|
+
*/
|
|
76
|
+
once<K extends keyof E>(event: K, handler: Handler<E[K]>): () => void {
|
|
77
|
+
const unsubscribe = this.on(event, (payload) => {
|
|
78
|
+
unsubscribe();
|
|
79
|
+
handler(payload);
|
|
80
|
+
});
|
|
81
|
+
return unsubscribe;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Tears down the instance, releasing all subscriptions and resources. */
|
|
85
|
+
dispose(): void {
|
|
86
|
+
if (this._disposed) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this._disposed = true;
|
|
91
|
+
this._abortController?.abort();
|
|
92
|
+
if (this._cleanups) {
|
|
93
|
+
for (const fn of this._cleanups) fn();
|
|
94
|
+
this._cleanups = null;
|
|
95
|
+
}
|
|
96
|
+
this.onDispose?.();
|
|
97
|
+
this._handlers.clear();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Registers a cleanup function to be called on dispose. @protected */
|
|
101
|
+
protected addCleanup(fn: () => void): void {
|
|
102
|
+
if (!this._cleanups) {
|
|
103
|
+
this._cleanups = [];
|
|
104
|
+
}
|
|
105
|
+
this._cleanups.push(fn);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Lifecycle hook called during dispose(). Override for custom teardown. @protected */
|
|
109
|
+
protected onDispose?(): void;
|
|
110
|
+
}
|
package/src/Feed.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Feed
|
|
2
|
+
|
|
3
|
+
Cursor-based feed state manager for server-side pagination (infinite scroll / load-more patterns). Optionally accumulates items across pages. Extends [`Trackable`](./Trackable.md) — subscribable, disposable, and auto-bound.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## When to Use
|
|
8
|
+
|
|
9
|
+
Use Feed as a ViewModel property to track cursor position, whether more pages are available, and optionally accumulate items across pages. Pair with `Resource.upsert()` for shared data caches, or use Feed's built-in `appendPage()`/`prependPage()` for component-scoped item lists.
|
|
10
|
+
|
|
11
|
+
For client-side page-based pagination, use `Pagination` instead.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Creating a Feed Instance
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import { Feed } from 'mvc-kit';
|
|
19
|
+
|
|
20
|
+
// Untyped (cursor/hasMore only)
|
|
21
|
+
readonly feed = new Feed();
|
|
22
|
+
|
|
23
|
+
// Typed (with item accumulation)
|
|
24
|
+
readonly feed = new Feed<Message>();
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Initial state: `cursor = null`, `hasMore = true`, `items = []`.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## API
|
|
32
|
+
|
|
33
|
+
### Readable State
|
|
34
|
+
|
|
35
|
+
#### `cursor: string | null`
|
|
36
|
+
|
|
37
|
+
Current pagination cursor. `null` before the first page is loaded.
|
|
38
|
+
|
|
39
|
+
#### `hasMore: boolean`
|
|
40
|
+
|
|
41
|
+
Whether the server reported more pages available. Starts as `true`.
|
|
42
|
+
|
|
43
|
+
#### `items: readonly T[]`
|
|
44
|
+
|
|
45
|
+
Accumulated items (from `appendPage`/`prependPage`). Returns a frozen array with a new reference on each change (safe for React rendering). Empty by default.
|
|
46
|
+
|
|
47
|
+
#### `count: number`
|
|
48
|
+
|
|
49
|
+
Number of accumulated items.
|
|
50
|
+
|
|
51
|
+
### Actions
|
|
52
|
+
|
|
53
|
+
#### `setResult(result: { hasMore: boolean; cursor?: string | null }): void`
|
|
54
|
+
|
|
55
|
+
Update cursor/hasMore only (does **not** affect items). Use when item storage is managed externally (e.g., via Resource):
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
const result = await fetchPosts(this.feed.cursor, 20, this.disposeSignal);
|
|
59
|
+
this.resource.upsert(...result.items);
|
|
60
|
+
this.feed.setResult(result);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
#### `appendPage(page: FeedPage<T>): void`
|
|
64
|
+
|
|
65
|
+
Append page items and update cursor/hasMore. Use for "load more" / infinite scroll:
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
const page = await this.service.getMessages(id, this.disposeSignal);
|
|
69
|
+
this.feed.appendPage(page); // items accessible via this.feed.items
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
#### `prependPage(page: FeedPage<T>): void`
|
|
73
|
+
|
|
74
|
+
Prepend page items and update cursor/hasMore. Use for "load newer" patterns (e.g., real-time feeds).
|
|
75
|
+
|
|
76
|
+
#### `push(...items: T[]): void`
|
|
77
|
+
|
|
78
|
+
Add items without affecting cursor/hasMore. Use for local insertions (e.g., optimistic sends):
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
this.feed.push(newMessage);
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
#### `filter(predicate: (item: T) => boolean): void`
|
|
85
|
+
|
|
86
|
+
Remove items that don't match the predicate. No-op (no notification) if nothing is filtered out. Does not affect cursor/hasMore:
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
this.feed.filter(msg => msg.id !== deletedId);
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
#### `replacePage(page: FeedPage<T>): void`
|
|
93
|
+
|
|
94
|
+
Replace all items and update cursor/hasMore atomically (single notification). Ideal for pull-to-refresh:
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
async refresh() {
|
|
98
|
+
const page = await this.service.getLatest(this.disposeSignal);
|
|
99
|
+
this.feed.replacePage(page); // clears old items, sets new ones in one go
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
#### `reset(): void`
|
|
104
|
+
|
|
105
|
+
Reset to initial state (`cursor = null`, `hasMore = true`, `items = []`). Use when filters change and you need to reload from the beginning.
|
|
106
|
+
|
|
107
|
+
### Subscribable Interface
|
|
108
|
+
|
|
109
|
+
#### `subscribe(cb: () => void): () => void`
|
|
110
|
+
|
|
111
|
+
Subscribe to state changes. Returns an unsubscribe function.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## FeedPage Type
|
|
116
|
+
|
|
117
|
+
A convenience type for server response shapes:
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
interface FeedPage<T> {
|
|
121
|
+
items: T[];
|
|
122
|
+
hasMore: boolean;
|
|
123
|
+
cursor?: string | null;
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## ViewModel Integration
|
|
130
|
+
|
|
131
|
+
### With Resource (shared data cache)
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
class SocialFeedVM extends ViewModel {
|
|
135
|
+
private resource = singleton(PostsResource);
|
|
136
|
+
readonly feed = new Feed();
|
|
137
|
+
|
|
138
|
+
get items() { return this.resource.items; }
|
|
139
|
+
get hasMore() { return this.feed.hasMore; }
|
|
140
|
+
|
|
141
|
+
protected onInit() {
|
|
142
|
+
if (this.resource.length === 0) this.loadMore();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async loadMore() {
|
|
146
|
+
const result = await fetchPosts(this.feed.cursor, 20, this.disposeSignal);
|
|
147
|
+
this.resource.upsert(...result.items);
|
|
148
|
+
this.feed.setResult(result);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### With Item Accumulation (component-scoped data)
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
class MessageThreadVM extends ViewModel<{ draft: string }> {
|
|
157
|
+
readonly feed = new Feed<Message>();
|
|
158
|
+
|
|
159
|
+
get messages() { return this.feed.items; }
|
|
160
|
+
|
|
161
|
+
async loadConversation(id: string) {
|
|
162
|
+
this.feed.reset();
|
|
163
|
+
const page = await this.service.getMessages(id, this.disposeSignal);
|
|
164
|
+
this.feed.appendPage(page);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async loadOlder() {
|
|
168
|
+
if (!this.feed.hasMore) return;
|
|
169
|
+
const page = await this.service.getMessages(id, this.disposeSignal, { cursor: this.feed.cursor });
|
|
170
|
+
this.feed.appendPage(page);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## React Usage with InfiniteScroll
|
|
178
|
+
|
|
179
|
+
```tsx
|
|
180
|
+
function SocialFeed() {
|
|
181
|
+
const [, vm] = useLocal(SocialFeedVM, {});
|
|
182
|
+
return (
|
|
183
|
+
<InfiniteScroll
|
|
184
|
+
hasMore={vm.hasMore}
|
|
185
|
+
loading={vm.async.loadMore?.loading}
|
|
186
|
+
onLoadMore={() => vm.loadMore()}
|
|
187
|
+
renderEnd={() => <p>You've seen it all!</p>}
|
|
188
|
+
>
|
|
189
|
+
<CardList
|
|
190
|
+
items={vm.items}
|
|
191
|
+
renderItem={post => <PostCard post={post} />}
|
|
192
|
+
/>
|
|
193
|
+
</InfiniteScroll>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Chat-style (reverse scroll)
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
<InfiniteScroll
|
|
202
|
+
hasMore={vm.feed.hasMore}
|
|
203
|
+
loading={vm.async.loadOlder.loading}
|
|
204
|
+
onLoadMore={() => vm.loadOlder()}
|
|
205
|
+
direction="up"
|
|
206
|
+
>
|
|
207
|
+
{vm.messages.map(msg => <MessageBubble key={msg.id} message={msg} />)}
|
|
208
|
+
</InfiniteScroll>
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Method Binding
|
|
212
|
+
|
|
213
|
+
All public methods are auto-bound in the constructor. You can pass them point-free as callbacks without losing `this` context:
|
|
214
|
+
|
|
215
|
+
```tsx
|
|
216
|
+
const { reset, appendPage } = feed;
|
|
217
|
+
<button onClick={reset}>Refresh</button> // point-free works
|
|
218
|
+
```
|