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
@@ -0,0 +1,92 @@
1
+ import { Trackable } from './Trackable';
2
+
3
+ /**
4
+ * Page-based pagination state manager with array slicing pipeline.
5
+ * Tracks current page and page size, provides navigation helpers.
6
+ * Subscribable — auto-tracked when used as a ViewModel property.
7
+ */
8
+ export class Pagination extends Trackable {
9
+ private _page: number = 1;
10
+ private _pageSize: number;
11
+
12
+ constructor(options?: { pageSize?: number }) {
13
+ super();
14
+ this._pageSize = options?.pageSize ?? 10;
15
+ }
16
+
17
+ // ── Readable state ──
18
+
19
+ /** Current page number (1-based). */
20
+ get page(): number {
21
+ return this._page;
22
+ }
23
+
24
+ /** Number of items per page. */
25
+ get pageSize(): number {
26
+ return this._pageSize;
27
+ }
28
+
29
+ // ── Derived (require total) ──
30
+
31
+ /** Total number of pages for the given item count. */
32
+ pageCount(total: number): number {
33
+ return Math.max(1, Math.ceil(total / this._pageSize));
34
+ }
35
+
36
+ /** Whether there is a next page available. */
37
+ hasNext(total: number): boolean {
38
+ return this._page < this.pageCount(total);
39
+ }
40
+
41
+ /** Whether there is a previous page available. */
42
+ hasPrev(): boolean {
43
+ return this._page > 1;
44
+ }
45
+
46
+ // ── Actions ──
47
+
48
+ /** Navigate to a specific page (clamped to >= 1). */
49
+ setPage(page: number): void {
50
+ const clamped = Math.max(1, Math.floor(page));
51
+ if (clamped === this._page) return;
52
+ this._page = clamped;
53
+ this.notify();
54
+ }
55
+
56
+ /** Change the page size and reset to page 1. */
57
+ setPageSize(size: number): void {
58
+ if (size < 1) return;
59
+ this._pageSize = size;
60
+ this._page = 1;
61
+ this.notify();
62
+ }
63
+
64
+ /** Advance to the next page. */
65
+ nextPage(): void {
66
+ this._page++;
67
+ this.notify();
68
+ }
69
+
70
+ /** Go back to the previous page. No-op if already on page 1. */
71
+ prevPage(): void {
72
+ if (this._page > 1) {
73
+ this._page--;
74
+ this.notify();
75
+ }
76
+ }
77
+
78
+ /** Reset to page 1. */
79
+ reset(): void {
80
+ if (this._page === 1) return;
81
+ this._page = 1;
82
+ this.notify();
83
+ }
84
+
85
+ // ── Pipeline ──
86
+
87
+ /** Slice an array to the current page window. Returns the page subset. */
88
+ apply<T>(items: T[]): T[] {
89
+ const start = (this._page - 1) * this._pageSize;
90
+ return items.slice(start, start + this._pageSize);
91
+ }
92
+ }
package/src/Pending.md ADDED
@@ -0,0 +1,380 @@
1
+ # Pending
2
+
3
+ Per-item operation queue with retry and status tracking. Manages optimistic update lifecycle with exponential backoff on transient errors. Extends [`Trackable`](./Trackable.md) — subscribable, disposable, and auto-bound.
4
+
5
+ ---
6
+
7
+ ## When to Use
8
+
9
+ Use Pending as a Resource property to track per-item server operations that may fail transiently. Pending provides:
10
+
11
+ - **Per-item status indicators** — show users which items are saving, retrying, or failed
12
+ - **Automatic retry with backoff** — network errors, timeouts, and 5xx errors retry automatically
13
+ - **Error classification** — 4xx errors (auth, validation) fail immediately without retry
14
+ - **Survive unmount** — lives on the singleton Resource, not the component-scoped ViewModel
15
+ - **Typed metadata** — attach display context (names, previews) to operations for rich UIs
16
+
17
+ For simple fire-and-forget operations where you don't need per-item status, use standard async tracking instead.
18
+
19
+ ---
20
+
21
+ ## Creating an Instance
22
+
23
+ ```typescript
24
+ import { Pending } from 'mvc-kit';
25
+
26
+ // String keys (default)
27
+ readonly pending = new Pending<string>();
28
+
29
+ // Numeric keys
30
+ readonly pending = new Pending<number>();
31
+
32
+ // With typed metadata for UI display
33
+ interface SendMeta { recipientName: string; preview: string }
34
+ readonly pending = new Pending<string, SendMeta>();
35
+ ```
36
+
37
+ ### Custom Configuration via Subclass
38
+
39
+ Override static properties (Channel pattern):
40
+
41
+ ```typescript
42
+ class AggressivePending extends Pending<string> {
43
+ static override MAX_RETRIES = 10;
44
+ static override RETRY_BASE = 500;
45
+ static override RETRY_MAX = 60000;
46
+ static override RETRY_FACTOR = 1.5;
47
+ }
48
+ ```
49
+
50
+ ---
51
+
52
+ ## API
53
+
54
+ ### Readable State
55
+
56
+ #### `getStatus(id: K): PendingOperation<Meta> | null`
57
+
58
+ Frozen snapshot of the operation's current state. Returns `null` if no operation exists for the given ID.
59
+
60
+ ```typescript
61
+ interface PendingOperation<Meta = unknown> {
62
+ readonly status: 'active' | 'retrying' | 'failed';
63
+ readonly operation: string;
64
+ readonly attempts: number;
65
+ readonly maxRetries: number;
66
+ readonly error: string | null;
67
+ readonly errorCode: AppError['code'] | null;
68
+ readonly nextRetryAt: number | null;
69
+ readonly createdAt: number;
70
+ readonly meta: Meta | null;
71
+ }
72
+ ```
73
+
74
+ The `meta` field contains the metadata passed to `enqueue()`, or `null` if none was provided.
75
+
76
+ #### `has(id: K): boolean`
77
+
78
+ Whether an operation exists for the given ID.
79
+
80
+ #### `count: number`
81
+
82
+ Total number of operations (all statuses).
83
+
84
+ #### `hasPending: boolean`
85
+
86
+ Whether any operations are in-flight (active or retrying). Does **not** include failed operations — use `hasFailed` for those.
87
+
88
+ #### `hasFailed: boolean`
89
+
90
+ Whether any operations are in `'failed'` status.
91
+
92
+ #### `failedCount: number`
93
+
94
+ Number of operations in `'failed'` status. Useful for rendering "N operations failed" banners.
95
+
96
+ #### `entries: readonly PendingEntry<K, Meta>[]`
97
+
98
+ All operations as a frozen array. Each entry extends `PendingOperation<Meta>` with an `id: K` field. Cached until the next mutation — reference-stable between reads.
99
+
100
+ ```typescript
101
+ interface PendingEntry<K extends string | number, Meta = unknown>
102
+ extends PendingOperation<Meta> {
103
+ readonly id: K;
104
+ }
105
+ ```
106
+
107
+ Use for rendering lists of pending/failed operations (e.g., in modals or status panels):
108
+
109
+ ```typescript
110
+ for (const entry of vm.pending.entries) {
111
+ entry.id; // K
112
+ entry.status; // 'active' | 'retrying' | 'failed'
113
+ entry.operation; // string
114
+ entry.meta; // Meta | null
115
+ }
116
+ ```
117
+
118
+ ### Core API
119
+
120
+ #### `enqueue(id: K, operation: string, execute: (signal: AbortSignal) => Promise<void>, meta?: Meta): void`
121
+
122
+ Fire-and-forget. Enqueues an operation and starts processing via microtask. If the same ID already has a pending operation, it is superseded (previous signal aborted).
123
+
124
+ ```typescript
125
+ // In a Resource method:
126
+ async deleteItem(id: string) {
127
+ this.optimistic(() => this.remove(id));
128
+ this.pending.enqueue(id, 'delete', async (signal) => {
129
+ await api.deleteItem(id, signal);
130
+ });
131
+ }
132
+
133
+ // With metadata for UI display:
134
+ enqueueSend(tempId: string, text: string, recipientName: string) {
135
+ this.pending.enqueue(tempId, 'send', async (signal) => {
136
+ await api.sendMessage(tempId, text, signal);
137
+ }, { recipientName, preview: text.slice(0, 50) });
138
+ }
139
+ ```
140
+
141
+ The `signal` parameter is owned by Pending — do **not** pass `this.disposeSignal` from a ViewModel.
142
+
143
+ ### Controls
144
+
145
+ #### `retry(id: K): void`
146
+
147
+ Re-process a failed operation. Resets attempts to 0. No-op if the operation is not in `'failed'` status.
148
+
149
+ #### `retryAll(): void`
150
+
151
+ Retry all failed operations.
152
+
153
+ #### `cancel(id: K): void`
154
+
155
+ Abort an in-flight operation's signal, clear its retry timer, and remove it. Works on any status.
156
+
157
+ #### `cancelAll(): void`
158
+
159
+ Cancel all operations.
160
+
161
+ #### `dismiss(id: K): void`
162
+
163
+ Remove a failed operation without retrying. No-op if the operation is not in `'failed'` status. Use for "dismiss" buttons in failure UIs.
164
+
165
+ #### `dismissAll(): void`
166
+
167
+ Remove all failed operations without retrying. Single notification. Does not affect active or retrying operations.
168
+
169
+ ### Hooks
170
+
171
+ Override in a subclass for side effects:
172
+
173
+ #### `protected isRetryable(error: unknown): boolean`
174
+
175
+ Determines whether an error should trigger retry. Default: retries on `network`, `timeout`, and `server_error` codes.
176
+
177
+ #### `protected onConfirmed?(id: K, operation: string): void`
178
+
179
+ Called when an operation succeeds.
180
+
181
+ #### `protected onFailed?(id: K, operation: string, error: unknown): void`
182
+
183
+ Called when an operation fails permanently (non-retryable or max retries exceeded).
184
+
185
+ ### Subscribable Interface
186
+
187
+ #### `subscribe(cb: () => void): () => void`
188
+
189
+ Subscribe to state changes. Returns an unsubscribe function. Auto-tracked by ViewModel when assigned as a property.
190
+
191
+ ### Lifecycle
192
+
193
+ #### `disposed: boolean`
194
+
195
+ Whether this instance has been disposed.
196
+
197
+ #### `dispose(): void`
198
+
199
+ Cancels all operations, clears all listeners. Called automatically when the owning Resource is torn down.
200
+
201
+ ---
202
+
203
+ ## ViewModel Integration
204
+
205
+ Pending lives on the **singleton Resource** so operations survive component unmount. The ViewModel holds a reference for auto-tracking:
206
+
207
+ ```typescript
208
+ class ItemsResource extends Resource<Item> {
209
+ readonly pending = new Pending<string>();
210
+
211
+ async deleteItem(id: string) {
212
+ this.optimistic(() => this.remove(id));
213
+ this.pending.enqueue(id, 'delete', async (signal) => {
214
+ await api.deleteItem(id, signal);
215
+ });
216
+ }
217
+
218
+ protected override onDispose() {
219
+ this.pending.dispose();
220
+ }
221
+ }
222
+
223
+ class ItemsVM extends ViewModel<{ filter: string }> {
224
+ private resource = singleton(ItemsResource);
225
+
226
+ // Auto-tracked: getter recomputes when pending status changes
227
+ get pending() { return this.resource.pending; }
228
+ get hasFailed() { return this.resource.pending.hasFailed; }
229
+ get failedCount() { return this.resource.pending.failedCount; }
230
+
231
+ get items() {
232
+ return this.resource.items.filter(i =>
233
+ i.name.includes(this.state.filter),
234
+ );
235
+ }
236
+
237
+ deleteItem(id: string) {
238
+ this.resource.deleteItem(id);
239
+ }
240
+
241
+ retryFailed() {
242
+ this.resource.pending.retryAll();
243
+ }
244
+
245
+ dismissFailed() {
246
+ this.resource.pending.dismissAll();
247
+ }
248
+ }
249
+ ```
250
+
251
+ ### Signal Ownership
252
+
253
+ The `signal` passed to `enqueue`'s callback is owned by Pending. It is aborted when:
254
+ - The operation is superseded (same ID enqueued again)
255
+ - The operation is cancelled via `cancel()` / `cancelAll()`
256
+ - The Pending instance is disposed
257
+
258
+ Do **not** pass `vm.disposeSignal` — that would abort the operation when the component unmounts, defeating the purpose of singleton-scoped resilience.
259
+
260
+ ---
261
+
262
+ ## React Usage
263
+
264
+ ```tsx
265
+ function ItemRow({ item, vm }: { item: Item; vm: ItemsVM }) {
266
+ const status = vm.pending.getStatus(item.id);
267
+
268
+ return (
269
+ <tr>
270
+ <td>{item.name}</td>
271
+ <td>
272
+ {status?.status === 'retrying' && <span>Retrying...</span>}
273
+ {status?.status === 'failed' && (
274
+ <>
275
+ <span>Failed: {status.error}</span>
276
+ <button onClick={() => vm.pending.retry(item.id)}>Retry</button>
277
+ <button onClick={() => vm.pending.dismiss(item.id)}>Dismiss</button>
278
+ </>
279
+ )}
280
+ </td>
281
+ </tr>
282
+ );
283
+ }
284
+
285
+ function FailedBanner({ vm }: { vm: ItemsVM }) {
286
+ if (!vm.hasFailed) return null;
287
+ return (
288
+ <div>
289
+ {vm.failedCount} operation(s) failed.
290
+ <button onClick={() => vm.retryFailed()}>Retry all</button>
291
+ <button onClick={() => vm.dismissFailed()}>Dismiss all</button>
292
+ </div>
293
+ );
294
+ }
295
+ ```
296
+
297
+ ### Pending Operations Modal with Meta
298
+
299
+ Use `entries` and typed metadata to render rich pending operations UIs:
300
+
301
+ ```typescript
302
+ // Resource with typed metadata
303
+ interface SendMeta { recipientName: string; preview: string }
304
+
305
+ class MessagesResource extends Resource<MessageState> {
306
+ readonly pending = new Pending<string, SendMeta>();
307
+
308
+ enqueueSend(tempId: string, convId: string, text: string, recipientName: string) {
309
+ this.pending.enqueue(tempId, 'send', async (signal) => {
310
+ await api.sendMessage(convId, text, signal);
311
+ }, { recipientName, preview: text.slice(0, 50) });
312
+ }
313
+ }
314
+ ```
315
+
316
+ ```tsx
317
+ // Modal listing all pending/failed operations
318
+ function PendingModal({ pending }: { pending: Pending<string, SendMeta> }) {
319
+ return (
320
+ <ul>
321
+ {pending.entries.map(entry => (
322
+ <li key={entry.id}>
323
+ <strong>To: {entry.meta?.recipientName}</strong>
324
+ <span>{entry.meta?.preview}</span>
325
+ <span>{entry.status} (attempt {entry.attempts}/{entry.maxRetries})</span>
326
+ {entry.status === 'failed' && (
327
+ <>
328
+ <button onClick={() => pending.retry(entry.id)}>Retry</button>
329
+ <button onClick={() => pending.dismiss(entry.id)}>Dismiss</button>
330
+ </>
331
+ )}
332
+ </li>
333
+ ))}
334
+ </ul>
335
+ );
336
+ }
337
+ ```
338
+
339
+ ---
340
+
341
+ ## Error Classification
342
+
343
+ Pending uses `classifyError()` from `src/errors.ts` to determine retry behavior:
344
+
345
+ | Error Code | Retries? | Examples |
346
+ |---|---|---|
347
+ | `network` | Yes | `TypeError: Failed to fetch` |
348
+ | `timeout` | Yes | `TimeoutError` |
349
+ | `server_error` | Yes | HTTP 500, 502, 503 |
350
+ | `unauthorized` | No | HTTP 401 |
351
+ | `forbidden` | No | HTTP 403 |
352
+ | `not_found` | No | HTTP 404 |
353
+ | `validation` | No | HTTP 422 |
354
+ | `rate_limited` | No | HTTP 429 |
355
+ | `unknown` | No | Unrecognized errors |
356
+ | `abort` | Silent remove | `AbortError` |
357
+
358
+ Override `isRetryable()` in a subclass to customize (e.g., to retry rate-limited requests).
359
+
360
+ ---
361
+
362
+ ## Configuration
363
+
364
+ | Static Property | Default | Description |
365
+ |---|---|---|
366
+ | `MAX_RETRIES` | `5` | Maximum retry attempts before marking as failed |
367
+ | `RETRY_BASE` | `1000` | Base delay (ms) for exponential backoff |
368
+ | `RETRY_MAX` | `30000` | Maximum delay cap (ms) |
369
+ | `RETRY_FACTOR` | `2` | Exponential multiplier |
370
+
371
+ Backoff formula (same as Channel): `Math.random() * Math.min(BASE * FACTOR^attempt, MAX)`
372
+
373
+ ## Method Binding
374
+
375
+ All public methods are auto-bound in the constructor. You can pass them point-free as callbacks without losing `this` context:
376
+
377
+ ```tsx
378
+ const { retryAll, dismissAll } = pending;
379
+ <button onClick={retryAll}>Retry All</button> // point-free works
380
+ ```