mvc-kit 2.12.0 → 2.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/agent-config/bin/postinstall.mjs +5 -3
  2. package/agent-config/bin/setup.mjs +3 -4
  3. package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
  4. package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
  5. package/agent-config/lib/install-claude.mjs +19 -33
  6. package/dist/Model.cjs +9 -1
  7. package/dist/Model.cjs.map +1 -1
  8. package/dist/Model.d.ts +1 -1
  9. package/dist/Model.d.ts.map +1 -1
  10. package/dist/Model.js +9 -1
  11. package/dist/Model.js.map +1 -1
  12. package/dist/ViewModel.cjs +9 -1
  13. package/dist/ViewModel.cjs.map +1 -1
  14. package/dist/ViewModel.d.ts +1 -1
  15. package/dist/ViewModel.d.ts.map +1 -1
  16. package/dist/ViewModel.js +9 -1
  17. package/dist/ViewModel.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/mvc-kit.cjs +3 -0
  21. package/dist/mvc-kit.cjs.map +1 -1
  22. package/dist/mvc-kit.js +3 -0
  23. package/dist/mvc-kit.js.map +1 -1
  24. package/dist/produceDraft.cjs +105 -0
  25. package/dist/produceDraft.cjs.map +1 -0
  26. package/dist/produceDraft.d.ts +19 -0
  27. package/dist/produceDraft.d.ts.map +1 -0
  28. package/dist/produceDraft.js +105 -0
  29. package/dist/produceDraft.js.map +1 -0
  30. package/package.json +4 -2
  31. package/src/Channel.md +408 -0
  32. package/src/Channel.test.ts +957 -0
  33. package/src/Channel.ts +429 -0
  34. package/src/Collection.md +533 -0
  35. package/src/Collection.test.ts +1559 -0
  36. package/src/Collection.ts +653 -0
  37. package/src/Controller.md +306 -0
  38. package/src/Controller.test.ts +380 -0
  39. package/src/Controller.ts +90 -0
  40. package/src/EventBus.md +308 -0
  41. package/src/EventBus.test.ts +295 -0
  42. package/src/EventBus.ts +110 -0
  43. package/src/Feed.md +218 -0
  44. package/src/Feed.test.ts +442 -0
  45. package/src/Feed.ts +101 -0
  46. package/src/Model.md +524 -0
  47. package/src/Model.test.ts +642 -0
  48. package/src/Model.ts +260 -0
  49. package/src/Pagination.md +168 -0
  50. package/src/Pagination.test.ts +244 -0
  51. package/src/Pagination.ts +92 -0
  52. package/src/Pending.md +380 -0
  53. package/src/Pending.test.ts +1719 -0
  54. package/src/Pending.ts +390 -0
  55. package/src/PersistentCollection.md +183 -0
  56. package/src/PersistentCollection.test.ts +649 -0
  57. package/src/PersistentCollection.ts +375 -0
  58. package/src/Resource.ViewModel.test.ts +503 -0
  59. package/src/Resource.md +239 -0
  60. package/src/Resource.test.ts +786 -0
  61. package/src/Resource.ts +231 -0
  62. package/src/Selection.md +155 -0
  63. package/src/Selection.test.ts +326 -0
  64. package/src/Selection.ts +117 -0
  65. package/src/Service.md +440 -0
  66. package/src/Service.test.ts +241 -0
  67. package/src/Service.ts +72 -0
  68. package/src/Sorting.md +170 -0
  69. package/src/Sorting.test.ts +334 -0
  70. package/src/Sorting.ts +135 -0
  71. package/src/Trackable.md +166 -0
  72. package/src/Trackable.test.ts +236 -0
  73. package/src/Trackable.ts +129 -0
  74. package/src/ViewModel.async.test.ts +813 -0
  75. package/src/ViewModel.derived.test.ts +1583 -0
  76. package/src/ViewModel.md +1111 -0
  77. package/src/ViewModel.test.ts +1236 -0
  78. package/src/ViewModel.ts +800 -0
  79. package/src/bindPublicMethods.test.ts +126 -0
  80. package/src/bindPublicMethods.ts +48 -0
  81. package/src/env.d.ts +5 -0
  82. package/src/errors.test.ts +155 -0
  83. package/src/errors.ts +133 -0
  84. package/src/index.ts +49 -0
  85. package/src/produceDraft.md +90 -0
  86. package/src/produceDraft.test.ts +394 -0
  87. package/src/produceDraft.ts +168 -0
  88. package/src/react/components/CardList.md +97 -0
  89. package/src/react/components/CardList.test.tsx +142 -0
  90. package/src/react/components/CardList.tsx +68 -0
  91. package/src/react/components/DataTable.md +179 -0
  92. package/src/react/components/DataTable.test.tsx +599 -0
  93. package/src/react/components/DataTable.tsx +267 -0
  94. package/src/react/components/InfiniteScroll.md +116 -0
  95. package/src/react/components/InfiniteScroll.test.tsx +218 -0
  96. package/src/react/components/InfiniteScroll.tsx +70 -0
  97. package/src/react/components/types.ts +90 -0
  98. package/src/react/derived.test.tsx +261 -0
  99. package/src/react/guards.ts +24 -0
  100. package/src/react/index.ts +40 -0
  101. package/src/react/provider.test.tsx +143 -0
  102. package/src/react/provider.tsx +55 -0
  103. package/src/react/strict-mode.test.tsx +266 -0
  104. package/src/react/types.ts +25 -0
  105. package/src/react/use-event-bus.md +214 -0
  106. package/src/react/use-event-bus.test.tsx +168 -0
  107. package/src/react/use-event-bus.ts +40 -0
  108. package/src/react/use-instance.md +204 -0
  109. package/src/react/use-instance.test.tsx +350 -0
  110. package/src/react/use-instance.ts +60 -0
  111. package/src/react/use-local.md +457 -0
  112. package/src/react/use-local.rapid-remount.test.tsx +503 -0
  113. package/src/react/use-local.test.tsx +692 -0
  114. package/src/react/use-local.ts +165 -0
  115. package/src/react/use-model.md +364 -0
  116. package/src/react/use-model.test.tsx +394 -0
  117. package/src/react/use-model.ts +161 -0
  118. package/src/react/use-singleton.md +415 -0
  119. package/src/react/use-singleton.test.tsx +296 -0
  120. package/src/react/use-singleton.ts +69 -0
  121. package/src/react/use-subscribe-only.ts +39 -0
  122. package/src/react/use-teardown.md +169 -0
  123. package/src/react/use-teardown.test.tsx +86 -0
  124. package/src/react/use-teardown.ts +27 -0
  125. package/src/react-native/NativeCollection.test.ts +250 -0
  126. package/src/react-native/NativeCollection.ts +138 -0
  127. package/src/react-native/index.ts +1 -0
  128. package/src/singleton.md +310 -0
  129. package/src/singleton.test.ts +204 -0
  130. package/src/singleton.ts +70 -0
  131. package/src/types.ts +70 -0
  132. package/src/walkPrototypeChain.ts +22 -0
  133. package/src/web/IndexedDBCollection.test.ts +235 -0
  134. package/src/web/IndexedDBCollection.ts +66 -0
  135. package/src/web/WebStorageCollection.test.ts +214 -0
  136. package/src/web/WebStorageCollection.ts +116 -0
  137. package/src/web/idb.ts +184 -0
  138. package/src/web/index.ts +2 -0
  139. package/src/wrapAsyncMethods.ts +249 -0
@@ -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
+ ```