mvc-kit 2.12.0 → 2.12.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.
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 +10 -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
package/src/Channel.md ADDED
@@ -0,0 +1,408 @@
1
+ # Channel
2
+
3
+ A Channel manages a persistent external connection (WebSocket, SSE, or any transport) with built-in auto-reconnect, typed message routing, and subscribable connection status. It implements `Subscribable<ChannelStatus>`, `Initializable`, and `Disposable`.
4
+
5
+ ---
6
+
7
+ ## Subclass Contract
8
+
9
+ Extend `Channel<MessageMap>` and implement two abstract methods:
10
+
11
+ ```typescript
12
+ interface ChatMessages {
13
+ message: { userId: string; text: string };
14
+ typing: { userId: string };
15
+ presence: { online: string[] };
16
+ }
17
+
18
+ class ChatChannel extends Channel<ChatMessages> {
19
+ private ws: WebSocket | null = null;
20
+
21
+ protected open(signal: AbortSignal): void {
22
+ this.ws = new WebSocket('wss://chat.example.com');
23
+
24
+ this.ws.onopen = () => {
25
+ // Connection established — framework handles status transition
26
+ };
27
+
28
+ this.ws.onmessage = (e) => {
29
+ const { type, payload } = JSON.parse(e.data);
30
+ this.receive(type, payload); // dispatch to on() handlers
31
+ };
32
+
33
+ this.ws.onclose = () => {
34
+ if (!signal.aborted) {
35
+ this.disconnected(); // triggers auto-reconnect
36
+ }
37
+ };
38
+
39
+ this.ws.onerror = () => this.ws?.close();
40
+
41
+ signal.addEventListener('abort', () => this.ws?.close());
42
+ }
43
+
44
+ protected close(): void {
45
+ this.ws?.close();
46
+ this.ws = null;
47
+ }
48
+ }
49
+ ```
50
+
51
+ | Method | Purpose |
52
+ |---|---|
53
+ | `open(signal)` | Establish the connection. The `signal` aborts on disconnect or dispose. Can return `void` or `Promise<void>`. |
54
+ | `close()` | Tear down the transport. Must not throw (errors are swallowed during dispose). |
55
+
56
+ ### Synchronous vs Async Open
57
+
58
+ `open()` can be synchronous (returns `void`) or asynchronous (returns `Promise<void>`). When synchronous, the channel transitions to `connected` immediately. When async, it transitions after the promise resolves. If it rejects, reconnect is scheduled.
59
+
60
+ ---
61
+
62
+ ## Connection Status
63
+
64
+ The `state` property exposes a frozen `ChannelStatus` object:
65
+
66
+ ```typescript
67
+ interface ChannelStatus {
68
+ readonly connected: boolean; // true when open() succeeded
69
+ readonly reconnecting: boolean; // true during backoff/retry
70
+ readonly attempt: number; // current reconnect attempt (0 when connected or idle)
71
+ readonly error: string | null; // last error message, or null
72
+ }
73
+ ```
74
+
75
+ Initial status is `{ connected: false, reconnecting: false, attempt: 0, error: null }`.
76
+
77
+ ### Status Transitions
78
+
79
+ ```
80
+ Idle ──connect()──→ Connecting ──open() succeeds──→ Connected
81
+ │ │
82
+ open() fails disconnected()
83
+ │ │
84
+ ▼ ▼
85
+ Reconnecting ◄───────────────── Reconnecting
86
+ │ │
87
+ timer fires → Connecting timer fires → Connecting
88
+
89
+ MAX_ATTEMPTS exceeded → Idle (with error)
90
+ ```
91
+
92
+ ### Subscribing to Status Changes
93
+
94
+ Channel implements `Subscribable<ChannelStatus>`. Listeners receive `(next, prev)`:
95
+
96
+ ```typescript
97
+ channel.subscribe((next, prev) => {
98
+ console.log(`${prev.connected} → ${next.connected}`);
99
+ });
100
+ ```
101
+
102
+ Status notifications are **deduplicated** — if the status object hasn't changed (all four fields match), no notification fires. This means calling `disconnect()` on an already-idle channel triggers no listeners.
103
+
104
+ ---
105
+
106
+ ## Connection Control
107
+
108
+ ```typescript
109
+ channel.connect(); // initiate connection (idempotent while connected/connecting)
110
+ channel.disconnect(); // manual disconnect, cancels pending reconnect, resets status
111
+ ```
112
+
113
+ **`connect()` behavior:**
114
+ - Idempotent when already connected or connecting — calling it again is a no-op.
115
+ - If called during reconnecting, it cancels the backoff timer and immediately attempts a fresh connection (attempt resets to 0).
116
+ - Ignored after dispose (DEV mode logs a warning).
117
+ - DEV mode warns if called before `init()`.
118
+
119
+ **`disconnect()` behavior:**
120
+ - Cancels any pending reconnect timer.
121
+ - Aborts the in-flight `open()` signal.
122
+ - Calls `close()` if connected or connecting.
123
+ - Resets status to idle (`connected: false, reconnecting: false, attempt: 0, error: null`).
124
+ - No-op if idle or disposed.
125
+
126
+ **Reconnect cycle:**
127
+ After `disconnect()` + `connect()`, the channel reconnects cleanly — each cycle creates a fresh `AbortController` for the open signal.
128
+
129
+ ---
130
+
131
+ ## Message Routing
132
+
133
+ Subclasses dispatch incoming data via `this.receive(type, payload)`. Consumers subscribe via `on()` and `once()`:
134
+
135
+ ```typescript
136
+ // Subscribe to all messages of a type
137
+ const unsub = channel.on('message', ({ userId, text }) => {
138
+ console.log(`${userId}: ${text}`);
139
+ });
140
+
141
+ // Subscribe to only the first message
142
+ channel.once('presence', ({ online }) => {
143
+ console.log('Initial presence:', online);
144
+ });
145
+
146
+ // Unsubscribe
147
+ unsub();
148
+ ```
149
+
150
+ **Key details:**
151
+ - `receive()` is **protected** — only the subclass can dispatch messages.
152
+ - Multiple handlers for the same type all fire in registration order.
153
+ - Handlers for different types are independent.
154
+ - `once()` auto-unsubscribes after the first delivery. Its unsubscribe function can cancel it before it fires.
155
+ - `on()` on a disposed channel returns a no-op unsubscribe.
156
+ - `receive()` after dispose is silently ignored (DEV mode logs a warning).
157
+
158
+ ---
159
+
160
+ ## Auto-Reconnect
161
+
162
+ When `disconnected()` is called from a connected or connecting state, the Channel schedules reconnection with exponential backoff and jitter.
163
+
164
+ ### Tuning via Static Properties
165
+
166
+ Override these on your subclass to control reconnect behavior:
167
+
168
+ ```typescript
169
+ class ChatChannel extends Channel<ChatMessages> {
170
+ static RECONNECT_BASE = 1000; // initial backoff (ms), default 1000
171
+ static RECONNECT_MAX = 30000; // max backoff cap (ms), default 30000
172
+ static RECONNECT_FACTOR = 2; // exponential multiplier, default 2
173
+ static MAX_ATTEMPTS = Infinity; // give up after N attempts, default Infinity
174
+ }
175
+ ```
176
+
177
+ ### Backoff Formula
178
+
179
+ ```
180
+ delay = random() * min(RECONNECT_BASE * RECONNECT_FACTOR^attempt, RECONNECT_MAX)
181
+ ```
182
+
183
+ The `random()` jitter prevents thundering-herd reconnect storms when many clients drop simultaneously.
184
+
185
+ ### MAX_ATTEMPTS
186
+
187
+ When `attempt > MAX_ATTEMPTS`, the channel stops reconnecting and transitions to idle with an error. The `error` field contains the last error message or `'Max reconnection attempts reached'`.
188
+
189
+ ### Interrupting Reconnect
190
+
191
+ - **`disconnect()`** — cancels the backoff timer, resets status to idle. No further attempts.
192
+ - **`connect()`** during reconnect — cancels the timer, starts a fresh attempt from attempt 0.
193
+ - **`dispose()`** — cancels everything. Timer is cleared, signals are aborted, transport is closed.
194
+
195
+ ### disconnected() Guards
196
+
197
+ `disconnected()` is only meaningful from connected or connecting states. If called while idle or already reconnecting, it's a no-op. This prevents double-reconnect from redundant close events.
198
+
199
+ ---
200
+
201
+ ## Lifecycle
202
+
203
+ ### init / dispose
204
+
205
+ ```typescript
206
+ const channel = new ChatChannel();
207
+ channel.init(); // sets initialized flag, calls onInit()
208
+ // ...use the channel...
209
+ channel.dispose(); // tears down everything
210
+ ```
211
+
212
+ - `init()` is **idempotent** — calling it twice only runs `onInit()` once.
213
+ - `init()` after `dispose()` is a no-op.
214
+ - `dispose()` is **idempotent** — safe to call multiple times.
215
+
216
+ ### onInit / onDispose
217
+
218
+ Optional lifecycle hooks:
219
+
220
+ ```typescript
221
+ class ChatChannel extends Channel<ChatMessages> {
222
+ protected onInit() {
223
+ // Set up external subscriptions, start timers, etc.
224
+ }
225
+
226
+ protected onDispose() {
227
+ // Final cleanup after everything else is torn down
228
+ }
229
+ }
230
+ ```
231
+
232
+ ### Dispose Sequence
233
+
234
+ When `dispose()` is called:
235
+
236
+ 1. Sets `disposed = true`
237
+ 2. Cancels pending reconnect timer
238
+ 3. Aborts the per-connection signal (`connectAbort`)
239
+ 4. Aborts the `disposeSignal`
240
+ 5. Calls `close()` (errors swallowed)
241
+ 6. Runs all registered `addCleanup()` callbacks
242
+ 7. Calls `onDispose()`
243
+ 8. Clears all status listeners and message handlers
244
+
245
+ ### disposeSignal
246
+
247
+ Lazy `AbortSignal` that aborts when the channel is disposed. The signal passed to `open()` is composed from both the dispose signal and a per-connection signal, so it aborts on either dispose **or** disconnect.
248
+
249
+ ```typescript
250
+ // In a ViewModel using a channel
251
+ protected onInit() {
252
+ this.channel.on('message', (msg) => {
253
+ this.set({ messages: [...this.state.messages, msg] });
254
+ });
255
+ this.channel.connect();
256
+ }
257
+ ```
258
+
259
+ ---
260
+
261
+ ## Infrastructure Helpers
262
+
263
+ ### addCleanup(fn)
264
+
265
+ Register a cleanup function that runs on dispose:
266
+
267
+ ```typescript
268
+ protected onInit() {
269
+ const unsub = externalService.on('event', this.handleEvent);
270
+ this.addCleanup(unsub);
271
+ }
272
+ ```
273
+
274
+ ### subscribeTo(source, listener)
275
+
276
+ Subscribe to another `Subscribable` with automatic cleanup on dispose:
277
+
278
+ ```typescript
279
+ protected onInit() {
280
+ this.subscribeTo(someOtherChannel, (status) => {
281
+ // React to another channel's status changes
282
+ });
283
+ }
284
+ ```
285
+
286
+ ### listenTo(source, event, handler)
287
+
288
+ Subscribe to a typed event on another Channel or EventBus with automatic cleanup on dispose:
289
+
290
+ ```typescript
291
+ protected onInit() {
292
+ this.listenTo(this.appBus, 'auth:logout', () => {
293
+ this.disconnect();
294
+ });
295
+ }
296
+ ```
297
+
298
+ ---
299
+
300
+ ## ViewModel Integration
301
+
302
+ Channels are typically singletons. ViewModels subscribe to them for status changes and message handling:
303
+
304
+ ```typescript
305
+ class ChatViewModel extends ViewModel<State> {
306
+ private channel = singleton(ChatChannel);
307
+
308
+ protected onInit() {
309
+ // Auto-tracked: channel status changes invalidate getters
310
+ this.subscribeTo(this.channel, () => {
311
+ this.set({ connectionStatus: this.channel.state });
312
+ });
313
+
314
+ // Message handling
315
+ const unsub = this.channel.on('message', (msg) => {
316
+ this.set({ messages: [...this.state.messages, msg] });
317
+ });
318
+ this.addCleanup(unsub);
319
+
320
+ this.channel.connect();
321
+ }
322
+
323
+ get isConnected() {
324
+ return this.channel.state.connected; // auto-tracked via subscribable detection
325
+ }
326
+ }
327
+ ```
328
+
329
+ ### pipeChannel — Channel-to-Collection Bridge
330
+
331
+ For the common pattern of piping Channel messages into a Collection, use `pipeChannel`:
332
+
333
+ ```typescript
334
+ class DashboardCardViewModel extends ViewModel {
335
+ private channel = singleton(DashboardChannel);
336
+ private collection = singleton(DashboardCollection);
337
+
338
+ protected onInit() {
339
+ this.pipeChannel(this.channel, 'data', this.collection);
340
+ this.channel.connect();
341
+ }
342
+ }
343
+ ```
344
+
345
+ This calls `channel.init()` (idempotent), subscribes to the event, and upserts each payload into the collection. Auto-cleanup on dispose and reset. Use `listenTo` directly if you need to transform or filter messages.
346
+
347
+ ### Auto-Tracking
348
+
349
+ Because Channel implements `Subscribable` (has a `subscribe` method), the ViewModel's getter memoization system auto-detects it. When the channel's status changes:
350
+
351
+ 1. The ViewModel's `_revision` counter increments
352
+ 2. Memoized getter caches invalidate
353
+ 3. Getters that read `channel.state` recompute on next access
354
+ 4. React re-renders via `useSyncExternalStore`
355
+
356
+ ### Singleton Pattern
357
+
358
+ ```typescript
359
+ const ch1 = singleton(ChatChannel);
360
+ const ch2 = singleton(ChatChannel);
361
+ ch1 === ch2; // true — same instance
362
+
363
+ teardownAll(); // disposes the channel along with all other singletons
364
+ ```
365
+
366
+ ---
367
+
368
+ ## Best Practices
369
+
370
+ **Always listen for abort in `open()`.** The signal passed to `open()` aborts on both disconnect and dispose. Attach a listener to clean up the transport:
371
+
372
+ ```typescript
373
+ signal.addEventListener('abort', () => this.ws?.close());
374
+ ```
375
+
376
+ **Call `disconnected()` from transport close events, not error events.** Let the transport's close handler trigger reconnect. Error events typically precede close — handling both causes double reconnect (which is safely guarded, but unnecessary).
377
+
378
+ **Don't call `disconnected()` if the signal is aborted.** When the abort signal fires, the framework is intentionally closing the connection (disconnect or dispose). Only call `disconnected()` for unexpected drops:
379
+
380
+ ```typescript
381
+ this.ws.onclose = () => {
382
+ if (!signal.aborted) this.disconnected();
383
+ };
384
+ ```
385
+
386
+ **Keep `close()` side-effect-free and safe.** It's called during dispose with errors swallowed, and can be called multiple times. Don't throw, and nullify references to prevent double-close issues.
387
+
388
+ **Use singleton resolution.** Channels represent persistent connections shared across ViewModels. Always resolve via `singleton(MyChannel)` — never `new MyChannel()` in multiple places.
389
+
390
+ **Override static config instead of overriding `_calculateDelay`.** The static properties (`RECONNECT_BASE`, `RECONNECT_MAX`, `RECONNECT_FACTOR`, `MAX_ATTEMPTS`) cover all tuning needs. Override `_calculateDelay` only if you need a completely custom backoff strategy (e.g., server-sent retry hints).
391
+
392
+ **Use `listenTo` for `on()` subscriptions in ViewModels.** While `subscribeTo` auto-registers cleanup for state subscriptions, message subscriptions via `channel.on()` need `listenTo` for automatic cleanup:
393
+
394
+ ```typescript
395
+ // Prescribed — auto-cleanup on dispose and reset
396
+ this.listenTo(this.channel, 'message', handler);
397
+ ```
398
+
399
+ > See `BEST_PRACTICES.md` for ViewModel integration patterns and prescribed usage.
400
+
401
+ ## Method Binding
402
+
403
+ All public methods are auto-bound in the constructor. You can pass them point-free as callbacks without losing `this` context:
404
+
405
+ ```tsx
406
+ const { connect, disconnect } = channel;
407
+ <button onClick={connect}>Connect</button> // point-free works
408
+ ```